diff --git a/CHANGELOG.md b/CHANGELOG.md index 42b9e551..bdc2a90a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Added analytics dashboard. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358) + ### 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) +### Changed +- Audit logging is now enabled by default. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358) + ## [4.4.0] - 2025-06-18 ### Added diff --git a/docs/docs.json b/docs/docs.json index eb75dcb5..c8b3888e 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -36,6 +36,7 @@ ] }, "docs/features/code-navigation", + "docs/features/analytics", "docs/features/mcp-server", { "group": "Agents", diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index 52440160..2a3c0624 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -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. -## Enabling Audit Logs -Audit logs must be explicitly enabled by setting the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables) to `true` +## Enabling/Disabling Audit Logs +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 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", "timestamp": "2025-06-17T22:47:58.587Z", - "action": "query.code_search", + "action": "user.performed_code_search", "actorId": "cmc12tnje0000xgn58jj8655h", "actorType": "user", "targetId": "1", @@ -54,7 +54,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ { "id": "cmc12vqgb0008xgn5nv5hl9y5", "timestamp": "2025-06-17T22:11:44.171Z", - "action": "query.code_search", + "action": "user.performed_code_search", "actorId": "cmc12tnje0000xgn58jj8655h", "actorType": "user", "targetId": "1", @@ -68,7 +68,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ { "id": "cmc12txwn0006xgn51ow1odid", "timestamp": "2025-06-17T22:10:20.519Z", - "action": "query.code_search", + "action": "user.performed_code_search", "actorId": "cmc12tnje0000xgn58jj8655h", "actorType": "user", "targetId": "1", @@ -116,6 +116,9 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ | `api_key.deleted` | `user` | `api_key` | | `user.creation_failed` | `user` | `user` | | `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_provisioned` | `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` | | `org.ownership_transfer_failed` | `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 diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx index 96f8e329..a55d735d 100644 --- a/docs/docs/configuration/environment-variables.mdx +++ b/docs/docs/configuration/environment-variables.mdx @@ -39,7 +39,7 @@ The following environment variables allow you to configure your Sourcebot deploy ### Enterprise Environment Variables | Variable | Default | Description | | :------- | :------ | :---------- | -| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `false` |

Enables/disables audit logging

| +| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` |

Enables/disables audit logging

| | `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` |

Enables/disables just-in-time user provisioning for SSO providers.

| | `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` |

The base URL for GitHub Enterprise SSO authentication.

| | `AUTH_EE_GITHUB_CLIENT_ID` | `-` |

The client ID for GitHub Enterprise SSO authentication.

| diff --git a/docs/docs/features/analytics.mdx b/docs/docs/features/analytics.mdx new file mode 100644 index 00000000..0b22acad --- /dev/null +++ b/docs/docs/features/analytics.mdx @@ -0,0 +1,51 @@ +--- +title: Analytics +sidebarTitle: Analytics +--- + +import LicenseKeyRequired from '/snippets/license-key-required.mdx' +import { Callout } from 'nextra/components' + + + + +## 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. + + + +## 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) diff --git a/docs/images/analytics_demo.mp4 b/docs/images/analytics_demo.mp4 new file mode 100644 index 00000000..10b537df Binary files /dev/null and b/docs/images/analytics_demo.mp4 differ diff --git a/docs/images/code_nav_chart.png b/docs/images/code_nav_chart.png new file mode 100644 index 00000000..e56b117f Binary files /dev/null and b/docs/images/code_nav_chart.png differ diff --git a/docs/images/code_search_chart.png b/docs/images/code_search_chart.png new file mode 100644 index 00000000..81a903ff Binary files /dev/null and b/docs/images/code_search_chart.png differ diff --git a/docs/images/cost_savings_chart.png b/docs/images/cost_savings_chart.png new file mode 100644 index 00000000..42238a4b Binary files /dev/null and b/docs/images/cost_savings_chart.png differ diff --git a/docs/images/dau_chart.png b/docs/images/dau_chart.png new file mode 100644 index 00000000..d1bdb80b Binary files /dev/null and b/docs/images/dau_chart.png differ diff --git a/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql b/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql new file mode 100644 index 00000000..b270515e --- /dev/null +++ b/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql @@ -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"); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 113e13f0..29189373 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -245,6 +245,12 @@ model Audit { orgId Int @@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 diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts index ef9bfdd3..8e72063a 100644 --- a/packages/db/tools/scriptRunner.ts +++ b/packages/db/tools/scriptRunner.ts @@ -1,6 +1,7 @@ import { PrismaClient } from "@sourcebot/db"; import { ArgumentParser } from "argparse"; import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections"; +import { injectAuditData } from "./scripts/inject-audit-data"; import { confirmAction } from "./utils"; import { createLogger } from "@sourcebot/logger"; @@ -10,6 +11,7 @@ export interface Script { export const scripts: Record = { "migrate-duplicate-connections": migrateDuplicateConnections, + "inject-audit-data": injectAuditData, } const parser = new ArgumentParser(); diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts new file mode 100644 index 00000000..11f3e8cc --- /dev/null +++ b/packages/db/tools/scripts/inject-audit-data.ts @@ -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}`); + }); + }, +}; \ No newline at end of file diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts index 478fe66d..49e7e70f 100644 --- a/packages/shared/src/entitlements.ts +++ b/packages/shared/src/entitlements.ts @@ -37,15 +37,16 @@ const entitlements = [ "multi-tenancy", "sso", "code-nav", - "audit" + "audit", + "analytics" ] as const; export type Entitlement = (typeof entitlements)[number]; const entitlementsByPlan: Record = { oss: [], "cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"], - "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit"], - "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "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", "analytics"], // Special entitlement for https://demo.sourcebot.dev "cloud:demo": ["public-access", "code-nav", "search-contexts"], } as const; diff --git a/packages/web/package.json b/packages/web/package.json index 09987c54..fe82fad6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -111,6 +111,7 @@ "codemirror-lang-sparql": "^2.0.0", "codemirror-lang-spreadsheet": "^1.3.0", "codemirror-lang-zig": "^0.1.0", + "date-fns": "^4.1.0", "embla-carousel-auto-scroll": "^8.3.0", "embla-carousel-react": "^8.3.0", "escape-string-regexp": "^5.0.0", @@ -119,7 +120,7 @@ "graphql": "^16.9.0", "http-status-codes": "^2.3.0", "input-otp": "^1.4.2", - "lucide-react": "^0.435.0", + "lucide-react": "^0.517.0", "micromatch": "^4.0.8", "next": "14.2.26", "next-auth": "^5.0.0-beta.25", @@ -138,6 +139,7 @@ "react-hotkeys-hook": "^4.5.1", "react-icons": "^5.3.0", "react-resizable-panels": "^2.1.1", + "recharts": "^2.15.3", "scroll-into-view-if-needed": "^3.1.0", "server-only": "^0.0.1", "sharp": "^0.33.5", diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx index c8f8384f..49124588 100644 --- a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx +++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx @@ -17,6 +17,8 @@ import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation import { useBrowseState } from "../../hooks/useBrowseState"; import { rangeHighlightingExtension } from "./rangeHighlightingExtension"; import useCaptureEvent from "@/hooks/useCaptureEvent"; +import { createAuditAction } from "@/ee/features/audit/actions"; +import { useDomain } from "@/hooks/useDomain"; interface PureCodePreviewPanelProps { path: string; @@ -40,6 +42,7 @@ export const PureCodePreviewPanel = ({ const hasCodeNavEntitlement = useHasEntitlement("code-nav"); const { updateBrowseState } = useBrowseState(); const { navigateToPath } = useBrowseNavigation(); + const domain = useDomain(); const captureEvent = useCaptureEvent(); const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM); @@ -134,6 +137,12 @@ export const PureCodePreviewPanel = ({ const onFindReferences = useCallback((symbolName: string) => { captureEvent('wa_browse_find_references_pressed', {}); + createAuditAction({ + action: "user.performed_find_references", + metadata: { + message: symbolName, + }, + }, domain) updateBrowseState({ selectedSymbolInfo: { @@ -145,13 +154,19 @@ export const PureCodePreviewPanel = ({ isBottomPanelCollapsed: false, 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 // instead popup the bottom sheet with the list of matches. const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { captureEvent('wa_browse_goto_definition_pressed', {}); + createAuditAction({ + action: "user.performed_goto_definition", + metadata: { + message: symbolName, + }, + }, domain) if (symbolDefinitions.length === 0) { return; @@ -180,7 +195,7 @@ export const PureCodePreviewPanel = ({ isBottomPanelCollapsed: false, }) } - }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]); + }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language, domain]); const theme = useCodeMirrorTheme(); diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx index 450ed74b..d6fec7d8 100644 --- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx +++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx @@ -43,6 +43,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip import { Toggle } from "@/components/ui/toggle"; import { useDomain } from "@/hooks/useDomain"; import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint"; +import { createAuditAction } from "@/ee/features/audit/actions"; import tailwind from "@/tailwind"; interface SearchBarProps { @@ -204,6 +205,13 @@ export const SearchBar = ({ setIsSuggestionsEnabled(false); setIsHistorySearchEnabled(false); + createAuditAction({ + action: "user.performed_code_search", + metadata: { + message: query, + }, + }, domain) + const url = createPathWithQueryParams(`/${domain}/search`, [SearchQueryParams.query, query], ); diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx index bcea40af..8c869f8d 100644 --- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx +++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx @@ -21,6 +21,9 @@ import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPo import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension"; import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement"; 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"; export interface CodePreviewFile { @@ -50,6 +53,7 @@ export const CodePreview = ({ const [editorRef, setEditorRef] = useState(null); const { navigateToPath } = useBrowseNavigation(); const hasCodeNavEntitlement = useHasEntitlement("code-nav"); + const domain = useDomain(); const [gutterWidth, setGutterWidth] = useState(0); const theme = useCodeMirrorTheme(); @@ -116,6 +120,12 @@ export const CodePreview = ({ const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => { captureEvent('wa_preview_panel_goto_definition_pressed', {}); + createAuditAction({ + action: "user.performed_goto_definition", + metadata: { + message: symbolName, + }, + }, domain) if (symbolDefinitions.length === 0) { 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) => { captureEvent('wa_preview_panel_find_references_pressed', {}); + createAuditAction({ + action: "user.performed_find_references", + metadata: { + message: symbolName, + }, + }, domain) navigateToPath({ repoName, @@ -171,7 +187,7 @@ export const CodePreview = ({ isBottomPanelCollapsed: false, } }) - }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]); + }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]); return (
diff --git a/packages/web/src/app/[domain]/settings/analytics/page.tsx b/packages/web/src/app/[domain]/settings/analytics/page.tsx new file mode 100644 index 00000000..a542432b --- /dev/null +++ b/packages/web/src/app/[domain]/settings/analytics/page.tsx @@ -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 ; +} + +function AnalyticsPageContent() { + const hasAnalyticsEntitlement = useHasEntitlement("analytics"); + + if (!hasAnalyticsEntitlement) { + return ; + } + + return ; +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx index 4ad4c387..21fc834f 100644 --- a/packages/web/src/app/[domain]/settings/layout.tsx +++ b/packages/web/src/app/[domain]/settings/layout.tsx @@ -85,6 +85,10 @@ export default async function SettingsLayout({ title: "API Keys", href: `/${domain}/settings/apiKeys`, }, + { + title: "Analytics", + href: `/${domain}/settings/analytics`, + }, ...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [ { title: "License", diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index a341192d..769ff5f9 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -141,7 +141,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ trustHost: true, events: { createUser: onCreateUser, - signIn: async ({ user, account }) => { + signIn: async ({ user }) => { if (user.id) { await auditService.createAudit({ action: "user.signed_in", diff --git a/packages/web/src/components/ui/chart.tsx b/packages/web/src/components/ui/chart.tsx new file mode 100644 index 00000000..39fba6d6 --- /dev/null +++ b/packages/web/src/components/ui/chart.tsx @@ -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 } + ) +} + +type ChartContextProps = { + config: ChartConfig +} + +const ChartContext = React.createContext(null) + +function useChart() { + const context = React.useContext(ChartContext) + + if (!context) { + throw new Error("useChart must be used within a ") + } + + 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 ( + +
+ + + {children} + +
+
+ ) +}) +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 ( +