feat(analytics): Adds analytics dashboard (#358)

* add deps

* hook up dau from audit table to analytics page

* add audit event for code nav

* analytics dashboard

* add changelog entry

* add news entry

* smaller video and news data nit

* feedback
This commit is contained in:
Michael Sukkarieh 2025-06-20 14:57:05 -07:00 committed by GitHub
parent fb2ef05172
commit 4bb93c9f3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1814 additions and 97 deletions

View file

@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Added analytics dashboard. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358)
### Fixed ### Fixed
- Fixed issue where invites appeared to be created successfully, but were not actually being created in the database. [#359](https://github.com/sourcebot-dev/sourcebot/pull/359) - Fixed issue where invites appeared to be created successfully, but were not actually being created in the database. [#359](https://github.com/sourcebot-dev/sourcebot/pull/359)
### Changed
- Audit logging is now enabled by default. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358)
## [4.4.0] - 2025-06-18 ## [4.4.0] - 2025-06-18
### Added ### Added

View file

@ -36,6 +36,7 @@
] ]
}, },
"docs/features/code-navigation", "docs/features/code-navigation",
"docs/features/analytics",
"docs/features/mcp-server", "docs/features/mcp-server",
{ {
"group": "Agents", "group": "Agents",

View file

@ -12,8 +12,8 @@ action, and when the action took place.
This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment. This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment.
## Enabling Audit Logs ## Enabling/Disabling Audit Logs
Audit logs must be explicitly enabled by setting the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables) to `true` Audit logs are enabled by default and can be controlled with the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables).
## Fetching Audit Logs ## Fetching Audit Logs
Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API: Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API:
@ -40,7 +40,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
{ {
"id": "cmc146c8r0001xgo2xyu0p463", "id": "cmc146c8r0001xgo2xyu0p463",
"timestamp": "2025-06-17T22:47:58.587Z", "timestamp": "2025-06-17T22:47:58.587Z",
"action": "query.code_search", "action": "user.performed_code_search",
"actorId": "cmc12tnje0000xgn58jj8655h", "actorId": "cmc12tnje0000xgn58jj8655h",
"actorType": "user", "actorType": "user",
"targetId": "1", "targetId": "1",
@ -54,7 +54,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
{ {
"id": "cmc12vqgb0008xgn5nv5hl9y5", "id": "cmc12vqgb0008xgn5nv5hl9y5",
"timestamp": "2025-06-17T22:11:44.171Z", "timestamp": "2025-06-17T22:11:44.171Z",
"action": "query.code_search", "action": "user.performed_code_search",
"actorId": "cmc12tnje0000xgn58jj8655h", "actorId": "cmc12tnje0000xgn58jj8655h",
"actorType": "user", "actorType": "user",
"targetId": "1", "targetId": "1",
@ -68,7 +68,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
{ {
"id": "cmc12txwn0006xgn51ow1odid", "id": "cmc12txwn0006xgn51ow1odid",
"timestamp": "2025-06-17T22:10:20.519Z", "timestamp": "2025-06-17T22:10:20.519Z",
"action": "query.code_search", "action": "user.performed_code_search",
"actorId": "cmc12tnje0000xgn58jj8655h", "actorId": "cmc12tnje0000xgn58jj8655h",
"actorType": "user", "actorType": "user",
"targetId": "1", "targetId": "1",
@ -116,6 +116,9 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `api_key.deleted` | `user` | `api_key` | | `api_key.deleted` | `user` | `api_key` |
| `user.creation_failed` | `user` | `user` | | `user.creation_failed` | `user` | `user` |
| `user.owner_created` | `user` | `org` | | `user.owner_created` | `user` | `org` |
| `user.performed_code_search` | `user` | `org` |
| `user.performed_find_references` | `user` | `org` |
| `user.performed_goto_definition` | `user` | `org` |
| `user.jit_provisioning_failed` | `user` | `org` | | `user.jit_provisioning_failed` | `user` | `org` |
| `user.jit_provisioned` | `user` | `org` | | `user.jit_provisioned` | `user` | `org` |
| `user.join_request_creation_failed` | `user` | `org` | | `user.join_request_creation_failed` | `user` | `org` |
@ -131,9 +134,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.signed_out` | `user` | `user` | | `user.signed_out` | `user` | `user` |
| `org.ownership_transfer_failed` | `user` | `org` | | `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` | | `org.ownership_transferred` | `user` | `org` |
| `query.file_source` | `user \| api_key` | `file` |
| `query.code_search` | `user \| api_key` | `org` |
| `query.list_repositories` | `user \| api_key` | `org` |
## Response schema ## Response schema

View file

@ -39,7 +39,7 @@ The following environment variables allow you to configure your Sourcebot deploy
### Enterprise Environment Variables ### Enterprise Environment Variables
| Variable | Default | Description | | Variable | Default | Description |
| :------- | :------ | :---------- | | :------- | :------ | :---------- |
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `false` | <p>Enables/disables audit logging</p> | | `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | <p>Enables/disables audit logging</p> |
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> | | `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> | | `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> | | `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |

View file

@ -0,0 +1,51 @@
---
title: Analytics
sidebarTitle: Analytics
---
import LicenseKeyRequired from '/snippets/license-key-required.mdx'
import { Callout } from 'nextra/components'
<LicenseKeyRequired />
## Overview
Analytics provides comprehensive insights into your organization's usage of Sourcebot, helping you understand adoption patterns and
quantify the value of time saved.
This dashboard is backed by [audit log](/docs/configuration/audit-logs) events. Please ensure you have audit logging enabled in order to see these insights.
<video
autoPlay
muted
loop
playsInline
className="w-full aspect-video"
src="/images/analytics_demo.mp4"
></video>
## Data Metrics
### Active Users
Tracks the number of unique users who performed any Sourcebot operation within each time period. This metric helps you understand team adoption
and engagement with Sourcebot.
![DAU Chart](/images/dau_chart.png)
### Code Searches
Counts the number of code search operations performed by your team.
![Code Search Chart](/images/code_search_chart.png)
### Code Navigation
Tracks "Go to Definition" and "Find All References" navigation actions. Navigation actions help developers quickly move
between code locations and understand code relationships.
![Code Nav Chart](/images/code_nav_chart.png)
## Cost Savings Calculator
The analytics dashboard includes a built-in cost savings calculator that helps you quantify the ROI of using Sourcebot.
![Cost Savings Chart](/images/cost_savings_chart.png)

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

BIN
docs/images/dau_chart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

View file

@ -0,0 +1,5 @@
-- CreateIndex
CREATE INDEX "idx_audit_core_actions_full" ON "Audit"("orgId", "timestamp", "action", "actorId");
-- CreateIndex
CREATE INDEX "idx_audit_actor_time_full" ON "Audit"("actorId", "timestamp");

View file

@ -245,6 +245,12 @@ model Audit {
orgId Int orgId Int
@@index([actorId, actorType, targetId, targetType, orgId]) @@index([actorId, actorType, targetId, targetType, orgId])
// Fast path for analytics queries orgId is first because we assume most deployments are single tenant
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
// Fast path for analytics queries for a specific user
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
} }
// @see : https://authjs.dev/concepts/database-models#user // @see : https://authjs.dev/concepts/database-models#user

View file

@ -1,6 +1,7 @@
import { PrismaClient } from "@sourcebot/db"; import { PrismaClient } from "@sourcebot/db";
import { ArgumentParser } from "argparse"; import { ArgumentParser } from "argparse";
import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections"; import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections";
import { injectAuditData } from "./scripts/inject-audit-data";
import { confirmAction } from "./utils"; import { confirmAction } from "./utils";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
@ -10,6 +11,7 @@ export interface Script {
export const scripts: Record<string, Script> = { export const scripts: Record<string, Script> = {
"migrate-duplicate-connections": migrateDuplicateConnections, "migrate-duplicate-connections": migrateDuplicateConnections,
"inject-audit-data": injectAuditData,
} }
const parser = new ArgumentParser(); const parser = new ArgumentParser();

View file

@ -0,0 +1,144 @@
import { Script } from "../scriptRunner";
import { PrismaClient } from "../../dist";
import { confirmAction } from "../utils";
import { createLogger } from "@sourcebot/logger";
const logger = createLogger('inject-audit-data');
// Generate realistic audit data for analytics testing
// Simulates 50 engineers with varying activity patterns
export const injectAuditData: Script = {
run: async (prisma: PrismaClient) => {
const orgId = 1;
// Check if org exists
const org = await prisma.org.findUnique({
where: { id: orgId }
});
if (!org) {
logger.error(`Organization with id ${orgId} not found. Please create it first.`);
return;
}
logger.info(`Injecting audit data for organization: ${org.name} (${org.domain})`);
// Generate 50 fake user IDs
const userIds = Array.from({ length: 50 }, (_, i) => `user_${String(i + 1).padStart(3, '0')}`);
// Actions we're tracking
const actions = [
'user.performed_code_search',
'user.performed_find_references',
'user.performed_goto_definition'
];
// Generate data for the last 90 days
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 90);
logger.info(`Generating data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
confirmAction();
// Generate data for each day
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const currentDate = new Date(d);
const dayOfWeek = currentDate.getDay(); // 0 = Sunday, 6 = Saturday
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
// For each user, generate activity for this day
for (const userId of userIds) {
// Determine if user is active today (higher chance on weekdays)
const isActiveToday = isWeekend
? Math.random() < 0.15 // 15% chance on weekends
: Math.random() < 0.85; // 85% chance on weekdays
if (!isActiveToday) continue;
// Generate code searches (2-5 per day)
const codeSearches = isWeekend
? Math.floor(Math.random() * 2) + 1 // 1-2 on weekends
: Math.floor(Math.random() * 4) + 2; // 2-5 on weekdays
// Generate navigation actions (5-10 per day)
const navigationActions = isWeekend
? Math.floor(Math.random() * 3) + 1 // 1-3 on weekends
: Math.floor(Math.random() * 6) + 5; // 5-10 on weekdays
// Create code search records
for (let i = 0; i < codeSearches; i++) {
const timestamp = new Date(currentDate);
// Spread throughout the day (9 AM to 6 PM on weekdays, more random on weekends)
if (isWeekend) {
timestamp.setHours(9 + Math.floor(Math.random() * 12));
timestamp.setMinutes(Math.floor(Math.random() * 60));
} else {
timestamp.setHours(9 + Math.floor(Math.random() * 9));
timestamp.setMinutes(Math.floor(Math.random() * 60));
}
timestamp.setSeconds(Math.floor(Math.random() * 60));
await prisma.audit.create({
data: {
timestamp,
action: 'user.performed_code_search',
actorId: userId,
actorType: 'user',
targetId: `search_${Math.floor(Math.random() * 1000)}`,
targetType: 'search',
sourcebotVersion: '1.0.0',
orgId
}
});
}
// Create navigation action records
for (let i = 0; i < navigationActions; i++) {
const timestamp = new Date(currentDate);
if (isWeekend) {
timestamp.setHours(9 + Math.floor(Math.random() * 12));
timestamp.setMinutes(Math.floor(Math.random() * 60));
} else {
timestamp.setHours(9 + Math.floor(Math.random() * 9));
timestamp.setMinutes(Math.floor(Math.random() * 60));
}
timestamp.setSeconds(Math.floor(Math.random() * 60));
// Randomly choose between find references and goto definition
const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
await prisma.audit.create({
data: {
timestamp,
action,
actorId: userId,
actorType: 'user',
targetId: `symbol_${Math.floor(Math.random() * 1000)}`,
targetType: 'symbol',
sourcebotVersion: '1.0.0',
orgId
}
});
}
}
}
logger.info(`\nAudit data injection complete!`);
logger.info(`Users: ${userIds.length}`);
logger.info(`Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
// Show some statistics
const stats = await prisma.audit.groupBy({
by: ['action'],
where: { orgId },
_count: { action: true }
});
logger.info('\nAction breakdown:');
stats.forEach(stat => {
logger.info(` ${stat.action}: ${stat._count.action}`);
});
},
};

View file

@ -37,15 +37,16 @@ const entitlements = [
"multi-tenancy", "multi-tenancy",
"sso", "sso",
"code-nav", "code-nav",
"audit" "audit",
"analytics"
] as const; ] as const;
export type Entitlement = (typeof entitlements)[number]; export type Entitlement = (typeof entitlements)[number];
const entitlementsByPlan: Record<Plan, Entitlement[]> = { const entitlementsByPlan: Record<Plan, Entitlement[]> = {
oss: [], oss: [],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"], "cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit"], "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit"], "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit", "analytics"],
// Special entitlement for https://demo.sourcebot.dev // Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["public-access", "code-nav", "search-contexts"], "cloud:demo": ["public-access", "code-nav", "search-contexts"],
} as const; } as const;

View file

@ -111,6 +111,7 @@
"codemirror-lang-sparql": "^2.0.0", "codemirror-lang-sparql": "^2.0.0",
"codemirror-lang-spreadsheet": "^1.3.0", "codemirror-lang-spreadsheet": "^1.3.0",
"codemirror-lang-zig": "^0.1.0", "codemirror-lang-zig": "^0.1.0",
"date-fns": "^4.1.0",
"embla-carousel-auto-scroll": "^8.3.0", "embla-carousel-auto-scroll": "^8.3.0",
"embla-carousel-react": "^8.3.0", "embla-carousel-react": "^8.3.0",
"escape-string-regexp": "^5.0.0", "escape-string-regexp": "^5.0.0",
@ -119,7 +120,7 @@
"graphql": "^16.9.0", "graphql": "^16.9.0",
"http-status-codes": "^2.3.0", "http-status-codes": "^2.3.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.435.0", "lucide-react": "^0.517.0",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"next": "14.2.26", "next": "14.2.26",
"next-auth": "^5.0.0-beta.25", "next-auth": "^5.0.0-beta.25",
@ -138,6 +139,7 @@
"react-hotkeys-hook": "^4.5.1", "react-hotkeys-hook": "^4.5.1",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-resizable-panels": "^2.1.1", "react-resizable-panels": "^2.1.1",
"recharts": "^2.15.3",
"scroll-into-view-if-needed": "^3.1.0", "scroll-into-view-if-needed": "^3.1.0",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"sharp": "^0.33.5", "sharp": "^0.33.5",

View file

@ -17,6 +17,8 @@ import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation
import { useBrowseState } from "../../hooks/useBrowseState"; import { useBrowseState } from "../../hooks/useBrowseState";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
import { createAuditAction } from "@/ee/features/audit/actions";
import { useDomain } from "@/hooks/useDomain";
interface PureCodePreviewPanelProps { interface PureCodePreviewPanelProps {
path: string; path: string;
@ -40,6 +42,7 @@ export const PureCodePreviewPanel = ({
const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const { updateBrowseState } = useBrowseState(); const { updateBrowseState } = useBrowseState();
const { navigateToPath } = useBrowseNavigation(); const { navigateToPath } = useBrowseNavigation();
const domain = useDomain();
const captureEvent = useCaptureEvent(); const captureEvent = useCaptureEvent();
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
@ -134,6 +137,12 @@ export const PureCodePreviewPanel = ({
const onFindReferences = useCallback((symbolName: string) => { const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_browse_find_references_pressed', {}); captureEvent('wa_browse_find_references_pressed', {});
createAuditAction({
action: "user.performed_find_references",
metadata: {
message: symbolName,
},
}, domain)
updateBrowseState({ updateBrowseState({
selectedSymbolInfo: { selectedSymbolInfo: {
@ -145,13 +154,19 @@ export const PureCodePreviewPanel = ({
isBottomPanelCollapsed: false, isBottomPanelCollapsed: false,
activeExploreMenuTab: "references", activeExploreMenuTab: "references",
}) })
}, [captureEvent, updateBrowseState, repoName, revisionName, language]); }, [captureEvent, updateBrowseState, repoName, revisionName, language, domain]);
// If we resolve multiple matches, instead of navigating to the first match, we should // If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches. // instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_browse_goto_definition_pressed', {}); captureEvent('wa_browse_goto_definition_pressed', {});
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
message: symbolName,
},
}, domain)
if (symbolDefinitions.length === 0) { if (symbolDefinitions.length === 0) {
return; return;
@ -180,7 +195,7 @@ export const PureCodePreviewPanel = ({
isBottomPanelCollapsed: false, isBottomPanelCollapsed: false,
}) })
} }
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]); }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language, domain]);
const theme = useCodeMirrorTheme(); const theme = useCodeMirrorTheme();

View file

@ -43,6 +43,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { Toggle } from "@/components/ui/toggle"; import { Toggle } from "@/components/ui/toggle";
import { useDomain } from "@/hooks/useDomain"; import { useDomain } from "@/hooks/useDomain";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
import { createAuditAction } from "@/ee/features/audit/actions";
import tailwind from "@/tailwind"; import tailwind from "@/tailwind";
interface SearchBarProps { interface SearchBarProps {
@ -204,6 +205,13 @@ export const SearchBar = ({
setIsSuggestionsEnabled(false); setIsSuggestionsEnabled(false);
setIsHistorySearchEnabled(false); setIsHistorySearchEnabled(false);
createAuditAction({
action: "user.performed_code_search",
metadata: {
message: query,
},
}, domain)
const url = createPathWithQueryParams(`/${domain}/search`, const url = createPathWithQueryParams(`/${domain}/search`,
[SearchQueryParams.query, query], [SearchQueryParams.query, query],
); );

View file

@ -21,6 +21,9 @@ import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPo
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo"; import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
import { createAuditAction } from "@/ee/features/audit/actions";
import { useDomain } from "@/hooks/useDomain";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
export interface CodePreviewFile { export interface CodePreviewFile {
@ -50,6 +53,7 @@ export const CodePreview = ({
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null); const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
const { navigateToPath } = useBrowseNavigation(); const { navigateToPath } = useBrowseNavigation();
const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const domain = useDomain();
const [gutterWidth, setGutterWidth] = useState(0); const [gutterWidth, setGutterWidth] = useState(0);
const theme = useCodeMirrorTheme(); const theme = useCodeMirrorTheme();
@ -116,6 +120,12 @@ export const CodePreview = ({
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_preview_panel_goto_definition_pressed', {}); captureEvent('wa_preview_panel_goto_definition_pressed', {});
createAuditAction({
action: "user.performed_goto_definition",
metadata: {
message: symbolName,
},
}, domain)
if (symbolDefinitions.length === 0) { if (symbolDefinitions.length === 0) {
return; return;
@ -150,10 +160,16 @@ export const CodePreview = ({
} }
}); });
} }
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]); }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
const onFindReferences = useCallback((symbolName: string) => { const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_preview_panel_find_references_pressed', {}); captureEvent('wa_preview_panel_find_references_pressed', {});
createAuditAction({
action: "user.performed_find_references",
metadata: {
message: symbolName,
},
}, domain)
navigateToPath({ navigateToPath({
repoName, repoName,
@ -171,7 +187,7 @@ export const CodePreview = ({
isBottomPanelCollapsed: false, isBottomPanelCollapsed: false,
} }
}) })
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]); }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">

View file

@ -0,0 +1,19 @@
"use client"
import { AnalyticsContent } from "@/ee/features/analytics/analyticsContent";
import { AnalyticsEntitlementMessage } from "@/ee/features/analytics/analyticsEntitlementMessage";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
export default function AnalyticsPage() {
return <AnalyticsPageContent />;
}
function AnalyticsPageContent() {
const hasAnalyticsEntitlement = useHasEntitlement("analytics");
if (!hasAnalyticsEntitlement) {
return <AnalyticsEntitlementMessage />;
}
return <AnalyticsContent />;
}

View file

@ -85,6 +85,10 @@ export default async function SettingsLayout({
title: "API Keys", title: "API Keys",
href: `/${domain}/settings/apiKeys`, href: `/${domain}/settings/apiKeys`,
}, },
{
title: "Analytics",
href: `/${domain}/settings/analytics`,
},
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [ ...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
{ {
title: "License", title: "License",

View file

@ -141,7 +141,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true, trustHost: true,
events: { events: {
createUser: onCreateUser, createUser: onCreateUser,
signIn: async ({ user, account }) => { signIn: async ({ user }) => {
if (user.id) { if (user.id) {
await auditService.createAudit({ await auditService.createAudit({
action: "user.signed_in", action: "user.signed_in",

View file

@ -0,0 +1,365 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
})
ChartContainer.displayName = "Chart"
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref
) => {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
)
ChartTooltipContent.displayName = "ChartTooltip"
const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref
) => {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View file

@ -0,0 +1,103 @@
'use server';
import { sew, withAuth, withOrgMembership } from "@/actions";
import { OrgRole } from "@sourcebot/db";
import { prisma } from "@/prisma";
import { ServiceError } from "@/lib/serviceError";
import { AnalyticsResponse } from "./types";
import { hasEntitlement } from "@sourcebot/shared";
import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes";
export const getAnalytics = async (domain: string, apiKey: string | undefined = undefined): Promise<AnalyticsResponse | ServiceError> => sew(() =>
withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => {
if (!hasEntitlement("analytics")) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "Analytics is not available in your current plan",
} satisfies ServiceError;
}
const rows = await prisma.$queryRaw<AnalyticsResponse>`
WITH core AS (
SELECT
date_trunc('day', "timestamp") AS day,
date_trunc('week', "timestamp") AS week,
date_trunc('month', "timestamp") AS month,
action,
"actorId"
FROM "Audit"
WHERE "orgId" = ${org.id}
AND action IN (
'user.performed_code_search',
'user.performed_find_references',
'user.performed_goto_definition'
)
),
periods AS (
SELECT unnest(array['day', 'week', 'month']) AS period
),
buckets AS (
SELECT
generate_series(
date_trunc('day', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})),
date_trunc('day', CURRENT_DATE),
interval '1 day'
) AS bucket,
'day' AS period
UNION ALL
SELECT
generate_series(
date_trunc('week', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})),
date_trunc('week', CURRENT_DATE),
interval '1 week'
),
'week'
UNION ALL
SELECT
generate_series(
date_trunc('month', (SELECT MIN("timestamp") FROM "Audit" WHERE "orgId" = ${org.id})),
date_trunc('month', CURRENT_DATE),
interval '1 month'
),
'month'
),
aggregated AS (
SELECT
b.period,
CASE b.period
WHEN 'day' THEN c.day
WHEN 'week' THEN c.week
ELSE c.month
END AS bucket,
COUNT(*) FILTER (WHERE c.action = 'user.performed_code_search') AS code_searches,
COUNT(*) FILTER (WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')) AS navigations,
COUNT(DISTINCT c."actorId") AS active_users
FROM core c
JOIN LATERAL (
SELECT unnest(array['day', 'week', 'month']) AS period
) b ON true
GROUP BY b.period, bucket
)
SELECT
b.period,
b.bucket,
COALESCE(a.code_searches, 0)::int AS code_searches,
COALESCE(a.navigations, 0)::int AS navigations,
COALESCE(a.active_users, 0)::int AS active_users
FROM buckets b
LEFT JOIN aggregated a
ON a.period = b.period AND a.bucket = b.bucket
ORDER BY b.period, b.bucket;
`;
return rows;
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
);

View file

@ -0,0 +1,650 @@
"use client"
import { ChartTooltip } from "@/components/ui/chart"
import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
import { Users, LucideIcon, Search, ArrowRight, Activity, DollarSign } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartContainer } from "@/components/ui/chart"
import { useQuery } from "@tanstack/react-query"
import { useDomain } from "@/hooks/useDomain"
import { unwrapServiceError } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
import { AnalyticsResponse } from "./types"
import { getAnalytics } from "./actions"
import { useTheme } from "next-themes"
import { useMemo, useState } from "react"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
interface AnalyticsChartProps {
data: AnalyticsResponse
title: string
icon: LucideIcon
period: "day" | "week" | "month"
dataKey: "code_searches" | "navigations" | "active_users"
color: string
gradientId: string
}
function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradientId }: AnalyticsChartProps) {
const { theme } = useTheme()
const isDark = theme === "dark"
const chartConfig = {
[dataKey]: {
label: title,
theme: {
light: color,
dark: color,
},
},
}
return (
<Card className="bg-card border-border shadow-lg hover:shadow-xl transition-all duration-300 hover:border-border/80">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`p-2 rounded-lg bg-muted/50`}
>
<Icon className="h-5 w-5" style={{ color }} />
</div>
<div>
<CardTitle className="text-lg font-semibold text-card-foreground">{title}</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<ChartContainer config={chartConfig} className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.4} />
<stop offset="50%" stopColor={color} stopOpacity={0.2} />
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis
dataKey="bucket"
axisLine={false}
tickLine={false}
tick={{ fill: isDark ? "#94a3b8" : "#64748b", fontSize: 11 }}
tickFormatter={(value) => {
const utcDate = new Date(value)
const displayDate = new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate())
const opts: Intl.DateTimeFormatOptions =
period === "day" || period === "week"
? { month: "short", day: "numeric" }
: { month: "short", year: "numeric" }
return displayDate.toLocaleDateString("en-US", opts)
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: isDark ? "#94a3b8" : "#64748b", fontSize: 11 }}
tickFormatter={(value) => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `${(value / 1000).toFixed(1)}K`
return value.toString()
}}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-popover/95 backdrop-blur-sm border border-border rounded-xl p-4 shadow-2xl">
<p className="text-muted-foreground text-sm mb-2 font-medium">
{(() => {
const utcDate = new Date(label)
const displayDate = new Date(
utcDate.getUTCFullYear(),
utcDate.getUTCMonth(),
utcDate.getUTCDate(),
)
const opts: Intl.DateTimeFormatOptions =
period === "day" || period === "week"
? { weekday: "short", month: "long", day: "numeric" }
: { month: "long", year: "numeric" }
return displayDate.toLocaleDateString("en-US", opts)
})()}
</p>
{payload.map((entry, index) => (
<div key={index} className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-popover-foreground text-sm">{title}</span>
</div>
<span className="text-popover-foreground font-semibold">{entry.value?.toLocaleString()}</span>
</div>
))}
</div>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey={dataKey}
stroke={color}
fillOpacity={1}
fill={`url(#${gradientId})`}
strokeWidth={2.5}
dot={false}
activeDot={{
r: 4,
fill: color,
stroke: isDark ? "#1e293b" : "#f8fafc",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}
interface SavingsChartProps {
data: AnalyticsResponse
title: string
icon: LucideIcon
period: "day" | "week" | "month"
color: string
gradientId: string
avgMinutesSaved: number
avgSalary: number
}
function SavingsChart({ data, title, icon: Icon, period, color, gradientId, avgMinutesSaved, avgSalary }: SavingsChartProps) {
const { theme } = useTheme()
const isDark = theme === "dark"
const savingsData = useMemo(() => {
return data.map(row => {
const totalOperations = row.code_searches + row.navigations
const totalMinutesSaved = totalOperations * avgMinutesSaved
const hourlyRate = avgSalary / (40 * 52) // Assuming 40 hours per week, 52 weeks per year
const hourlySavings = totalMinutesSaved / 60 * hourlyRate
return {
...row,
savings: Math.round(hourlySavings * 100) / 100 // Round to 2 decimal places
}
})
}, [data, avgMinutesSaved, avgSalary])
const chartConfig = {
savings: {
label: title,
theme: {
light: color,
dark: color,
},
},
}
return (
<Card className="bg-card border-border shadow-lg hover:shadow-xl transition-all duration-300 hover:border-border/80">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div
className={`p-2 rounded-lg bg-muted/50`}
>
<Icon className="h-5 w-5" style={{ color }} />
</div>
<div>
<CardTitle className="text-lg font-semibold text-card-foreground">{title}</CardTitle>
</div>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<ChartContainer config={chartConfig} className="h-[240px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={savingsData} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.4} />
<stop offset="50%" stopColor={color} stopOpacity={0.2} />
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis
dataKey="bucket"
axisLine={false}
tickLine={false}
tick={{ fill: isDark ? "#94a3b8" : "#64748b", fontSize: 11 }}
tickFormatter={(value) => {
const utcDate = new Date(value)
const displayDate = new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate())
const opts: Intl.DateTimeFormatOptions =
period === "day" || period === "week"
? { month: "short", day: "numeric" }
: { month: "short", year: "numeric" }
return displayDate.toLocaleDateString("en-US", opts)
}}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fill: isDark ? "#94a3b8" : "#64748b", fontSize: 11 }}
tickFormatter={(value) => {
if (value >= 1000000) return `$${(value / 1000000).toFixed(1)}M`
if (value >= 1000) return `$${(value / 1000).toFixed(1)}K`
return `$${value.toFixed(0)}`
}}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (active && payload && payload.length) {
return (
<div className="bg-popover/95 backdrop-blur-sm border border-border rounded-xl p-4 shadow-2xl">
<p className="text-muted-foreground text-sm mb-2 font-medium">
{(() => {
const utcDate = new Date(label)
const displayDate = new Date(
utcDate.getUTCFullYear(),
utcDate.getUTCMonth(),
utcDate.getUTCDate(),
)
const opts: Intl.DateTimeFormatOptions =
period === "day" || period === "week"
? { weekday: "short", month: "long", day: "numeric" }
: { month: "long", year: "numeric" }
return displayDate.toLocaleDateString("en-US", opts)
})()}
</p>
{payload.map((entry, index) => (
<div key={index} className="flex items-center justify-between space-x-4">
<div className="flex items-center space-x-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: entry.color }} />
<span className="text-popover-foreground text-sm">{title}</span>
</div>
<span className="text-popover-foreground font-semibold">${entry.value?.toLocaleString()}</span>
</div>
))}
</div>
)
}
return null
}}
/>
<Area
type="monotone"
dataKey="savings"
stroke={color}
fillOpacity={1}
fill={`url(#${gradientId})`}
strokeWidth={2.5}
dot={false}
activeDot={{
r: 4,
fill: color,
stroke: isDark ? "#1e293b" : "#f8fafc",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</CardContent>
</Card>
)
}
function LoadingSkeleton() {
return (
<div className="space-y-8">
{[1, 2, 3].map((groupIndex) => (
<div key={groupIndex} className="space-y-4">
{/* Full-width chart skeleton */}
<Card className="bg-card border-border shadow-lg">
<CardHeader className="pb-4">
<div className="flex items-center space-x-3">
<Skeleton className="h-9 w-9 rounded-lg bg-muted" />
<div className="space-y-2">
<Skeleton className="h-5 w-32 bg-muted" />
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-[240px] w-full bg-muted rounded-lg" />
</CardContent>
</Card>
{/* Side-by-side charts skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[1, 2].map((chartIndex) => (
<Card key={chartIndex} className="bg-card border-border shadow-lg">
<CardHeader className="pb-4">
<div className="flex items-center space-x-3">
<Skeleton className="h-9 w-9 rounded-lg bg-muted" />
<div className="space-y-2">
<Skeleton className="h-5 w-32 bg-muted" />
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<Skeleton className="h-[240px] w-full bg-muted rounded-lg" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
)
}
export function AnalyticsContent() {
const domain = useDomain()
const { theme } = useTheme()
// Store these values as strings in the state to allow us to have empty fields for better UX
const [avgMinutesSaved, setAvgMinutesSaved] = useState("2")
const [avgSalary, setAvgSalary] = useState("100000")
const numericAvgMinutesSaved = parseFloat(avgMinutesSaved) || 0
const numericAvgSalary = parseInt(avgSalary, 10) || 0
const {
data: analyticsResponse,
isPending,
isError,
error
} = useQuery({
queryKey: ["analytics", domain],
queryFn: () => unwrapServiceError(getAnalytics(domain)),
})
const chartColors = useMemo(() => ({
users: {
light: "#3b82f6",
dark: "#60a5fa",
},
searches: {
light: "#f59e0b",
dark: "#fbbf24",
},
navigations: {
light: "#ef4444",
dark: "#f87171",
},
savings: {
light: "#10b981",
dark: "#34d399",
},
}), [])
const getColor = (colorKey: keyof typeof chartColors) => {
return theme === "dark" ? chartColors[colorKey].dark : chartColors[colorKey].light
}
const totalSavings = useMemo(() => {
if (!analyticsResponse) return 0
const totalOperations = analyticsResponse.reduce((sum, row) => sum + row.code_searches + row.navigations, 0)
const totalMinutesSaved = totalOperations * numericAvgMinutesSaved
const hourlyRate = numericAvgSalary / (40 * 52)
return Math.round((totalMinutesSaved / 60 * hourlyRate) * 100) / 100
}, [analyticsResponse, numericAvgMinutesSaved, numericAvgSalary])
if (isPending) {
return (
<div className="min-h-screen bg-background p-6">
<LoadingSkeleton />
</div>
)
}
if (isError) {
return (
<div className="min-h-screen bg-background flex items-center justify-center">
<Card className="bg-destructive/10 border-destructive/20 p-8">
<div className="text-center">
<div className="p-3 rounded-full bg-destructive/20 w-fit mx-auto mb-4">
<Activity className="h-8 w-8 text-destructive" />
</div>
<h3 className="text-xl font-semibold text-foreground mb-2">Analytics Unavailable</h3>
<p className="text-destructive">Error loading analytics: {error.message}</p>
</div>
</Card>
</div>
)
}
const dailyData = analyticsResponse.filter((row) => row.period === "day")
const weeklyData = analyticsResponse.filter((row) => row.period === "week")
const monthlyData = analyticsResponse.filter((row) => row.period === "month")
const chartGroups = [
{
title: "Active Users",
icon: Users,
color: getColor("users"),
charts: [
{
title: "Daily Active Users",
data: dailyData,
dataKey: "active_users" as const,
gradientId: "dailyUsers",
fullWidth: true,
},
{
title: "Weekly Active Users",
data: weeklyData,
dataKey: "active_users" as const,
gradientId: "weeklyUsers",
fullWidth: false,
},
{
title: "Monthly Active Users",
data: monthlyData,
dataKey: "active_users" as const,
gradientId: "monthlyUsers",
fullWidth: false,
},
],
},
{
title: "Code Searches",
icon: Search,
color: getColor("searches"),
charts: [
{
title: "Daily Code Searches",
data: dailyData,
dataKey: "code_searches" as const,
gradientId: "dailyCodeSearches",
fullWidth: true,
},
{
title: "Weekly Code Searches",
data: weeklyData,
dataKey: "code_searches" as const,
gradientId: "weeklyCodeSearches",
fullWidth: false,
},
{
title: "Monthly Code Searches",
data: monthlyData,
dataKey: "code_searches" as const,
gradientId: "monthlyCodeSearches",
fullWidth: false,
},
],
},
{
title: "Navigations",
icon: ArrowRight,
color: getColor("navigations"),
charts: [
{
title: "Daily Navigations",
data: dailyData,
dataKey: "navigations" as const,
gradientId: "dailyNavigations",
fullWidth: true,
},
{
title: "Weekly Navigations",
data: weeklyData,
dataKey: "navigations" as const,
gradientId: "weeklyNavigations",
fullWidth: false,
},
{
title: "Monthly Navigations",
data: monthlyData,
dataKey: "navigations" as const,
gradientId: "monthlyNavigations",
fullWidth: false,
},
],
},
]
return (
<div className="space-y-8">
{chartGroups.map((group) => (
<div key={group.title} className="space-y-4">
{group.charts
.filter(chart => chart.fullWidth)
.map((chart) => (
<div key={chart.title} className="w-full">
<AnalyticsChart
data={chart.data}
title={chart.title}
icon={group.icon}
period={chart.data[0]?.period as "day" | "week" | "month"}
dataKey={chart.dataKey}
color={group.color}
gradientId={chart.gradientId}
/>
</div>
))}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{group.charts
.filter(chart => !chart.fullWidth)
.map((chart) => (
<AnalyticsChart
key={chart.title}
data={chart.data}
title={chart.title}
icon={group.icon}
period={chart.data[0]?.period as "day" | "week" | "month"}
dataKey={chart.dataKey}
color={group.color}
gradientId={chart.gradientId}
/>
))}
</div>
</div>
))}
<div className="space-y-6">
<Card className="bg-card border-border shadow-lg">
<CardHeader>
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-muted/50">
<DollarSign className="h-5 w-5" style={{ color: getColor("savings") }} />
</div>
<div>
<CardTitle className="text-xl font-semibold text-card-foreground">Savings Calculator</CardTitle>
<p className="text-muted-foreground text-sm">Calculate the monetary value of time saved using Sourcebot</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div className="space-y-2">
<Label htmlFor="avgMinutesSaved">Average Minutes Saved Per Operation</Label>
<Input
id="avgMinutesSaved"
type="number"
min="0"
step="0.1"
value={avgMinutesSaved}
onChange={(e) => setAvgMinutesSaved(e.target.value)}
placeholder="2"
/>
<p className="text-xs text-muted-foreground">Estimated time saved per search or navigation operation</p>
</div>
<div className="space-y-2">
<Label htmlFor="avgSalary">Average Annual Salary ($)</Label>
<Input
id="avgSalary"
type="number"
min="0"
step="1000"
value={avgSalary}
onChange={(e) => setAvgSalary(e.target.value)}
placeholder="100000"
/>
<p className="text-xs text-muted-foreground">Average annual salary of your engineering team</p>
</div>
</div>
<Card className="bg-muted/30 border-border/50">
<CardContent className="pt-6">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Total Estimated Savings</p>
<p className="text-3xl font-bold text-card-foreground">${totalSavings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">
Based on {analyticsResponse.reduce((sum, row) => sum + row.code_searches + row.navigations, 0).toLocaleString()} total operations
</p>
</div>
</CardContent>
</Card>
</CardContent>
</Card>
<div className="space-y-4">
<SavingsChart
data={dailyData}
title="Daily Savings"
icon={DollarSign}
period="day"
color={getColor("savings")}
gradientId="dailySavings"
avgMinutesSaved={numericAvgMinutesSaved}
avgSalary={numericAvgSalary}
/>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<SavingsChart
data={weeklyData}
title="Weekly Savings"
icon={DollarSign}
period="week"
color={getColor("savings")}
gradientId="weeklySavings"
avgMinutesSaved={numericAvgMinutesSaved}
avgSalary={numericAvgSalary}
/>
<SavingsChart
data={monthlyData}
title="Monthly Savings"
icon={DollarSign}
period="month"
color={getColor("savings")}
gradientId="monthlySavings"
avgMinutesSaved={numericAvgMinutesSaved}
avgSalary={numericAvgSalary}
/>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,47 @@
"use client"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { BarChart3, Mail } from "lucide-react"
export function AnalyticsEntitlementMessage() {
return (
<div className="flex items-center justify-center min-h-[60vh] py-12">
<Card className="w-full max-w-lg bg-card border-border shadow-xl p-2">
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-4">
<div className="p-3 rounded-full bg-muted">
<BarChart3 className="h-8 w-8 text-muted-foreground" />
</div>
</div>
<CardTitle className="text-xl font-semibold text-card-foreground">
Analytics is an Enterprise Feature
</CardTitle>
<CardDescription className="text-muted-foreground mt-2">
Get insights into your organization&apos;s usage patterns and activity. <a href="https://docs.sourcebot.dev/docs/features/analytics" target="_blank" rel="noopener" className="text-primary hover:underline">Learn more</a>
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-4">
<div className="bg-muted/50 rounded-lg p-4 border border-border">
<p className="text-sm text-muted-foreground">
Want to try out Sourcebot&apos;s enterprise features? Reach out to us and we&apos;ll get back to you within
a couple hours with a trial license.
</p>
</div>
<div className="flex flex-col gap-2">
<Button asChild className="w-full">
<a
href="https://sourcebot.dev/contact"
target="_blank"
rel="noopener"
>
<Mail className="h-4 w-4 mr-2" />
Request a trial license
</a>
</Button>
</div>
</CardContent>
</Card>
</div>
)
}

View file

@ -0,0 +1,10 @@
import { z } from "zod";
export const analyticsResponseSchema = z.array(z.object({
period: z.enum(['day', 'week', 'month']),
bucket: z.date(),
code_searches: z.number(),
navigations: z.number(),
active_users: z.number(),
}))
export type AnalyticsResponse = z.infer<typeof analyticsResponseSchema>;

View file

@ -1,3 +1,5 @@
"use server";
import { prisma } from "@/prisma"; import { prisma } from "@/prisma";
import { ErrorCode } from "@/lib/errorCodes"; import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes"; import { StatusCodes } from "http-status-codes";
@ -6,10 +8,18 @@ import { OrgRole } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger"; import { createLogger } from "@sourcebot/logger";
import { ServiceError } from "@/lib/serviceError"; import { ServiceError } from "@/lib/serviceError";
import { getAuditService } from "@/ee/features/audit/factory"; import { getAuditService } from "@/ee/features/audit/factory";
import { AuditEvent } from "./types";
const auditService = getAuditService(); const auditService = getAuditService();
const logger = createLogger('audit-utils'); const logger = createLogger('audit-utils');
export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersion' | 'orgId' | 'actor' | 'target'>, domain: string) => sew(async () =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {
await auditService.createAudit({ ...event, orgId: org.id, actor: { id: userId, type: "user" }, target: { id: org.id.toString(), type: "org" } })
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowSingleTenantUnauthedAccess = */ true)
);
export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() => export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() =>
withAuth((userId) => withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {

View file

@ -1,11 +1,13 @@
import { IAuditService } from '@/ee/features/audit/types'; import { IAuditService } from '@/ee/features/audit/types';
import { MockAuditService } from '@/ee/features/audit/mockAuditService'; import { MockAuditService } from '@/ee/features/audit/mockAuditService';
import { AuditService } from '@/ee/features/audit/auditService'; import { AuditService } from '@/ee/features/audit/auditService';
import { hasEntitlement } from '@sourcebot/shared';
import { env } from '@/env.mjs'; import { env } from '@/env.mjs';
let enterpriseService: IAuditService | undefined; let enterpriseService: IAuditService | undefined;
export function getAuditService(): IAuditService { export function getAuditService(): IAuditService {
enterpriseService = enterpriseService ?? (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true' ? new AuditService() : new MockAuditService()); const auditLogsEnabled = (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') && hasEntitlement("audit");
enterpriseService = enterpriseService ?? (auditLogsEnabled ? new AuditService() : new MockAuditService());
return enterpriseService; return enterpriseService;
} }

View file

@ -85,7 +85,7 @@ export const env = createEnv({
// EE License // EE License
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(), SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('false'), SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('true'),
// GitHub app for review agent // GitHub app for review agent
GITHUB_APP_ID: z.string().optional(), GITHUB_APP_ID: z.string().optional(),

View file

@ -7,16 +7,13 @@ import { isServiceError } from "../../lib/utils";
import { search } from "./searchApi"; import { search } from "./searchApi";
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew, withAuth, withOrgMembership } from "@/actions";
import { OrgRole } from "@sourcebot/db"; import { OrgRole } from "@sourcebot/db";
import { getAuditService } from "@/ee/features/audit/factory";
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here. // @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
// This will allow us to support permalinks to files at a specific revision that may not be indexed // This will allow us to support permalinks to files at a specific revision that may not be indexed
// by zoekt. // by zoekt.
const auditService = getAuditService();
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() => export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
withAuth((userId, apiKeyHash) => withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async () => {
const escapedFileName = escapeStringRegexp(fileName); const escapedFileName = escapeStringRegexp(fileName);
const escapedRepository = escapeStringRegexp(repository); const escapedRepository = escapeStringRegexp(repository);
@ -45,18 +42,6 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource
const source = file.content ?? ''; const source = file.content ?? '';
const language = file.language; const language = file.language;
await auditService.createAudit({
action: "query.file_source",
actor: {
id: apiKeyHash ?? userId,
type: apiKeyHash ? "api_key" : "user"
},
orgId: org.id,
target: {
id: `${escapedRepository}/${escapedFileName}${branch ? `:${branch}` : ''}`,
type: "file"
}
});
return { return {
source, source,
language, language,

View file

@ -4,12 +4,9 @@ import { ListRepositoriesResponse } from "./types";
import { zoektFetch } from "./zoektClient"; import { zoektFetch } from "./zoektClient";
import { zoektListRepositoriesResponseSchema } from "./zoektSchema"; import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew, withAuth, withOrgMembership } from "@/actions";
import { getAuditService } from "@/ee/features/audit/factory";
const auditService = getAuditService();
export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise<ListRepositoriesResponse | ServiceError> => sew(() => export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise<ListRepositoriesResponse | ServiceError> => sew(() =>
withAuth((userId, apiKeyHash) => withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
const body = JSON.stringify({ const body = JSON.stringify({
opts: { opts: {
@ -47,22 +44,6 @@ export const listRepositories = async (domain: string, apiKey: string | undefine
const result = parser.parse(listBody); const result = parser.parse(listBody);
await auditService.createAudit({
action: "query.list_repositories",
actor: {
id: apiKeyHash ?? userId,
type: apiKeyHash ? "api_key" : "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: result.repos.map((repo) => repo.name).join(", ")
}
});
return result; return result;
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );

View file

@ -11,9 +11,6 @@ import { OrgRole, Repo } from "@sourcebot/db";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew, withAuth, withOrgMembership } from "@/actions";
import { base64Decode } from "@sourcebot/shared"; import { base64Decode } from "@sourcebot/shared";
import { getAuditService } from "@/ee/features/audit/factory";
const auditService = getAuditService();
// List of supported query prefixes in zoekt. // List of supported query prefixes in zoekt.
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417 // @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
@ -129,7 +126,7 @@ const getFileWebUrl = (template: string, branch: string, fileName: string): stri
} }
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() => export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() =>
withAuth((userId, apiKeyHash) => withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
const transformedQuery = await transformZoektQuery(query, org.id); const transformedQuery = await transformZoektQuery(query, org.id);
if (isServiceError(transformedQuery)) { if (isServiceError(transformedQuery)) {
@ -302,22 +299,6 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
} }
}).filter((file) => file !== undefined) ?? []; }).filter((file) => file !== undefined) ?? [];
await auditService.createAudit({
action: "query.code_search",
actor: {
id: apiKeyHash ?? userId,
type: apiKeyHash ? "api_key" : "user"
},
target: {
id: org.id.toString(),
type: "org"
},
orgId: org.id,
metadata: {
message: query,
}
});
return { return {
zoektStats: { zoektStats: {
duration: Result.Duration, duration: Result.Duration,

View file

@ -158,15 +158,6 @@ const pruneOldGuestUser = async () => {
} }
} }
const validateEntitlements = () => {
if (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
if (!hasEntitlement('audit')) {
logger.error(`Audit logging is enabled but your license does not include the audit logging entitlement. Please reach out to us to enquire about upgrading your license.`);
process.exit(1);
}
}
}
const initSingleTenancy = async () => { const initSingleTenancy = async () => {
await prisma.org.upsert({ await prisma.org.upsert({
where: { where: {
@ -184,9 +175,6 @@ const initSingleTenancy = async () => {
// To keep things simple, we'll just delete the old guest user if it exists in the DB // To keep things simple, we'll just delete the old guest user if it exists in the DB
await pruneOldGuestUser(); await pruneOldGuestUser();
// Startup time entitlement/environment variable validation
validateEntitlements();
const hasPublicAccessEntitlement = hasEntitlement("public-access"); const hasPublicAccessEntitlement = hasEntitlement("public-access");
if (hasPublicAccessEntitlement) { if (hasPublicAccessEntitlement) {
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN); const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);

View file

@ -1,6 +1,12 @@
import { NewsItem } from "./types"; import { NewsItem } from "./types";
export const newsData: NewsItem[] = [ export const newsData: NewsItem[] = [
{
unique_id: "analytics",
header: "Analytics Dashboard",
sub_header: "Understand your team's Sourcebot usage",
url: "https://docs.sourcebot.dev/docs/features/analytics"
},
{ {
unique_id: "audit-logs", unique_id: "audit-logs",
header: "Audit logs", header: "Audit logs",

325
yarn.lock
View file

@ -280,6 +280,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
version: 7.27.6
resolution: "@babel/runtime@npm:7.27.6"
checksum: 10c0/89726be83f356f511dcdb74d3ea4d873a5f0cf0017d4530cb53aa27380c01ca102d573eff8b8b77815e624b1f8c24e7f0311834ad4fb632c90a770fda00bd4c8
languageName: node
linkType: hard
"@babel/template@npm:^7.24.0, @babel/template@npm:^7.26.9": "@babel/template@npm:^7.24.0, @babel/template@npm:^7.26.9":
version: 7.26.9 version: 7.26.9
resolution: "@babel/template@npm:7.26.9" resolution: "@babel/template@npm:7.26.9"
@ -6017,6 +6024,7 @@ __metadata:
codemirror-lang-spreadsheet: "npm:^1.3.0" codemirror-lang-spreadsheet: "npm:^1.3.0"
codemirror-lang-zig: "npm:^0.1.0" codemirror-lang-zig: "npm:^0.1.0"
cross-env: "npm:^7.0.3" cross-env: "npm:^7.0.3"
date-fns: "npm:^4.1.0"
embla-carousel-auto-scroll: "npm:^8.3.0" embla-carousel-auto-scroll: "npm:^8.3.0"
embla-carousel-react: "npm:^8.3.0" embla-carousel-react: "npm:^8.3.0"
escape-string-regexp: "npm:^5.0.0" escape-string-regexp: "npm:^5.0.0"
@ -6030,7 +6038,7 @@ __metadata:
http-status-codes: "npm:^2.3.0" http-status-codes: "npm:^2.3.0"
input-otp: "npm:^1.4.2" input-otp: "npm:^1.4.2"
jsdom: "npm:^25.0.1" jsdom: "npm:^25.0.1"
lucide-react: "npm:^0.435.0" lucide-react: "npm:^0.517.0"
micromatch: "npm:^4.0.8" micromatch: "npm:^4.0.8"
next: "npm:14.2.26" next: "npm:14.2.26"
next-auth: "npm:^5.0.0-beta.25" next-auth: "npm:^5.0.0-beta.25"
@ -6052,6 +6060,7 @@ __metadata:
react-hotkeys-hook: "npm:^4.5.1" react-hotkeys-hook: "npm:^4.5.1"
react-icons: "npm:^5.3.0" react-icons: "npm:^5.3.0"
react-resizable-panels: "npm:^2.1.1" react-resizable-panels: "npm:^2.1.1"
recharts: "npm:^2.15.3"
scroll-into-view-if-needed: "npm:^3.1.0" scroll-into-view-if-needed: "npm:^3.1.0"
server-only: "npm:^0.0.1" server-only: "npm:^0.0.1"
sharp: "npm:^0.33.5" sharp: "npm:^0.33.5"
@ -6309,6 +6318,75 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/d3-array@npm:^3.0.3":
version: 3.2.1
resolution: "@types/d3-array@npm:3.2.1"
checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca
languageName: node
linkType: hard
"@types/d3-color@npm:*":
version: 3.1.3
resolution: "@types/d3-color@npm:3.1.3"
checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae
languageName: node
linkType: hard
"@types/d3-ease@npm:^3.0.0":
version: 3.0.2
resolution: "@types/d3-ease@npm:3.0.2"
checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c
languageName: node
linkType: hard
"@types/d3-interpolate@npm:^3.0.1":
version: 3.0.4
resolution: "@types/d3-interpolate@npm:3.0.4"
dependencies:
"@types/d3-color": "npm:*"
checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
languageName: node
linkType: hard
"@types/d3-path@npm:*":
version: 3.1.1
resolution: "@types/d3-path@npm:3.1.1"
checksum: 10c0/2c36eb31ebaf2ce4712e793fd88087117976f7c4ed69cc2431825f999c8c77cca5cea286f3326432b770739ac6ccd5d04d851eb65e7a4dbcc10c982b49ad2c02
languageName: node
linkType: hard
"@types/d3-scale@npm:^4.0.2":
version: 4.0.9
resolution: "@types/d3-scale@npm:4.0.9"
dependencies:
"@types/d3-time": "npm:*"
checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8
languageName: node
linkType: hard
"@types/d3-shape@npm:^3.1.0":
version: 3.1.7
resolution: "@types/d3-shape@npm:3.1.7"
dependencies:
"@types/d3-path": "npm:*"
checksum: 10c0/38e59771c1c4c83b67aa1f941ce350410522a149d2175832fdc06396b2bb3b2c1a2dd549e0f8230f9f24296ee5641a515eaf10f55ee1ef6c4f83749e2dd7dcfd
languageName: node
linkType: hard
"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0":
version: 3.0.4
resolution: "@types/d3-time@npm:3.0.4"
checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174
languageName: node
linkType: hard
"@types/d3-timer@npm:^3.0.0":
version: 3.0.2
resolution: "@types/d3-timer@npm:3.0.2"
checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1
languageName: node
linkType: hard
"@types/estree@npm:*, @types/estree@npm:1.0.6, @types/estree@npm:^1.0.0": "@types/estree@npm:*, @types/estree@npm:1.0.6, @types/estree@npm:^1.0.0":
version: 1.0.6 version: 1.0.6
resolution: "@types/estree@npm:1.0.6" resolution: "@types/estree@npm:1.0.6"
@ -7950,7 +8028,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"clsx@npm:^2.1.1": "clsx@npm:^2.0.0, clsx@npm:^2.1.1":
version: 2.1.1 version: 2.1.1
resolution: "clsx@npm:2.1.1" resolution: "clsx@npm:2.1.1"
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839 checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
@ -8505,6 +8583,99 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
version: 3.2.4
resolution: "d3-array@npm:3.2.4"
dependencies:
internmap: "npm:1 - 2"
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
languageName: node
linkType: hard
"d3-color@npm:1 - 3":
version: 3.1.0
resolution: "d3-color@npm:3.1.0"
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
languageName: node
linkType: hard
"d3-ease@npm:^3.0.1":
version: 3.0.1
resolution: "d3-ease@npm:3.0.1"
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
languageName: node
linkType: hard
"d3-format@npm:1 - 3":
version: 3.1.0
resolution: "d3-format@npm:3.1.0"
checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75
languageName: node
linkType: hard
"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
version: 3.0.1
resolution: "d3-interpolate@npm:3.0.1"
dependencies:
d3-color: "npm:1 - 3"
checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a
languageName: node
linkType: hard
"d3-path@npm:^3.1.0":
version: 3.1.0
resolution: "d3-path@npm:3.1.0"
checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da
languageName: node
linkType: hard
"d3-scale@npm:^4.0.2":
version: 4.0.2
resolution: "d3-scale@npm:4.0.2"
dependencies:
d3-array: "npm:2.10.0 - 3"
d3-format: "npm:1 - 3"
d3-interpolate: "npm:1.2.0 - 3"
d3-time: "npm:2.1.1 - 3"
d3-time-format: "npm:2 - 4"
checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1
languageName: node
linkType: hard
"d3-shape@npm:^3.1.0":
version: 3.2.0
resolution: "d3-shape@npm:3.2.0"
dependencies:
d3-path: "npm:^3.1.0"
checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132
languageName: node
linkType: hard
"d3-time-format@npm:2 - 4":
version: 4.1.0
resolution: "d3-time-format@npm:4.1.0"
dependencies:
d3-time: "npm:1 - 3"
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
languageName: node
linkType: hard
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0":
version: 3.1.0
resolution: "d3-time@npm:3.1.0"
dependencies:
d3-array: "npm:2 - 3"
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
languageName: node
linkType: hard
"d3-timer@npm:^3.0.1":
version: 3.0.1
resolution: "d3-timer@npm:3.0.1"
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
languageName: node
linkType: hard
"damerau-levenshtein@npm:^1.0.8": "damerau-levenshtein@npm:^1.0.8":
version: 1.0.8 version: 1.0.8
resolution: "damerau-levenshtein@npm:1.0.8" resolution: "damerau-levenshtein@npm:1.0.8"
@ -8555,6 +8726,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"date-fns@npm:^4.1.0":
version: 4.1.0
resolution: "date-fns@npm:4.1.0"
checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8
languageName: node
linkType: hard
"debounce-promise@npm:^3.1.2": "debounce-promise@npm:^3.1.2":
version: 3.1.2 version: 3.1.2
resolution: "debounce-promise@npm:3.1.2" resolution: "debounce-promise@npm:3.1.2"
@ -8611,6 +8789,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"decimal.js-light@npm:^2.4.1":
version: 2.5.1
resolution: "decimal.js-light@npm:2.5.1"
checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020
languageName: node
linkType: hard
"decimal.js@npm:^10.4.3": "decimal.js@npm:^10.4.3":
version: 10.5.0 version: 10.5.0
resolution: "decimal.js@npm:10.5.0" resolution: "decimal.js@npm:10.5.0"
@ -8776,6 +8961,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"dom-helpers@npm:^5.0.1":
version: 5.2.1
resolution: "dom-helpers@npm:5.2.1"
dependencies:
"@babel/runtime": "npm:^7.8.7"
csstype: "npm:^3.0.2"
checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c
languageName: node
linkType: hard
"dom-serializer@npm:^2.0.0": "dom-serializer@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "dom-serializer@npm:2.0.0" resolution: "dom-serializer@npm:2.0.0"
@ -9808,6 +10003,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"eventemitter3@npm:^4.0.1":
version: 4.0.7
resolution: "eventemitter3@npm:4.0.7"
checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b
languageName: node
linkType: hard
"eventsource-parser@npm:^3.0.1": "eventsource-parser@npm:^3.0.1":
version: 3.0.1 version: 3.0.1
resolution: "eventsource-parser@npm:3.0.1" resolution: "eventsource-parser@npm:3.0.1"
@ -9956,6 +10158,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fast-equals@npm:^5.0.1":
version: 5.2.2
resolution: "fast-equals@npm:5.2.2"
checksum: 10c0/2bfeac6317a8959a00e2134749323557e5df6dea3af24e4457297733eace8ce4313fcbca2cf4532f3a6792607461e80442cd8d3af148d5c2e4e98ad996d6e5b5
languageName: node
linkType: hard
"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2": "fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2":
version: 3.3.3 version: 3.3.3
resolution: "fast-glob@npm:3.3.3" resolution: "fast-glob@npm:3.3.3"
@ -10930,6 +11139,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"internmap@npm:1 - 2":
version: 2.0.3
resolution: "internmap@npm:2.0.3"
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
languageName: node
linkType: hard
"ioredis@npm:^5.4.1, ioredis@npm:^5.4.2": "ioredis@npm:^5.4.1, ioredis@npm:^5.4.2":
version: 5.6.0 version: 5.6.0
resolution: "ioredis@npm:5.6.0" resolution: "ioredis@npm:5.6.0"
@ -11825,12 +12041,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lucide-react@npm:^0.435.0": "lucide-react@npm:^0.517.0":
version: 0.435.0 version: 0.517.0
resolution: "lucide-react@npm:0.435.0" resolution: "lucide-react@npm:0.517.0"
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/8a1653901a362d83696b555ff2cc1f06126008c8a09b1b8a0db8484b98c9de5c44589d00296d1f419ebb4e65524fcc4eb1fe39f1721dd22242dc34788f8936f5 checksum: 10c0/9e827d7c5fd441b9628778e4a121fca4c6354b6aa4fab8b3efda1b060dd3d0b4dac43ee813161ef30f30d0919009fc4565e620d59d4e9bf9425269e242156edb
languageName: node languageName: node
linkType: hard linkType: hard
@ -13626,7 +13842,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": "prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
version: 15.8.1 version: 15.8.1
resolution: "prop-types@npm:15.8.1" resolution: "prop-types@npm:15.8.1"
dependencies: dependencies:
@ -13872,6 +14088,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-is@npm:^18.3.1":
version: 18.3.1
resolution: "react-is@npm:18.3.1"
checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
languageName: node
linkType: hard
"react-promise-suspense@npm:0.3.4": "react-promise-suspense@npm:0.3.4":
version: 0.3.4 version: 0.3.4
resolution: "react-promise-suspense@npm:0.3.4" resolution: "react-promise-suspense@npm:0.3.4"
@ -13945,6 +14168,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-smooth@npm:^4.0.4":
version: 4.0.4
resolution: "react-smooth@npm:4.0.4"
dependencies:
fast-equals: "npm:^5.0.1"
prop-types: "npm:^15.8.1"
react-transition-group: "npm:^4.4.5"
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/d94cb27f808721ec040d320ca1927919199495fd212e54eb9dc8ee3f73ff1d808a34be9f4b09fe49b01f411ac2387fdf0e4bee297f18faf56f94bfbef5fd204c
languageName: node
linkType: hard
"react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": "react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3":
version: 2.2.3 version: 2.2.3
resolution: "react-style-singleton@npm:2.2.3" resolution: "react-style-singleton@npm:2.2.3"
@ -13961,6 +14198,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-transition-group@npm:^4.4.5":
version: 4.4.5
resolution: "react-transition-group@npm:4.4.5"
dependencies:
"@babel/runtime": "npm:^7.5.5"
dom-helpers: "npm:^5.0.1"
loose-envify: "npm:^1.4.0"
prop-types: "npm:^15.6.2"
peerDependencies:
react: ">=16.6.0"
react-dom: ">=16.6.0"
checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82
languageName: node
linkType: hard
"react@npm:^18": "react@npm:^18":
version: 18.3.1 version: 18.3.1
resolution: "react@npm:18.3.1" resolution: "react@npm:18.3.1"
@ -14024,6 +14276,34 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"recharts-scale@npm:^0.4.4":
version: 0.4.5
resolution: "recharts-scale@npm:0.4.5"
dependencies:
decimal.js-light: "npm:^2.4.1"
checksum: 10c0/64ce1fc4ebe62001787bf4dc4cbb779452d33831619309c71c50277c58e8968ffe98941562d9d0d5ffdb02588ebd62f4fe6548fa826110fd458db9c3cc6dadc1
languageName: node
linkType: hard
"recharts@npm:^2.15.3":
version: 2.15.3
resolution: "recharts@npm:2.15.3"
dependencies:
clsx: "npm:^2.0.0"
eventemitter3: "npm:^4.0.1"
lodash: "npm:^4.17.21"
react-is: "npm:^18.3.1"
react-smooth: "npm:^4.0.4"
recharts-scale: "npm:^0.4.4"
tiny-invariant: "npm:^1.3.1"
victory-vendor: "npm:^36.6.8"
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/76757605d67a07562bcfb1a4b9a3a0b6b5fed2b84ee5f00813cedf151502969965bf7bd3856eb7e5d60c1d71c7b0d67d9ae2f1ef45676152fcd532abafc501fb
languageName: node
linkType: hard
"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": "redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0":
version: 1.2.0 version: 1.2.0
resolution: "redis-errors@npm:1.2.0" resolution: "redis-errors@npm:1.2.0"
@ -15578,6 +15858,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tiny-invariant@npm:^1.3.1":
version: 1.3.3
resolution: "tiny-invariant@npm:1.3.3"
checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a
languageName: node
linkType: hard
"tinybench@npm:^2.9.0": "tinybench@npm:^2.9.0":
version: 2.9.0 version: 2.9.0
resolution: "tinybench@npm:2.9.0" resolution: "tinybench@npm:2.9.0"
@ -16242,6 +16529,28 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"victory-vendor@npm:^36.6.8":
version: 36.9.2
resolution: "victory-vendor@npm:36.9.2"
dependencies:
"@types/d3-array": "npm:^3.0.3"
"@types/d3-ease": "npm:^3.0.0"
"@types/d3-interpolate": "npm:^3.0.1"
"@types/d3-scale": "npm:^4.0.2"
"@types/d3-shape": "npm:^3.1.0"
"@types/d3-time": "npm:^3.0.0"
"@types/d3-timer": "npm:^3.0.0"
d3-array: "npm:^3.1.6"
d3-ease: "npm:^3.0.1"
d3-interpolate: "npm:^3.0.1"
d3-scale: "npm:^4.0.2"
d3-shape: "npm:^3.1.0"
d3-time: "npm:^3.0.0"
d3-timer: "npm:^3.0.1"
checksum: 10c0/bad36de3bf4d406834743c2e99a8281d786af324d7e84b7f7a2fc02c27a3779034fb0c3c4707d4c8e68683334d924a67100cfa13985235565e83b9877f8e2ffd
languageName: node
linkType: hard
"vite-node@npm:2.1.9": "vite-node@npm:2.1.9":
version: 2.1.9 version: 2.1.9
resolution: "vite-node@npm:2.1.9" resolution: "vite-node@npm:2.1.9"