mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 04:15:30 +00:00
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:
parent
fb2ef05172
commit
4bb93c9f3e
36 changed files with 1814 additions and 97 deletions
|
|
@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added analytics dashboard. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358)
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fixed issue where invites appeared to be created successfully, but were not actually being created in the database. [#359](https://github.com/sourcebot-dev/sourcebot/pull/359)
|
- Fixed issue where invites appeared to be created successfully, but were not actually being created in the database. [#359](https://github.com/sourcebot-dev/sourcebot/pull/359)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Audit logging is now enabled by default. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358)
|
||||||
|
|
||||||
## [4.4.0] - 2025-06-18
|
## [4.4.0] - 2025-06-18
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"docs/features/code-navigation",
|
"docs/features/code-navigation",
|
||||||
|
"docs/features/analytics",
|
||||||
"docs/features/mcp-server",
|
"docs/features/mcp-server",
|
||||||
{
|
{
|
||||||
"group": "Agents",
|
"group": "Agents",
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ action, and when the action took place.
|
||||||
|
|
||||||
This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment.
|
This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment.
|
||||||
|
|
||||||
## Enabling Audit Logs
|
## Enabling/Disabling Audit Logs
|
||||||
Audit logs must be explicitly enabled by setting the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables) to `true`
|
Audit logs are enabled by default and can be controlled with the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables).
|
||||||
|
|
||||||
## Fetching Audit Logs
|
## Fetching Audit Logs
|
||||||
Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API:
|
Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API:
|
||||||
|
|
@ -40,7 +40,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
|
||||||
{
|
{
|
||||||
"id": "cmc146c8r0001xgo2xyu0p463",
|
"id": "cmc146c8r0001xgo2xyu0p463",
|
||||||
"timestamp": "2025-06-17T22:47:58.587Z",
|
"timestamp": "2025-06-17T22:47:58.587Z",
|
||||||
"action": "query.code_search",
|
"action": "user.performed_code_search",
|
||||||
"actorId": "cmc12tnje0000xgn58jj8655h",
|
"actorId": "cmc12tnje0000xgn58jj8655h",
|
||||||
"actorType": "user",
|
"actorType": "user",
|
||||||
"targetId": "1",
|
"targetId": "1",
|
||||||
|
|
@ -54,7 +54,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
|
||||||
{
|
{
|
||||||
"id": "cmc12vqgb0008xgn5nv5hl9y5",
|
"id": "cmc12vqgb0008xgn5nv5hl9y5",
|
||||||
"timestamp": "2025-06-17T22:11:44.171Z",
|
"timestamp": "2025-06-17T22:11:44.171Z",
|
||||||
"action": "query.code_search",
|
"action": "user.performed_code_search",
|
||||||
"actorId": "cmc12tnje0000xgn58jj8655h",
|
"actorId": "cmc12tnje0000xgn58jj8655h",
|
||||||
"actorType": "user",
|
"actorType": "user",
|
||||||
"targetId": "1",
|
"targetId": "1",
|
||||||
|
|
@ -68,7 +68,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
|
||||||
{
|
{
|
||||||
"id": "cmc12txwn0006xgn51ow1odid",
|
"id": "cmc12txwn0006xgn51ow1odid",
|
||||||
"timestamp": "2025-06-17T22:10:20.519Z",
|
"timestamp": "2025-06-17T22:10:20.519Z",
|
||||||
"action": "query.code_search",
|
"action": "user.performed_code_search",
|
||||||
"actorId": "cmc12tnje0000xgn58jj8655h",
|
"actorId": "cmc12tnje0000xgn58jj8655h",
|
||||||
"actorType": "user",
|
"actorType": "user",
|
||||||
"targetId": "1",
|
"targetId": "1",
|
||||||
|
|
@ -116,6 +116,9 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
|
||||||
| `api_key.deleted` | `user` | `api_key` |
|
| `api_key.deleted` | `user` | `api_key` |
|
||||||
| `user.creation_failed` | `user` | `user` |
|
| `user.creation_failed` | `user` | `user` |
|
||||||
| `user.owner_created` | `user` | `org` |
|
| `user.owner_created` | `user` | `org` |
|
||||||
|
| `user.performed_code_search` | `user` | `org` |
|
||||||
|
| `user.performed_find_references` | `user` | `org` |
|
||||||
|
| `user.performed_goto_definition` | `user` | `org` |
|
||||||
| `user.jit_provisioning_failed` | `user` | `org` |
|
| `user.jit_provisioning_failed` | `user` | `org` |
|
||||||
| `user.jit_provisioned` | `user` | `org` |
|
| `user.jit_provisioned` | `user` | `org` |
|
||||||
| `user.join_request_creation_failed` | `user` | `org` |
|
| `user.join_request_creation_failed` | `user` | `org` |
|
||||||
|
|
@ -131,9 +134,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
|
||||||
| `user.signed_out` | `user` | `user` |
|
| `user.signed_out` | `user` | `user` |
|
||||||
| `org.ownership_transfer_failed` | `user` | `org` |
|
| `org.ownership_transfer_failed` | `user` | `org` |
|
||||||
| `org.ownership_transferred` | `user` | `org` |
|
| `org.ownership_transferred` | `user` | `org` |
|
||||||
| `query.file_source` | `user \| api_key` | `file` |
|
|
||||||
| `query.code_search` | `user \| api_key` | `org` |
|
|
||||||
| `query.list_repositories` | `user \| api_key` | `org` |
|
|
||||||
|
|
||||||
|
|
||||||
## Response schema
|
## Response schema
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ The following environment variables allow you to configure your Sourcebot deploy
|
||||||
### Enterprise Environment Variables
|
### Enterprise Environment Variables
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| :------- | :------ | :---------- |
|
| :------- | :------ | :---------- |
|
||||||
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `false` | <p>Enables/disables audit logging</p> |
|
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | <p>Enables/disables audit logging</p> |
|
||||||
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
|
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | <p>Enables/disables just-in-time user provisioning for SSO providers.</p> |
|
||||||
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
|
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | <p>The base URL for GitHub Enterprise SSO authentication.</p> |
|
||||||
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |
|
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | <p>The client ID for GitHub Enterprise SSO authentication.</p> |
|
||||||
|
|
|
||||||
51
docs/docs/features/analytics.mdx
Normal file
51
docs/docs/features/analytics.mdx
Normal 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### Code Searches
|
||||||
|
Counts the number of code search operations performed by your team.
|
||||||
|
|
||||||
|

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

|
||||||
|
|
||||||
|
## Cost Savings Calculator
|
||||||
|
|
||||||
|
The analytics dashboard includes a built-in cost savings calculator that helps you quantify the ROI of using Sourcebot.
|
||||||
|
|
||||||
|

|
||||||
BIN
docs/images/analytics_demo.mp4
Normal file
BIN
docs/images/analytics_demo.mp4
Normal file
Binary file not shown.
BIN
docs/images/code_nav_chart.png
Normal file
BIN
docs/images/code_nav_chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 597 KiB |
BIN
docs/images/code_search_chart.png
Normal file
BIN
docs/images/code_search_chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 612 KiB |
BIN
docs/images/cost_savings_chart.png
Normal file
BIN
docs/images/cost_savings_chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 739 KiB |
BIN
docs/images/dau_chart.png
Normal file
BIN
docs/images/dau_chart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 561 KiB |
|
|
@ -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");
|
||||||
|
|
@ -245,6 +245,12 @@ model Audit {
|
||||||
orgId Int
|
orgId Int
|
||||||
|
|
||||||
@@index([actorId, actorType, targetId, targetType, orgId])
|
@@index([actorId, actorType, targetId, targetType, orgId])
|
||||||
|
|
||||||
|
// Fast path for analytics queries – orgId is first because we assume most deployments are single tenant
|
||||||
|
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
|
||||||
|
|
||||||
|
// Fast path for analytics queries for a specific user
|
||||||
|
@@index([actorId, timestamp], map: "idx_audit_actor_time_full")
|
||||||
}
|
}
|
||||||
|
|
||||||
// @see : https://authjs.dev/concepts/database-models#user
|
// @see : https://authjs.dev/concepts/database-models#user
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { PrismaClient } from "@sourcebot/db";
|
import { PrismaClient } from "@sourcebot/db";
|
||||||
import { ArgumentParser } from "argparse";
|
import { ArgumentParser } from "argparse";
|
||||||
import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections";
|
import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections";
|
||||||
|
import { injectAuditData } from "./scripts/inject-audit-data";
|
||||||
import { confirmAction } from "./utils";
|
import { confirmAction } from "./utils";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
|
|
||||||
|
|
@ -10,6 +11,7 @@ export interface Script {
|
||||||
|
|
||||||
export const scripts: Record<string, Script> = {
|
export const scripts: Record<string, Script> = {
|
||||||
"migrate-duplicate-connections": migrateDuplicateConnections,
|
"migrate-duplicate-connections": migrateDuplicateConnections,
|
||||||
|
"inject-audit-data": injectAuditData,
|
||||||
}
|
}
|
||||||
|
|
||||||
const parser = new ArgumentParser();
|
const parser = new ArgumentParser();
|
||||||
|
|
|
||||||
144
packages/db/tools/scripts/inject-audit-data.ts
Normal file
144
packages/db/tools/scripts/inject-audit-data.ts
Normal 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}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -37,15 +37,16 @@ const entitlements = [
|
||||||
"multi-tenancy",
|
"multi-tenancy",
|
||||||
"sso",
|
"sso",
|
||||||
"code-nav",
|
"code-nav",
|
||||||
"audit"
|
"audit",
|
||||||
|
"analytics"
|
||||||
] as const;
|
] as const;
|
||||||
export type Entitlement = (typeof entitlements)[number];
|
export type Entitlement = (typeof entitlements)[number];
|
||||||
|
|
||||||
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
const entitlementsByPlan: Record<Plan, Entitlement[]> = {
|
||||||
oss: [],
|
oss: [],
|
||||||
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
|
||||||
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit"],
|
"self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
|
||||||
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit"],
|
"self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit", "analytics"],
|
||||||
// Special entitlement for https://demo.sourcebot.dev
|
// Special entitlement for https://demo.sourcebot.dev
|
||||||
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
|
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -111,6 +111,7 @@
|
||||||
"codemirror-lang-sparql": "^2.0.0",
|
"codemirror-lang-sparql": "^2.0.0",
|
||||||
"codemirror-lang-spreadsheet": "^1.3.0",
|
"codemirror-lang-spreadsheet": "^1.3.0",
|
||||||
"codemirror-lang-zig": "^0.1.0",
|
"codemirror-lang-zig": "^0.1.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"embla-carousel-auto-scroll": "^8.3.0",
|
"embla-carousel-auto-scroll": "^8.3.0",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"escape-string-regexp": "^5.0.0",
|
"escape-string-regexp": "^5.0.0",
|
||||||
|
|
@ -119,7 +120,7 @@
|
||||||
"graphql": "^16.9.0",
|
"graphql": "^16.9.0",
|
||||||
"http-status-codes": "^2.3.0",
|
"http-status-codes": "^2.3.0",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.435.0",
|
"lucide-react": "^0.517.0",
|
||||||
"micromatch": "^4.0.8",
|
"micromatch": "^4.0.8",
|
||||||
"next": "14.2.26",
|
"next": "14.2.26",
|
||||||
"next-auth": "^5.0.0-beta.25",
|
"next-auth": "^5.0.0-beta.25",
|
||||||
|
|
@ -138,6 +139,7 @@
|
||||||
"react-hotkeys-hook": "^4.5.1",
|
"react-hotkeys-hook": "^4.5.1",
|
||||||
"react-icons": "^5.3.0",
|
"react-icons": "^5.3.0",
|
||||||
"react-resizable-panels": "^2.1.1",
|
"react-resizable-panels": "^2.1.1",
|
||||||
|
"recharts": "^2.15.3",
|
||||||
"scroll-into-view-if-needed": "^3.1.0",
|
"scroll-into-view-if-needed": "^3.1.0",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation
|
||||||
import { useBrowseState } from "../../hooks/useBrowseState";
|
import { useBrowseState } from "../../hooks/useBrowseState";
|
||||||
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
|
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
import { createAuditAction } from "@/ee/features/audit/actions";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
interface PureCodePreviewPanelProps {
|
interface PureCodePreviewPanelProps {
|
||||||
path: string;
|
path: string;
|
||||||
|
|
@ -40,6 +42,7 @@ export const PureCodePreviewPanel = ({
|
||||||
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
||||||
const { updateBrowseState } = useBrowseState();
|
const { updateBrowseState } = useBrowseState();
|
||||||
const { navigateToPath } = useBrowseNavigation();
|
const { navigateToPath } = useBrowseNavigation();
|
||||||
|
const domain = useDomain();
|
||||||
const captureEvent = useCaptureEvent();
|
const captureEvent = useCaptureEvent();
|
||||||
|
|
||||||
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
|
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
|
||||||
|
|
@ -134,6 +137,12 @@ export const PureCodePreviewPanel = ({
|
||||||
|
|
||||||
const onFindReferences = useCallback((symbolName: string) => {
|
const onFindReferences = useCallback((symbolName: string) => {
|
||||||
captureEvent('wa_browse_find_references_pressed', {});
|
captureEvent('wa_browse_find_references_pressed', {});
|
||||||
|
createAuditAction({
|
||||||
|
action: "user.performed_find_references",
|
||||||
|
metadata: {
|
||||||
|
message: symbolName,
|
||||||
|
},
|
||||||
|
}, domain)
|
||||||
|
|
||||||
updateBrowseState({
|
updateBrowseState({
|
||||||
selectedSymbolInfo: {
|
selectedSymbolInfo: {
|
||||||
|
|
@ -145,13 +154,19 @@ export const PureCodePreviewPanel = ({
|
||||||
isBottomPanelCollapsed: false,
|
isBottomPanelCollapsed: false,
|
||||||
activeExploreMenuTab: "references",
|
activeExploreMenuTab: "references",
|
||||||
})
|
})
|
||||||
}, [captureEvent, updateBrowseState, repoName, revisionName, language]);
|
}, [captureEvent, updateBrowseState, repoName, revisionName, language, domain]);
|
||||||
|
|
||||||
|
|
||||||
// If we resolve multiple matches, instead of navigating to the first match, we should
|
// If we resolve multiple matches, instead of navigating to the first match, we should
|
||||||
// instead popup the bottom sheet with the list of matches.
|
// instead popup the bottom sheet with the list of matches.
|
||||||
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
|
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
|
||||||
captureEvent('wa_browse_goto_definition_pressed', {});
|
captureEvent('wa_browse_goto_definition_pressed', {});
|
||||||
|
createAuditAction({
|
||||||
|
action: "user.performed_goto_definition",
|
||||||
|
metadata: {
|
||||||
|
message: symbolName,
|
||||||
|
},
|
||||||
|
}, domain)
|
||||||
|
|
||||||
if (symbolDefinitions.length === 0) {
|
if (symbolDefinitions.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -180,7 +195,7 @@ export const PureCodePreviewPanel = ({
|
||||||
isBottomPanelCollapsed: false,
|
isBottomPanelCollapsed: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
|
}, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language, domain]);
|
||||||
|
|
||||||
const theme = useCodeMirrorTheme();
|
const theme = useCodeMirrorTheme();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
|
||||||
import { Toggle } from "@/components/ui/toggle";
|
import { Toggle } from "@/components/ui/toggle";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
|
||||||
|
import { createAuditAction } from "@/ee/features/audit/actions";
|
||||||
import tailwind from "@/tailwind";
|
import tailwind from "@/tailwind";
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
|
|
@ -204,6 +205,13 @@ export const SearchBar = ({
|
||||||
setIsSuggestionsEnabled(false);
|
setIsSuggestionsEnabled(false);
|
||||||
setIsHistorySearchEnabled(false);
|
setIsHistorySearchEnabled(false);
|
||||||
|
|
||||||
|
createAuditAction({
|
||||||
|
action: "user.performed_code_search",
|
||||||
|
metadata: {
|
||||||
|
message: query,
|
||||||
|
},
|
||||||
|
}, domain)
|
||||||
|
|
||||||
const url = createPathWithQueryParams(`/${domain}/search`,
|
const url = createPathWithQueryParams(`/${domain}/search`,
|
||||||
[SearchQueryParams.query, query],
|
[SearchQueryParams.query, query],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,9 @@ import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPo
|
||||||
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
|
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
|
||||||
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
|
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
|
||||||
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
|
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
|
||||||
|
import { createAuditAction } from "@/ee/features/audit/actions";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
|
||||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||||
|
|
||||||
export interface CodePreviewFile {
|
export interface CodePreviewFile {
|
||||||
|
|
@ -50,6 +53,7 @@ export const CodePreview = ({
|
||||||
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
|
const [editorRef, setEditorRef] = useState<ReactCodeMirrorRef | null>(null);
|
||||||
const { navigateToPath } = useBrowseNavigation();
|
const { navigateToPath } = useBrowseNavigation();
|
||||||
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
|
||||||
|
const domain = useDomain();
|
||||||
|
|
||||||
const [gutterWidth, setGutterWidth] = useState(0);
|
const [gutterWidth, setGutterWidth] = useState(0);
|
||||||
const theme = useCodeMirrorTheme();
|
const theme = useCodeMirrorTheme();
|
||||||
|
|
@ -116,6 +120,12 @@ export const CodePreview = ({
|
||||||
|
|
||||||
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
|
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
|
||||||
captureEvent('wa_preview_panel_goto_definition_pressed', {});
|
captureEvent('wa_preview_panel_goto_definition_pressed', {});
|
||||||
|
createAuditAction({
|
||||||
|
action: "user.performed_goto_definition",
|
||||||
|
metadata: {
|
||||||
|
message: symbolName,
|
||||||
|
},
|
||||||
|
}, domain)
|
||||||
|
|
||||||
if (symbolDefinitions.length === 0) {
|
if (symbolDefinitions.length === 0) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -150,10 +160,16 @@ export const CodePreview = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
|
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
|
||||||
|
|
||||||
const onFindReferences = useCallback((symbolName: string) => {
|
const onFindReferences = useCallback((symbolName: string) => {
|
||||||
captureEvent('wa_preview_panel_find_references_pressed', {});
|
captureEvent('wa_preview_panel_find_references_pressed', {});
|
||||||
|
createAuditAction({
|
||||||
|
action: "user.performed_find_references",
|
||||||
|
metadata: {
|
||||||
|
message: symbolName,
|
||||||
|
},
|
||||||
|
}, domain)
|
||||||
|
|
||||||
navigateToPath({
|
navigateToPath({
|
||||||
repoName,
|
repoName,
|
||||||
|
|
@ -171,7 +187,7 @@ export const CodePreview = ({
|
||||||
isBottomPanelCollapsed: false,
|
isBottomPanelCollapsed: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
|
}, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
|
|
|
||||||
19
packages/web/src/app/[domain]/settings/analytics/page.tsx
Normal file
19
packages/web/src/app/[domain]/settings/analytics/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
|
|
@ -85,6 +85,10 @@ export default async function SettingsLayout({
|
||||||
title: "API Keys",
|
title: "API Keys",
|
||||||
href: `/${domain}/settings/apiKeys`,
|
href: `/${domain}/settings/apiKeys`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Analytics",
|
||||||
|
href: `/${domain}/settings/analytics`,
|
||||||
|
},
|
||||||
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
|
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
|
||||||
{
|
{
|
||||||
title: "License",
|
title: "License",
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
trustHost: true,
|
trustHost: true,
|
||||||
events: {
|
events: {
|
||||||
createUser: onCreateUser,
|
createUser: onCreateUser,
|
||||||
signIn: async ({ user, account }) => {
|
signIn: async ({ user }) => {
|
||||||
if (user.id) {
|
if (user.id) {
|
||||||
await auditService.createAudit({
|
await auditService.createAudit({
|
||||||
action: "user.signed_in",
|
action: "user.signed_in",
|
||||||
|
|
|
||||||
365
packages/web/src/components/ui/chart.tsx
Normal file
365
packages/web/src/components/ui/chart.tsx
Normal 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,
|
||||||
|
}
|
||||||
103
packages/web/src/ee/features/analytics/actions.ts
Normal file
103
packages/web/src/ee/features/analytics/actions.ts
Normal 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)
|
||||||
|
);
|
||||||
650
packages/web/src/ee/features/analytics/analyticsContent.tsx
Normal file
650
packages/web/src/ee/features/analytics/analyticsContent.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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'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's enterprise features? Reach out to us and we'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
packages/web/src/ee/features/analytics/types.ts
Normal file
10
packages/web/src/ee/features/analytics/types.ts
Normal 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>;
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
"use server";
|
||||||
|
|
||||||
import { prisma } from "@/prisma";
|
import { prisma } from "@/prisma";
|
||||||
import { ErrorCode } from "@/lib/errorCodes";
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
import { StatusCodes } from "http-status-codes";
|
import { StatusCodes } from "http-status-codes";
|
||||||
|
|
@ -6,10 +8,18 @@ import { OrgRole } from "@sourcebot/db";
|
||||||
import { createLogger } from "@sourcebot/logger";
|
import { createLogger } from "@sourcebot/logger";
|
||||||
import { ServiceError } from "@/lib/serviceError";
|
import { ServiceError } from "@/lib/serviceError";
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
import { getAuditService } from "@/ee/features/audit/factory";
|
||||||
|
import { AuditEvent } from "./types";
|
||||||
|
|
||||||
const auditService = getAuditService();
|
const auditService = getAuditService();
|
||||||
const logger = createLogger('audit-utils');
|
const logger = createLogger('audit-utils');
|
||||||
|
|
||||||
|
export const createAuditAction = async (event: Omit<AuditEvent, 'sourcebotVersion' | 'orgId' | 'actor' | 'target'>, domain: string) => sew(async () =>
|
||||||
|
withAuth((userId) =>
|
||||||
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
await auditService.createAudit({ ...event, orgId: org.id, actor: { id: userId, type: "user" }, target: { id: org.id.toString(), type: "org" } })
|
||||||
|
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowSingleTenantUnauthedAccess = */ true)
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() =>
|
export const fetchAuditRecords = async (domain: string, apiKey: string | undefined = undefined) => sew(() =>
|
||||||
withAuth((userId) =>
|
withAuth((userId) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { IAuditService } from '@/ee/features/audit/types';
|
import { IAuditService } from '@/ee/features/audit/types';
|
||||||
import { MockAuditService } from '@/ee/features/audit/mockAuditService';
|
import { MockAuditService } from '@/ee/features/audit/mockAuditService';
|
||||||
import { AuditService } from '@/ee/features/audit/auditService';
|
import { AuditService } from '@/ee/features/audit/auditService';
|
||||||
|
import { hasEntitlement } from '@sourcebot/shared';
|
||||||
import { env } from '@/env.mjs';
|
import { env } from '@/env.mjs';
|
||||||
|
|
||||||
let enterpriseService: IAuditService | undefined;
|
let enterpriseService: IAuditService | undefined;
|
||||||
|
|
||||||
export function getAuditService(): IAuditService {
|
export function getAuditService(): IAuditService {
|
||||||
enterpriseService = enterpriseService ?? (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true' ? new AuditService() : new MockAuditService());
|
const auditLogsEnabled = (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') && hasEntitlement("audit");
|
||||||
|
enterpriseService = enterpriseService ?? (auditLogsEnabled ? new AuditService() : new MockAuditService());
|
||||||
return enterpriseService;
|
return enterpriseService;
|
||||||
}
|
}
|
||||||
|
|
@ -85,7 +85,7 @@ export const env = createEnv({
|
||||||
|
|
||||||
// EE License
|
// EE License
|
||||||
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
|
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
|
||||||
SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('false'),
|
SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('true'),
|
||||||
|
|
||||||
// GitHub app for review agent
|
// GitHub app for review agent
|
||||||
GITHUB_APP_ID: z.string().optional(),
|
GITHUB_APP_ID: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,13 @@ import { isServiceError } from "../../lib/utils";
|
||||||
import { search } from "./searchApi";
|
import { search } from "./searchApi";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { OrgRole } from "@sourcebot/db";
|
import { OrgRole } from "@sourcebot/db";
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
|
||||||
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
|
// @todo (bkellam) : We should really be using `git show <hash>:<path>` to fetch file contents here.
|
||||||
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
// This will allow us to support permalinks to files at a specific revision that may not be indexed
|
||||||
// by zoekt.
|
// by zoekt.
|
||||||
|
|
||||||
const auditService = getAuditService();
|
|
||||||
|
|
||||||
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
export const getFileSource = async ({ fileName, repository, branch }: FileSourceRequest, domain: string, apiKey: string | undefined = undefined): Promise<FileSourceResponse | ServiceError> => sew(() =>
|
||||||
withAuth((userId, apiKeyHash) =>
|
withAuth((userId, _apiKeyHash) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async () => {
|
||||||
const escapedFileName = escapeStringRegexp(fileName);
|
const escapedFileName = escapeStringRegexp(fileName);
|
||||||
const escapedRepository = escapeStringRegexp(repository);
|
const escapedRepository = escapeStringRegexp(repository);
|
||||||
|
|
||||||
|
|
@ -45,18 +42,6 @@ export const getFileSource = async ({ fileName, repository, branch }: FileSource
|
||||||
const source = file.content ?? '';
|
const source = file.content ?? '';
|
||||||
const language = file.language;
|
const language = file.language;
|
||||||
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "query.file_source",
|
|
||||||
actor: {
|
|
||||||
id: apiKeyHash ?? userId,
|
|
||||||
type: apiKeyHash ? "api_key" : "user"
|
|
||||||
},
|
|
||||||
orgId: org.id,
|
|
||||||
target: {
|
|
||||||
id: `${escapedRepository}/${escapedFileName}${branch ? `:${branch}` : ''}`,
|
|
||||||
type: "file"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
source,
|
source,
|
||||||
language,
|
language,
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,9 @@ import { ListRepositoriesResponse } from "./types";
|
||||||
import { zoektFetch } from "./zoektClient";
|
import { zoektFetch } from "./zoektClient";
|
||||||
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
|
import { zoektListRepositoriesResponseSchema } from "./zoektSchema";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
|
||||||
|
|
||||||
const auditService = getAuditService();
|
|
||||||
|
|
||||||
export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise<ListRepositoriesResponse | ServiceError> => sew(() =>
|
export const listRepositories = async (domain: string, apiKey: string | undefined = undefined): Promise<ListRepositoriesResponse | ServiceError> => sew(() =>
|
||||||
withAuth((userId, apiKeyHash) =>
|
withAuth((userId, _apiKeyHash) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
opts: {
|
opts: {
|
||||||
|
|
@ -47,22 +44,6 @@ export const listRepositories = async (domain: string, apiKey: string | undefine
|
||||||
|
|
||||||
const result = parser.parse(listBody);
|
const result = parser.parse(listBody);
|
||||||
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "query.list_repositories",
|
|
||||||
actor: {
|
|
||||||
id: apiKeyHash ?? userId,
|
|
||||||
type: apiKeyHash ? "api_key" : "user"
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
id: org.id.toString(),
|
|
||||||
type: "org"
|
|
||||||
},
|
|
||||||
orgId: org.id,
|
|
||||||
metadata: {
|
|
||||||
message: result.repos.map((repo) => repo.name).join(", ")
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true, apiKey ? { apiKey, domain } : undefined)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,6 @@ import { OrgRole, Repo } from "@sourcebot/db";
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
import { sew, withAuth, withOrgMembership } from "@/actions";
|
import { sew, withAuth, withOrgMembership } from "@/actions";
|
||||||
import { base64Decode } from "@sourcebot/shared";
|
import { base64Decode } from "@sourcebot/shared";
|
||||||
import { getAuditService } from "@/ee/features/audit/factory";
|
|
||||||
|
|
||||||
const auditService = getAuditService();
|
|
||||||
|
|
||||||
// List of supported query prefixes in zoekt.
|
// List of supported query prefixes in zoekt.
|
||||||
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
// @see : https://github.com/sourcebot-dev/zoekt/blob/main/query/parse.go#L417
|
||||||
|
|
@ -129,7 +126,7 @@ const getFileWebUrl = (template: string, branch: string, fileName: string): stri
|
||||||
}
|
}
|
||||||
|
|
||||||
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() =>
|
export const search = async ({ query, matches, contextLines, whole }: SearchRequest, domain: string, apiKey: string | undefined = undefined) => sew(() =>
|
||||||
withAuth((userId, apiKeyHash) =>
|
withAuth((userId, _apiKeyHash) =>
|
||||||
withOrgMembership(userId, domain, async ({ org }) => {
|
withOrgMembership(userId, domain, async ({ org }) => {
|
||||||
const transformedQuery = await transformZoektQuery(query, org.id);
|
const transformedQuery = await transformZoektQuery(query, org.id);
|
||||||
if (isServiceError(transformedQuery)) {
|
if (isServiceError(transformedQuery)) {
|
||||||
|
|
@ -302,22 +299,6 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
|
||||||
}
|
}
|
||||||
}).filter((file) => file !== undefined) ?? [];
|
}).filter((file) => file !== undefined) ?? [];
|
||||||
|
|
||||||
await auditService.createAudit({
|
|
||||||
action: "query.code_search",
|
|
||||||
actor: {
|
|
||||||
id: apiKeyHash ?? userId,
|
|
||||||
type: apiKeyHash ? "api_key" : "user"
|
|
||||||
},
|
|
||||||
target: {
|
|
||||||
id: org.id.toString(),
|
|
||||||
type: "org"
|
|
||||||
},
|
|
||||||
orgId: org.id,
|
|
||||||
metadata: {
|
|
||||||
message: query,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
zoektStats: {
|
zoektStats: {
|
||||||
duration: Result.Duration,
|
duration: Result.Duration,
|
||||||
|
|
|
||||||
|
|
@ -158,15 +158,6 @@ const pruneOldGuestUser = async () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateEntitlements = () => {
|
|
||||||
if (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED === 'true') {
|
|
||||||
if (!hasEntitlement('audit')) {
|
|
||||||
logger.error(`Audit logging is enabled but your license does not include the audit logging entitlement. Please reach out to us to enquire about upgrading your license.`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const initSingleTenancy = async () => {
|
const initSingleTenancy = async () => {
|
||||||
await prisma.org.upsert({
|
await prisma.org.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -184,9 +175,6 @@ const initSingleTenancy = async () => {
|
||||||
// To keep things simple, we'll just delete the old guest user if it exists in the DB
|
// To keep things simple, we'll just delete the old guest user if it exists in the DB
|
||||||
await pruneOldGuestUser();
|
await pruneOldGuestUser();
|
||||||
|
|
||||||
// Startup time entitlement/environment variable validation
|
|
||||||
validateEntitlements();
|
|
||||||
|
|
||||||
const hasPublicAccessEntitlement = hasEntitlement("public-access");
|
const hasPublicAccessEntitlement = hasEntitlement("public-access");
|
||||||
if (hasPublicAccessEntitlement) {
|
if (hasPublicAccessEntitlement) {
|
||||||
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);
|
const res = await createGuestUser(SINGLE_TENANT_ORG_DOMAIN);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { NewsItem } from "./types";
|
import { NewsItem } from "./types";
|
||||||
|
|
||||||
export const newsData: NewsItem[] = [
|
export const newsData: NewsItem[] = [
|
||||||
|
{
|
||||||
|
unique_id: "analytics",
|
||||||
|
header: "Analytics Dashboard",
|
||||||
|
sub_header: "Understand your team's Sourcebot usage",
|
||||||
|
url: "https://docs.sourcebot.dev/docs/features/analytics"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
unique_id: "audit-logs",
|
unique_id: "audit-logs",
|
||||||
header: "Audit logs",
|
header: "Audit logs",
|
||||||
|
|
|
||||||
325
yarn.lock
325
yarn.lock
|
|
@ -280,6 +280,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.8.7":
|
||||||
|
version: 7.27.6
|
||||||
|
resolution: "@babel/runtime@npm:7.27.6"
|
||||||
|
checksum: 10c0/89726be83f356f511dcdb74d3ea4d873a5f0cf0017d4530cb53aa27380c01ca102d573eff8b8b77815e624b1f8c24e7f0311834ad4fb632c90a770fda00bd4c8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@babel/template@npm:^7.24.0, @babel/template@npm:^7.26.9":
|
"@babel/template@npm:^7.24.0, @babel/template@npm:^7.26.9":
|
||||||
version: 7.26.9
|
version: 7.26.9
|
||||||
resolution: "@babel/template@npm:7.26.9"
|
resolution: "@babel/template@npm:7.26.9"
|
||||||
|
|
@ -6017,6 +6024,7 @@ __metadata:
|
||||||
codemirror-lang-spreadsheet: "npm:^1.3.0"
|
codemirror-lang-spreadsheet: "npm:^1.3.0"
|
||||||
codemirror-lang-zig: "npm:^0.1.0"
|
codemirror-lang-zig: "npm:^0.1.0"
|
||||||
cross-env: "npm:^7.0.3"
|
cross-env: "npm:^7.0.3"
|
||||||
|
date-fns: "npm:^4.1.0"
|
||||||
embla-carousel-auto-scroll: "npm:^8.3.0"
|
embla-carousel-auto-scroll: "npm:^8.3.0"
|
||||||
embla-carousel-react: "npm:^8.3.0"
|
embla-carousel-react: "npm:^8.3.0"
|
||||||
escape-string-regexp: "npm:^5.0.0"
|
escape-string-regexp: "npm:^5.0.0"
|
||||||
|
|
@ -6030,7 +6038,7 @@ __metadata:
|
||||||
http-status-codes: "npm:^2.3.0"
|
http-status-codes: "npm:^2.3.0"
|
||||||
input-otp: "npm:^1.4.2"
|
input-otp: "npm:^1.4.2"
|
||||||
jsdom: "npm:^25.0.1"
|
jsdom: "npm:^25.0.1"
|
||||||
lucide-react: "npm:^0.435.0"
|
lucide-react: "npm:^0.517.0"
|
||||||
micromatch: "npm:^4.0.8"
|
micromatch: "npm:^4.0.8"
|
||||||
next: "npm:14.2.26"
|
next: "npm:14.2.26"
|
||||||
next-auth: "npm:^5.0.0-beta.25"
|
next-auth: "npm:^5.0.0-beta.25"
|
||||||
|
|
@ -6052,6 +6060,7 @@ __metadata:
|
||||||
react-hotkeys-hook: "npm:^4.5.1"
|
react-hotkeys-hook: "npm:^4.5.1"
|
||||||
react-icons: "npm:^5.3.0"
|
react-icons: "npm:^5.3.0"
|
||||||
react-resizable-panels: "npm:^2.1.1"
|
react-resizable-panels: "npm:^2.1.1"
|
||||||
|
recharts: "npm:^2.15.3"
|
||||||
scroll-into-view-if-needed: "npm:^3.1.0"
|
scroll-into-view-if-needed: "npm:^3.1.0"
|
||||||
server-only: "npm:^0.0.1"
|
server-only: "npm:^0.0.1"
|
||||||
sharp: "npm:^0.33.5"
|
sharp: "npm:^0.33.5"
|
||||||
|
|
@ -6309,6 +6318,75 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-array@npm:^3.0.3":
|
||||||
|
version: 3.2.1
|
||||||
|
resolution: "@types/d3-array@npm:3.2.1"
|
||||||
|
checksum: 10c0/38bf2c778451f4b79ec81a2288cb4312fe3d6449ecdf562970cc339b60f280f31c93a024c7ff512607795e79d3beb0cbda123bb07010167bce32927f71364bca
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-color@npm:*":
|
||||||
|
version: 3.1.3
|
||||||
|
resolution: "@types/d3-color@npm:3.1.3"
|
||||||
|
checksum: 10c0/65eb0487de606eb5ad81735a9a5b3142d30bc5ea801ed9b14b77cb14c9b909f718c059f13af341264ee189acf171508053342142bdf99338667cea26a2d8d6ae
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-ease@npm:^3.0.0":
|
||||||
|
version: 3.0.2
|
||||||
|
resolution: "@types/d3-ease@npm:3.0.2"
|
||||||
|
checksum: 10c0/aff5a1e572a937ee9bff6465225d7ba27d5e0c976bd9eacdac2e6f10700a7cb0c9ea2597aff6b43a6ed850a3210030870238894a77ec73e309b4a9d0333f099c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-interpolate@npm:^3.0.1":
|
||||||
|
version: 3.0.4
|
||||||
|
resolution: "@types/d3-interpolate@npm:3.0.4"
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-color": "npm:*"
|
||||||
|
checksum: 10c0/066ebb8da570b518dd332df6b12ae3b1eaa0a7f4f0c702e3c57f812cf529cc3500ec2aac8dc094f31897790346c6b1ebd8cd7a077176727f4860c2b181a65ca4
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-path@npm:*":
|
||||||
|
version: 3.1.1
|
||||||
|
resolution: "@types/d3-path@npm:3.1.1"
|
||||||
|
checksum: 10c0/2c36eb31ebaf2ce4712e793fd88087117976f7c4ed69cc2431825f999c8c77cca5cea286f3326432b770739ac6ccd5d04d851eb65e7a4dbcc10c982b49ad2c02
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-scale@npm:^4.0.2":
|
||||||
|
version: 4.0.9
|
||||||
|
resolution: "@types/d3-scale@npm:4.0.9"
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-time": "npm:*"
|
||||||
|
checksum: 10c0/4ac44233c05cd50b65b33ecb35d99fdf07566bcdbc55bc1306b2f27d1c5134d8c560d356f2c8e76b096e9125ffb8d26d95f78d56e210d1c542cb255bdf31d6c8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-shape@npm:^3.1.0":
|
||||||
|
version: 3.1.7
|
||||||
|
resolution: "@types/d3-shape@npm:3.1.7"
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-path": "npm:*"
|
||||||
|
checksum: 10c0/38e59771c1c4c83b67aa1f941ce350410522a149d2175832fdc06396b2bb3b2c1a2dd549e0f8230f9f24296ee5641a515eaf10f55ee1ef6c4f83749e2dd7dcfd
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-time@npm:*, @types/d3-time@npm:^3.0.0":
|
||||||
|
version: 3.0.4
|
||||||
|
resolution: "@types/d3-time@npm:3.0.4"
|
||||||
|
checksum: 10c0/6d9e2255d63f7a313a543113920c612e957d70da4fb0890931da6c2459010291b8b1f95e149a538500c1c99e7e6c89ffcce5554dd29a31ff134a38ea94b6d174
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"@types/d3-timer@npm:^3.0.0":
|
||||||
|
version: 3.0.2
|
||||||
|
resolution: "@types/d3-timer@npm:3.0.2"
|
||||||
|
checksum: 10c0/c644dd9571fcc62b1aa12c03bcad40571553020feeb5811f1d8a937ac1e65b8a04b759b4873aef610e28b8714ac71c9885a4d6c127a048d95118f7e5b506d9e1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"@types/estree@npm:*, @types/estree@npm:1.0.6, @types/estree@npm:^1.0.0":
|
"@types/estree@npm:*, @types/estree@npm:1.0.6, @types/estree@npm:^1.0.0":
|
||||||
version: 1.0.6
|
version: 1.0.6
|
||||||
resolution: "@types/estree@npm:1.0.6"
|
resolution: "@types/estree@npm:1.0.6"
|
||||||
|
|
@ -7950,7 +8028,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"clsx@npm:^2.1.1":
|
"clsx@npm:^2.0.0, clsx@npm:^2.1.1":
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
resolution: "clsx@npm:2.1.1"
|
resolution: "clsx@npm:2.1.1"
|
||||||
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
|
checksum: 10c0/c4c8eb865f8c82baab07e71bfa8897c73454881c4f99d6bc81585aecd7c441746c1399d08363dc096c550cceaf97bd4ce1e8854e1771e9998d9f94c4fe075839
|
||||||
|
|
@ -8505,6 +8583,99 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6":
|
||||||
|
version: 3.2.4
|
||||||
|
resolution: "d3-array@npm:3.2.4"
|
||||||
|
dependencies:
|
||||||
|
internmap: "npm:1 - 2"
|
||||||
|
checksum: 10c0/08b95e91130f98c1375db0e0af718f4371ccacef7d5d257727fe74f79a24383e79aba280b9ffae655483ffbbad4fd1dec4ade0119d88c4749f388641c8bf8c50
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-color@npm:1 - 3":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "d3-color@npm:3.1.0"
|
||||||
|
checksum: 10c0/a4e20e1115fa696fce041fbe13fbc80dc4c19150fa72027a7c128ade980bc0eeeba4bcf28c9e21f0bce0e0dbfe7ca5869ef67746541dcfda053e4802ad19783c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-ease@npm:^3.0.1":
|
||||||
|
version: 3.0.1
|
||||||
|
resolution: "d3-ease@npm:3.0.1"
|
||||||
|
checksum: 10c0/fec8ef826c0cc35cda3092c6841e07672868b1839fcaf556e19266a3a37e6bc7977d8298c0fcb9885e7799bfdcef7db1baaba9cd4dcf4bc5e952cf78574a88b0
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-format@npm:1 - 3":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "d3-format@npm:3.1.0"
|
||||||
|
checksum: 10c0/049f5c0871ebce9859fc5e2f07f336b3c5bfff52a2540e0bac7e703fce567cd9346f4ad1079dd18d6f1e0eaa0599941c1810898926f10ac21a31fd0a34b4aa75
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1":
|
||||||
|
version: 3.0.1
|
||||||
|
resolution: "d3-interpolate@npm:3.0.1"
|
||||||
|
dependencies:
|
||||||
|
d3-color: "npm:1 - 3"
|
||||||
|
checksum: 10c0/19f4b4daa8d733906671afff7767c19488f51a43d251f8b7f484d5d3cfc36c663f0a66c38fe91eee30f40327443d799be17169f55a293a3ba949e84e57a33e6a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-path@npm:^3.1.0":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "d3-path@npm:3.1.0"
|
||||||
|
checksum: 10c0/dc1d58ec87fa8319bd240cf7689995111a124b141428354e9637aa83059eb12e681f77187e0ada5dedfce346f7e3d1f903467ceb41b379bfd01cd8e31721f5da
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-scale@npm:^4.0.2":
|
||||||
|
version: 4.0.2
|
||||||
|
resolution: "d3-scale@npm:4.0.2"
|
||||||
|
dependencies:
|
||||||
|
d3-array: "npm:2.10.0 - 3"
|
||||||
|
d3-format: "npm:1 - 3"
|
||||||
|
d3-interpolate: "npm:1.2.0 - 3"
|
||||||
|
d3-time: "npm:2.1.1 - 3"
|
||||||
|
d3-time-format: "npm:2 - 4"
|
||||||
|
checksum: 10c0/65d9ad8c2641aec30ed5673a7410feb187a224d6ca8d1a520d68a7d6eac9d04caedbff4713d1e8545be33eb7fec5739983a7ab1d22d4e5ad35368c6729d362f1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-shape@npm:^3.1.0":
|
||||||
|
version: 3.2.0
|
||||||
|
resolution: "d3-shape@npm:3.2.0"
|
||||||
|
dependencies:
|
||||||
|
d3-path: "npm:^3.1.0"
|
||||||
|
checksum: 10c0/f1c9d1f09926daaf6f6193ae3b4c4b5521e81da7d8902d24b38694517c7f527ce3c9a77a9d3a5722ad1e3ff355860b014557b450023d66a944eabf8cfde37132
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-time-format@npm:2 - 4":
|
||||||
|
version: 4.1.0
|
||||||
|
resolution: "d3-time-format@npm:4.1.0"
|
||||||
|
dependencies:
|
||||||
|
d3-time: "npm:1 - 3"
|
||||||
|
checksum: 10c0/735e00fb25a7fd5d418fac350018713ae394eefddb0d745fab12bbff0517f9cdb5f807c7bbe87bb6eeb06249662f8ea84fec075f7d0cd68609735b2ceb29d206
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-time@npm:1 - 3, d3-time@npm:2.1.1 - 3, d3-time@npm:^3.0.0":
|
||||||
|
version: 3.1.0
|
||||||
|
resolution: "d3-time@npm:3.1.0"
|
||||||
|
dependencies:
|
||||||
|
d3-array: "npm:2 - 3"
|
||||||
|
checksum: 10c0/a984f77e1aaeaa182679b46fbf57eceb6ebdb5f67d7578d6f68ef933f8eeb63737c0949991618a8d29472dbf43736c7d7f17c452b2770f8c1271191cba724ca1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"d3-timer@npm:^3.0.1":
|
||||||
|
version: 3.0.1
|
||||||
|
resolution: "d3-timer@npm:3.0.1"
|
||||||
|
checksum: 10c0/d4c63cb4bb5461d7038aac561b097cd1c5673969b27cbdd0e87fa48d9300a538b9e6f39b4a7f0e3592ef4f963d858c8a9f0e92754db73116770856f2fc04561a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"damerau-levenshtein@npm:^1.0.8":
|
"damerau-levenshtein@npm:^1.0.8":
|
||||||
version: 1.0.8
|
version: 1.0.8
|
||||||
resolution: "damerau-levenshtein@npm:1.0.8"
|
resolution: "damerau-levenshtein@npm:1.0.8"
|
||||||
|
|
@ -8555,6 +8726,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"date-fns@npm:^4.1.0":
|
||||||
|
version: 4.1.0
|
||||||
|
resolution: "date-fns@npm:4.1.0"
|
||||||
|
checksum: 10c0/b79ff32830e6b7faa009590af6ae0fb8c3fd9ffad46d930548fbb5acf473773b4712ae887e156ba91a7b3dc30591ce0f517d69fd83bd9c38650fdc03b4e0bac8
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"debounce-promise@npm:^3.1.2":
|
"debounce-promise@npm:^3.1.2":
|
||||||
version: 3.1.2
|
version: 3.1.2
|
||||||
resolution: "debounce-promise@npm:3.1.2"
|
resolution: "debounce-promise@npm:3.1.2"
|
||||||
|
|
@ -8611,6 +8789,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"decimal.js-light@npm:^2.4.1":
|
||||||
|
version: 2.5.1
|
||||||
|
resolution: "decimal.js-light@npm:2.5.1"
|
||||||
|
checksum: 10c0/4fd33f535aac9e5bd832796831b65d9ec7914ad129c7437b3ab991b0c2eaaa5a57e654e6174c4a17f1b3895ea366f0c1ab4955cdcdf7cfdcf3ad5a58b456c020
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"decimal.js@npm:^10.4.3":
|
"decimal.js@npm:^10.4.3":
|
||||||
version: 10.5.0
|
version: 10.5.0
|
||||||
resolution: "decimal.js@npm:10.5.0"
|
resolution: "decimal.js@npm:10.5.0"
|
||||||
|
|
@ -8776,6 +8961,16 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"dom-helpers@npm:^5.0.1":
|
||||||
|
version: 5.2.1
|
||||||
|
resolution: "dom-helpers@npm:5.2.1"
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime": "npm:^7.8.7"
|
||||||
|
csstype: "npm:^3.0.2"
|
||||||
|
checksum: 10c0/f735074d66dd759b36b158fa26e9d00c9388ee0e8c9b16af941c38f014a37fc80782de83afefd621681b19ac0501034b4f1c4a3bff5caa1b8667f0212b5e124c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"dom-serializer@npm:^2.0.0":
|
"dom-serializer@npm:^2.0.0":
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
resolution: "dom-serializer@npm:2.0.0"
|
resolution: "dom-serializer@npm:2.0.0"
|
||||||
|
|
@ -9808,6 +10003,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"eventemitter3@npm:^4.0.1":
|
||||||
|
version: 4.0.7
|
||||||
|
resolution: "eventemitter3@npm:4.0.7"
|
||||||
|
checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"eventsource-parser@npm:^3.0.1":
|
"eventsource-parser@npm:^3.0.1":
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
resolution: "eventsource-parser@npm:3.0.1"
|
resolution: "eventsource-parser@npm:3.0.1"
|
||||||
|
|
@ -9956,6 +10158,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"fast-equals@npm:^5.0.1":
|
||||||
|
version: 5.2.2
|
||||||
|
resolution: "fast-equals@npm:5.2.2"
|
||||||
|
checksum: 10c0/2bfeac6317a8959a00e2134749323557e5df6dea3af24e4457297733eace8ce4313fcbca2cf4532f3a6792607461e80442cd8d3af148d5c2e4e98ad996d6e5b5
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2":
|
"fast-glob@npm:^3.2.9, fast-glob@npm:^3.3.2":
|
||||||
version: 3.3.3
|
version: 3.3.3
|
||||||
resolution: "fast-glob@npm:3.3.3"
|
resolution: "fast-glob@npm:3.3.3"
|
||||||
|
|
@ -10930,6 +11139,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"internmap@npm:1 - 2":
|
||||||
|
version: 2.0.3
|
||||||
|
resolution: "internmap@npm:2.0.3"
|
||||||
|
checksum: 10c0/8cedd57f07bbc22501516fbfc70447f0c6812871d471096fad9ea603516eacc2137b633633daf432c029712df0baefd793686388ddf5737e3ea15074b877f7ed
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"ioredis@npm:^5.4.1, ioredis@npm:^5.4.2":
|
"ioredis@npm:^5.4.1, ioredis@npm:^5.4.2":
|
||||||
version: 5.6.0
|
version: 5.6.0
|
||||||
resolution: "ioredis@npm:5.6.0"
|
resolution: "ioredis@npm:5.6.0"
|
||||||
|
|
@ -11825,12 +12041,12 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"lucide-react@npm:^0.435.0":
|
"lucide-react@npm:^0.517.0":
|
||||||
version: 0.435.0
|
version: 0.517.0
|
||||||
resolution: "lucide-react@npm:0.435.0"
|
resolution: "lucide-react@npm:0.517.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
checksum: 10c0/8a1653901a362d83696b555ff2cc1f06126008c8a09b1b8a0db8484b98c9de5c44589d00296d1f419ebb4e65524fcc4eb1fe39f1721dd22242dc34788f8936f5
|
checksum: 10c0/9e827d7c5fd441b9628778e4a121fca4c6354b6aa4fab8b3efda1b060dd3d0b4dac43ee813161ef30f30d0919009fc4565e620d59d4e9bf9425269e242156edb
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
@ -13626,7 +13842,7 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
|
"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1":
|
||||||
version: 15.8.1
|
version: 15.8.1
|
||||||
resolution: "prop-types@npm:15.8.1"
|
resolution: "prop-types@npm:15.8.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -13872,6 +14088,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-is@npm:^18.3.1":
|
||||||
|
version: 18.3.1
|
||||||
|
resolution: "react-is@npm:18.3.1"
|
||||||
|
checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react-promise-suspense@npm:0.3.4":
|
"react-promise-suspense@npm:0.3.4":
|
||||||
version: 0.3.4
|
version: 0.3.4
|
||||||
resolution: "react-promise-suspense@npm:0.3.4"
|
resolution: "react-promise-suspense@npm:0.3.4"
|
||||||
|
|
@ -13945,6 +14168,20 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-smooth@npm:^4.0.4":
|
||||||
|
version: 4.0.4
|
||||||
|
resolution: "react-smooth@npm:4.0.4"
|
||||||
|
dependencies:
|
||||||
|
fast-equals: "npm:^5.0.1"
|
||||||
|
prop-types: "npm:^15.8.1"
|
||||||
|
react-transition-group: "npm:^4.4.5"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
checksum: 10c0/d94cb27f808721ec040d320ca1927919199495fd212e54eb9dc8ee3f73ff1d808a34be9f4b09fe49b01f411ac2387fdf0e4bee297f18faf56f94bfbef5fd204c
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3":
|
"react-style-singleton@npm:^2.2.1, react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3":
|
||||||
version: 2.2.3
|
version: 2.2.3
|
||||||
resolution: "react-style-singleton@npm:2.2.3"
|
resolution: "react-style-singleton@npm:2.2.3"
|
||||||
|
|
@ -13961,6 +14198,21 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"react-transition-group@npm:^4.4.5":
|
||||||
|
version: 4.4.5
|
||||||
|
resolution: "react-transition-group@npm:4.4.5"
|
||||||
|
dependencies:
|
||||||
|
"@babel/runtime": "npm:^7.5.5"
|
||||||
|
dom-helpers: "npm:^5.0.1"
|
||||||
|
loose-envify: "npm:^1.4.0"
|
||||||
|
prop-types: "npm:^15.6.2"
|
||||||
|
peerDependencies:
|
||||||
|
react: ">=16.6.0"
|
||||||
|
react-dom: ">=16.6.0"
|
||||||
|
checksum: 10c0/2ba754ba748faefa15f87c96dfa700d5525054a0141de8c75763aae6734af0740e77e11261a1e8f4ffc08fd9ab78510122e05c21c2d79066c38bb6861a886c82
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"react@npm:^18":
|
"react@npm:^18":
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
resolution: "react@npm:18.3.1"
|
resolution: "react@npm:18.3.1"
|
||||||
|
|
@ -14024,6 +14276,34 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"recharts-scale@npm:^0.4.4":
|
||||||
|
version: 0.4.5
|
||||||
|
resolution: "recharts-scale@npm:0.4.5"
|
||||||
|
dependencies:
|
||||||
|
decimal.js-light: "npm:^2.4.1"
|
||||||
|
checksum: 10c0/64ce1fc4ebe62001787bf4dc4cbb779452d33831619309c71c50277c58e8968ffe98941562d9d0d5ffdb02588ebd62f4fe6548fa826110fd458db9c3cc6dadc1
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
|
"recharts@npm:^2.15.3":
|
||||||
|
version: 2.15.3
|
||||||
|
resolution: "recharts@npm:2.15.3"
|
||||||
|
dependencies:
|
||||||
|
clsx: "npm:^2.0.0"
|
||||||
|
eventemitter3: "npm:^4.0.1"
|
||||||
|
lodash: "npm:^4.17.21"
|
||||||
|
react-is: "npm:^18.3.1"
|
||||||
|
react-smooth: "npm:^4.0.4"
|
||||||
|
recharts-scale: "npm:^0.4.4"
|
||||||
|
tiny-invariant: "npm:^1.3.1"
|
||||||
|
victory-vendor: "npm:^36.6.8"
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
checksum: 10c0/76757605d67a07562bcfb1a4b9a3a0b6b5fed2b84ee5f00813cedf151502969965bf7bd3856eb7e5d60c1d71c7b0d67d9ae2f1ef45676152fcd532abafc501fb
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0":
|
"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0":
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
resolution: "redis-errors@npm:1.2.0"
|
resolution: "redis-errors@npm:1.2.0"
|
||||||
|
|
@ -15578,6 +15858,13 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"tiny-invariant@npm:^1.3.1":
|
||||||
|
version: 1.3.3
|
||||||
|
resolution: "tiny-invariant@npm:1.3.3"
|
||||||
|
checksum: 10c0/65af4a07324b591a059b35269cd696aba21bef2107f29b9f5894d83cc143159a204b299553435b03874ebb5b94d019afa8b8eff241c8a4cfee95872c2e1c1c4a
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"tinybench@npm:^2.9.0":
|
"tinybench@npm:^2.9.0":
|
||||||
version: 2.9.0
|
version: 2.9.0
|
||||||
resolution: "tinybench@npm:2.9.0"
|
resolution: "tinybench@npm:2.9.0"
|
||||||
|
|
@ -16242,6 +16529,28 @@ __metadata:
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"victory-vendor@npm:^36.6.8":
|
||||||
|
version: 36.9.2
|
||||||
|
resolution: "victory-vendor@npm:36.9.2"
|
||||||
|
dependencies:
|
||||||
|
"@types/d3-array": "npm:^3.0.3"
|
||||||
|
"@types/d3-ease": "npm:^3.0.0"
|
||||||
|
"@types/d3-interpolate": "npm:^3.0.1"
|
||||||
|
"@types/d3-scale": "npm:^4.0.2"
|
||||||
|
"@types/d3-shape": "npm:^3.1.0"
|
||||||
|
"@types/d3-time": "npm:^3.0.0"
|
||||||
|
"@types/d3-timer": "npm:^3.0.0"
|
||||||
|
d3-array: "npm:^3.1.6"
|
||||||
|
d3-ease: "npm:^3.0.1"
|
||||||
|
d3-interpolate: "npm:^3.0.1"
|
||||||
|
d3-scale: "npm:^4.0.2"
|
||||||
|
d3-shape: "npm:^3.1.0"
|
||||||
|
d3-time: "npm:^3.0.0"
|
||||||
|
d3-timer: "npm:^3.0.1"
|
||||||
|
checksum: 10c0/bad36de3bf4d406834743c2e99a8281d786af324d7e84b7f7a2fc02c27a3779034fb0c3c4707d4c8e68683334d924a67100cfa13985235565e83b9877f8e2ffd
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"vite-node@npm:2.1.9":
|
"vite-node@npm:2.1.9":
|
||||||
version: 2.1.9
|
version: 2.1.9
|
||||||
resolution: "vite-node@npm:2.1.9"
|
resolution: "vite-node@npm:2.1.9"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue