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]
### 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

View file

@ -36,6 +36,7 @@
]
},
"docs/features/code-navigation",
"docs/features/analytics",
"docs/features/mcp-server",
{
"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.
## 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

View file

@ -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` | <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_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> |

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
@@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

View file

@ -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<string, Script> = {
"migrate-duplicate-connections": migrateDuplicateConnections,
"inject-audit-data": injectAuditData,
}
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",
"sso",
"code-nav",
"audit"
"audit",
"analytics"
] as const;
export type Entitlement = (typeof entitlements)[number];
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
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;

View file

@ -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",

View file

@ -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();

View file

@ -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],
);

View file

@ -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<ReactCodeMirrorRef | null>(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 (
<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",
href: `/${domain}/settings/apiKeys`,
},
{
title: "Analytics",
href: `/${domain}/settings/analytics`,
},
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
{
title: "License",

View file

@ -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",

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 { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes";
@ -6,10 +8,18 @@ import { OrgRole } from "@sourcebot/db";
import { createLogger } from "@sourcebot/logger";
import { ServiceError } from "@/lib/serviceError";
import { getAuditService } from "@/ee/features/audit/factory";
import { AuditEvent } from "./types";
const auditService = getAuditService();
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(() =>
withAuth((userId) =>
withOrgMembership(userId, domain, async ({ org }) => {

View file

@ -1,11 +1,13 @@
import { IAuditService } from '@/ee/features/audit/types';
import { MockAuditService } from '@/ee/features/audit/mockAuditService';
import { AuditService } from '@/ee/features/audit/auditService';
import { hasEntitlement } from '@sourcebot/shared';
import { env } from '@/env.mjs';
let enterpriseService: IAuditService | undefined;
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;
}

View file

@ -85,7 +85,7 @@ export const env = createEnv({
// EE License
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_ID: z.string().optional(),

View file

@ -7,16 +7,13 @@ import { isServiceError } from "../../lib/utils";
import { search } from "./searchApi";
import { sew, withAuth, withOrgMembership } from "@/actions";
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.
// This will allow us to support permalinks to files at a specific revision that may not be indexed
// by zoekt.
const auditService = getAuditService();
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
withAuth((userId, apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => {
withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async () => {
const escapedFileName = escapeStringRegexp(fileName);
const escapedRepository = escapeStringRegexp(repository);
@ -45,18 +42,6 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource
const source = file.content ?? '';
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 {
source,
language,

View file

@ -4,12 +4,9 @@ import { ListRepositoriesResponse } from "./types";
import { zoektFetch } from "./zoektClient";
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
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(() =>
withAuth((userId, apiKeyHash) =>
withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => {
const body = JSON.stringify({
opts: {
@ -47,22 +44,6 @@ export const listRepositories = async (domain: string, apiKey: string | undefine
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;
}, /* 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 { sew, withAuth, withOrgMembership } from "@/actions";
import { base64Decode } from "@sourcebot/shared";
import { getAuditService } from "@/ee/features/audit/factory";
const auditService = getAuditService();
// List of supported query prefixes in zoekt.
// @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(() =>
withAuth((userId, apiKeyHash) =>
withAuth((userId, _apiKeyHash) =>
withOrgMembership(userId, domain, async ({ org }) => {
const transformedQuery = await transformZoektQuery(query, org.id);
if (isServiceError(transformedQuery)) {
@ -302,22 +299,6 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
}
}).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 {
zoektStats: {
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 () => {
await prisma.org.upsert({
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
await pruneOldGuestUser();
// Startup time entitlement/environment variable validation
validateEntitlements();
const hasPublicAccessEntitlement = hasEntitlement("public-access");
if (hasPublicAccessEntitlement) {
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);

View file

@ -1,6 +1,12 @@
import { NewsItem } from "./types";
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",
header: "Audit logs",

325
yarn.lock
View file

@ -280,6 +280,13 @@ __metadata:
languageName: node
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":
version: 7.26.9
resolution: "@babel/template@npm:7.26.9"
@ -6017,6 +6024,7 @@ __metadata:
codemirror-lang-spreadsheet: "npm:^1.3.0"
codemirror-lang-zig: "npm:^0.1.0"
cross-env: "npm:^7.0.3"
date-fns: "npm:^4.1.0"
embla-carousel-auto-scroll: "npm:^8.3.0"
embla-carousel-react: "npm:^8.3.0"
escape-string-regexp: "npm:^5.0.0"
@ -6030,7 +6038,7 @@ __metadata:
http-status-codes: "npm:^2.3.0"
input-otp: "npm:^1.4.2"
jsdom: "npm:^25.0.1"
lucide-react: "npm:^0.435.0"
lucide-react: "npm:^0.517.0"
micromatch: "npm:^4.0.8"
next: "npm:14.2.26"
next-auth: "npm:^5.0.0-beta.25"
@ -6052,6 +6060,7 @@ __metadata:
react-hotkeys-hook: "npm:^4.5.1"
react-icons: "npm:^5.3.0"
react-resizable-panels: "npm:^2.1.1"
recharts: "npm:^2.15.3"
scroll-into-view-if-needed: "npm:^3.1.0"
server-only: "npm:^0.0.1"
sharp: "npm:^0.33.5"
@ -6309,6 +6318,75 @@ __metadata:
languageName: node
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":
version: 1.0.6
resolution: "@types/estree@npm:1.0.6"
@ -7950,7 +8028,7 @@ __metadata:
languageName: node
linkType: hard
"clsx@npm:^2.1.1":
"clsx@npm:^2.0.0, clsx@npm:^2.1.1":
version: 2.1.1
resolution: "clsx@npm:2.1.1"
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
@ -8505,6 +8583,99 @@ __metadata:
languageName: node
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":
version: 1.0.8
resolution: "damerau-levenshtein@npm:1.0.8"
@ -8555,6 +8726,13 @@ __metadata:
languageName: node
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":
version: 3.1.2
resolution: "debounce-promise@npm:3.1.2"
@ -8611,6 +8789,13 @@ __metadata:
languageName: node
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":
version: 10.5.0
resolution: "decimal.js@npm:10.5.0"
@ -8776,6 +8961,16 @@ __metadata:
languageName: node
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":
version: 2.0.0
resolution: "dom-serializer@npm:2.0.0"
@ -9808,6 +10003,13 @@ __metadata:
languageName: node
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":
version: 3.0.1
resolution: "eventsource-parser@npm:3.0.1"
@ -9956,6 +10158,13 @@ __metadata:
languageName: node
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":
version: 3.3.3
resolution: "fast-glob@npm:3.3.3"
@ -10930,6 +11139,13 @@ __metadata:
languageName: node
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":
version: 5.6.0
resolution: "ioredis@npm:5.6.0"
@ -11825,12 +12041,12 @@ __metadata:
languageName: node
linkType: hard
"lucide-react@npm:^0.435.0":
version: 0.435.0
resolution: "lucide-react@npm:0.435.0"
"lucide-react@npm:^0.517.0":
version: 0.517.0
resolution: "lucide-react@npm:0.517.0"
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
checksum: 10c0/8a1653901a362d83696b555ff2cc1f06126008c8a09b1b8a0db8484b98c9de5c44589d00296d1f419ebb4e65524fcc4eb1fe39f1721dd22242dc34788f8936f5
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/9e827d7c5fd441b9628778e4a121fca4c6354b6aa4fab8b3efda1b060dd3d0b4dac43ee813161ef30f30d0919009fc4565e620d59d4e9bf9425269e242156edb
languageName: node
linkType: hard
@ -13626,7 +13842,7 @@ __metadata:
languageName: node
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
resolution: "prop-types@npm:15.8.1"
dependencies:
@ -13872,6 +14088,13 @@ __metadata:
languageName: node
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":
version: 0.3.4
resolution: "react-promise-suspense@npm:0.3.4"
@ -13945,6 +14168,20 @@ __metadata:
languageName: node
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":
version: 2.2.3
resolution: "react-style-singleton@npm:2.2.3"
@ -13961,6 +14198,21 @@ __metadata:
languageName: node
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":
version: 18.3.1
resolution: "react@npm:18.3.1"
@ -14024,6 +14276,34 @@ __metadata:
languageName: node
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":
version: 1.2.0
resolution: "redis-errors@npm:1.2.0"
@ -15578,6 +15858,13 @@ __metadata:
languageName: node
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":
version: 2.9.0
resolution: "tinybench@npm:2.9.0"
@ -16242,6 +16529,28 @@ __metadata:
languageName: node
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":
version: 2.1.9
resolution: "vite-node@npm:2.1.9"