diff --git a/CLAUDE.md b/CLAUDE.md index 9162eff4..da034eb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,9 @@ ```bash npm install -npm run dev +cd web && npm install && cd .. +npm run dev # Backend +npm run dev:web # Dashboard frontend (separate terminal) ``` ## Architecture @@ -56,8 +58,10 @@ Lefthook runs pre-commit (lint, typecheck) and pre-push (test) hooks automatical - `src/triggers/` - Extensible trigger system (Trello, GitHub) - `src/agents/` - AI agent implementations - `src/gadgets/` - Custom gadgets (Trello, Git) +- `src/api/` - Dashboard API (tRPC routers, auth handlers) - `src/trello/` - Trello API client - `src/utils/` - Utilities (logging, repo cloning, lifecycle) +- `web/` - Dashboard frontend (React 19, Vite, Tailwind v4, TanStack Router) - `tools/` - Developer scripts (session debugging, DB seeding, secrets management) ## Environment Variables @@ -86,6 +90,8 @@ CASCADE stores all project configuration in PostgreSQL (Supabase). The `config/p - `agent_configs` - Per-agent-type overrides (model, iterations, backend, prompt), scoped globally, per-org, or per-project - `credentials` - Org-scoped credentials (API keys, tokens) - `project_credential_overrides` - Per-project credential overrides (optional, falls back to org defaults) +- `users` - Dashboard users (email, bcrypt password hash, org-scoped) +- `sessions` - Session tokens for cookie-based auth (30-day expiry) ### Database Scripts @@ -187,6 +193,54 @@ When using a Claude Max subscription (OAuth token), API costs are covered by the When enabled and the backend is `claude-code`, reported costs are zeroed after each session. +## Dashboard + +CASCADE includes a web dashboard for exploring agent runs, logs, LLM calls, and debug analyses. + +### Running the Dashboard + +```bash +npm run dev # Backend on :3000 (existing tsx watch) +npm run dev:web # Frontend on :5173 (Vite, proxies /trpc + /api to :3000) +``` + +### Production Build + +```bash +npm run build:web # Vite builds frontend to dist/web/ +npm run build # tsc compiles backend to dist/ +npm start # Serves API + static frontend on single port +``` + +### Architecture + +The dashboard is a single-process deployment. The Hono server mounts tRPC routes (`/trpc/*`), auth routes (`/api/auth/*`), and in production serves the built frontend as static files. + +- **API**: tRPC v11 via `@hono/trpc-server` for end-to-end type safety +- **Auth**: Session cookies (HTTP-only, 30-day expiry) with bcrypt password hashing +- **Frontend**: React 19 + Vite + Tailwind CSS v4 + shadcn/ui + TanStack Router +- **Type sharing**: Frontend imports `type AppRouter` from the backend (type-only, no server code in bundle) + +### User Management + +Users are managed via direct database inserts: + +```bash +# Generate bcrypt hash +node -e "import('bcrypt').then(b => b.default.hash('password', 10).then(console.log))" + +# Insert user +psql $DATABASE_URL -c "INSERT INTO users (org_id, email, password_hash, name, role) VALUES ('my-org', 'user@example.com', '\$2b\$10\$...', 'User Name', 'admin');" +``` + +### Key Files + +- `src/api/trpc.ts` - tRPC context, procedures, auth middleware +- `src/api/router.ts` - Root router composition (exports `type AppRouter`) +- `src/api/routers/` - tRPC routers (auth, runs, projects) +- `src/api/auth/` - Login/logout Hono handlers, session resolution +- `web/src/lib/trpc.ts` - Frontend tRPC client (type-safe via AppRouter import) + ## Adding New Triggers 1. Create trigger handler in `src/triggers/` diff --git a/package-lock.json b/package-lock.json index 33e6c425..948cf0c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,11 +11,14 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.42", "@hono/node-server": "^1.13.7", + "@hono/trpc-server": "^0.4.2", "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", "archiver": "^7.0.1", + "bcrypt": "^6.0.0", "bullmq": "^5.66.4", "chalk": "^5.4.1", "dhalsim": "^2.2.0", @@ -38,6 +41,7 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@types/adm-zip": "^0.5.7", + "@types/bcrypt": "^6.0.0", "@types/diff-match-patch": "^1.0.36", "@types/dockerode": "^3.3.47", "@types/node": "^22.10.2", @@ -1686,6 +1690,19 @@ "hono": "^4" } }, + "node_modules/@hono/trpc-server": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@hono/trpc-server/-/trpc-server-0.4.2.tgz", + "integrity": "sha512-3TDrc42CZLgcTFkXQba+y7JlRWRiyw1AqhLqztWyNS2IFT+3bHld0lxKdGBttCtGKHYx0505dM67RMazjhdZqw==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@trpc/server": "^10.10.0 || >11.0.0-rc", + "hono": ">=4.0.0" + } + }, "node_modules/@img/sharp-darwin-arm64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", @@ -2798,6 +2815,18 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@trpc/server": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.10.0.tgz", + "integrity": "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.7.2" + } + }, "node_modules/@types/adm-zip": { "version": "0.5.7", "dev": true, @@ -2813,6 +2842,16 @@ "@types/readdir-glob": "*" } }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/conventional-commits-parser": { "version": "5.0.2", "dev": true, @@ -3394,6 +3433,20 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "license": "BSD-3-Clause", @@ -6615,6 +6668,15 @@ "version": "3.1.1", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, "node_modules/node-domexception": { "version": "1.0.0", "funding": [ @@ -6661,6 +6723,17 @@ "url": "https://opencollective.com/node-fetch" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "license": "MIT", diff --git a/package.json b/package.json index 0e3a7ffe..b4388aa3 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "main": "dist/index.js", "scripts": { "dev": "tsx watch src/index.ts", + "dev:web": "cd web && npx vite", "build": "tsc", + "build:web": "cd web && npm run build", "start": "node dist/index.js", "test": "vitest run", "test:watch": "vitest", @@ -37,11 +39,14 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.42", "@hono/node-server": "^1.13.7", + "@hono/trpc-server": "^0.4.2", "@llmist/cli": "^15.2.1", "@oclif/core": "^4.8.0", "@octokit/rest": "^22.0.1", + "@trpc/server": "^11.10.0", "@types/archiver": "^7.0.0", "archiver": "^7.0.1", + "bcrypt": "^6.0.0", "bullmq": "^5.66.4", "chalk": "^5.4.1", "dhalsim": "^2.2.0", @@ -61,6 +66,7 @@ "@commitlint/cli": "^20.1.0", "@commitlint/config-conventional": "^20.0.0", "@types/adm-zip": "^0.5.7", + "@types/bcrypt": "^6.0.0", "@types/diff-match-patch": "^1.0.36", "@types/dockerode": "^3.3.47", "@types/node": "^22.10.2", diff --git a/src/api/auth/login.ts b/src/api/auth/login.ts new file mode 100644 index 00000000..ed1d5270 --- /dev/null +++ b/src/api/auth/login.ts @@ -0,0 +1,45 @@ +import { randomBytes } from 'node:crypto'; +import bcrypt from 'bcrypt'; +import type { Context } from 'hono'; +import { setCookie } from 'hono/cookie'; +import { createSession, getUserByEmail } from '../../db/repositories/usersRepository.js'; + +const SESSION_EXPIRY_DAYS = 30; + +export async function loginHandler(c: Context) { + const body = await c.req.json<{ email?: string; password?: string }>(); + if (!body.email || !body.password) { + return c.json({ error: 'Email and password are required' }, 400); + } + + const user = await getUserByEmail(body.email); + if (!user) { + return c.json({ error: 'Invalid credentials' }, 401); + } + + const valid = await bcrypt.compare(body.password, user.passwordHash); + if (!valid) { + return c.json({ error: 'Invalid credentials' }, 401); + } + + const token = randomBytes(64).toString('hex'); + const expiresAt = new Date(Date.now() + SESSION_EXPIRY_DAYS * 24 * 60 * 60 * 1000); + + await createSession(user.id, token, expiresAt); + + const isProduction = process.env.NODE_ENV === 'production'; + setCookie(c, 'cascade_session', token, { + httpOnly: true, + sameSite: 'Lax', + secure: isProduction, + path: '/', + maxAge: SESSION_EXPIRY_DAYS * 24 * 60 * 60, + }); + + return c.json({ + id: user.id, + email: user.email, + name: user.name, + role: user.role, + }); +} diff --git a/src/api/auth/logout.ts b/src/api/auth/logout.ts new file mode 100644 index 00000000..cac93bdf --- /dev/null +++ b/src/api/auth/logout.ts @@ -0,0 +1,13 @@ +import type { Context } from 'hono'; +import { deleteCookie, getCookie } from 'hono/cookie'; +import { deleteSession } from '../../db/repositories/usersRepository.js'; + +export async function logoutHandler(c: Context) { + const token = getCookie(c, 'cascade_session'); + if (token) { + await deleteSession(token); + } + + deleteCookie(c, 'cascade_session', { path: '/' }); + return c.json({ ok: true }); +} diff --git a/src/api/auth/session.ts b/src/api/auth/session.ts new file mode 100644 index 00000000..a54bff45 --- /dev/null +++ b/src/api/auth/session.ts @@ -0,0 +1,11 @@ +import { + type DashboardUser, + getSessionByToken, + getUserById, +} from '../../db/repositories/usersRepository.js'; + +export async function resolveUserFromSession(token: string): Promise { + const session = await getSessionByToken(token); + if (!session) return null; + return getUserById(session.userId); +} diff --git a/src/api/router.ts b/src/api/router.ts new file mode 100644 index 00000000..980e5d1c --- /dev/null +++ b/src/api/router.ts @@ -0,0 +1,12 @@ +import { authRouter } from './routers/auth.js'; +import { projectsRouter } from './routers/projects.js'; +import { runsRouter } from './routers/runs.js'; +import { router } from './trpc.js'; + +export const appRouter = router({ + auth: authRouter, + runs: runsRouter, + projects: projectsRouter, +}); + +export type AppRouter = typeof appRouter; diff --git a/src/api/routers/auth.ts b/src/api/routers/auth.ts new file mode 100644 index 00000000..ad0a41cd --- /dev/null +++ b/src/api/routers/auth.ts @@ -0,0 +1,13 @@ +import { protectedProcedure, router } from '../trpc.js'; + +export const authRouter = router({ + me: protectedProcedure.query(({ ctx }) => { + return { + id: ctx.user.id, + email: ctx.user.email, + name: ctx.user.name, + role: ctx.user.role, + orgId: ctx.user.orgId, + }; + }), +}); diff --git a/src/api/routers/projects.ts b/src/api/routers/projects.ts new file mode 100644 index 00000000..b6137c1f --- /dev/null +++ b/src/api/routers/projects.ts @@ -0,0 +1,8 @@ +import { listProjectsForOrg } from '../../db/repositories/runsRepository.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const projectsRouter = router({ + list: protectedProcedure.query(async ({ ctx }) => { + return listProjectsForOrg(ctx.user.orgId); + }), +}); diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts new file mode 100644 index 00000000..a0daea61 --- /dev/null +++ b/src/api/routers/runs.ts @@ -0,0 +1,94 @@ +import { TRPCError } from '@trpc/server'; +import { eq } from 'drizzle-orm'; +import { z } from 'zod'; +import { getDb } from '../../db/client.js'; +import { + getDebugAnalysisByRunId, + getLlmCallByNumber, + getRunById, + getRunLogs, + listLlmCallsMeta, + listRuns, +} from '../../db/repositories/runsRepository.js'; +import { projects } from '../../db/schema/index.js'; +import { protectedProcedure, router } from '../trpc.js'; + +export const runsRouter = router({ + list: protectedProcedure + .input( + z.object({ + projectId: z.string().optional(), + status: z.array(z.string()).optional(), + agentType: z.string().optional(), + startedAfter: z.string().datetime().optional(), + startedBefore: z.string().datetime().optional(), + limit: z.number().min(1).max(100).default(50), + offset: z.number().min(0).default(0), + sort: z.enum(['startedAt', 'durationMs', 'costUsd']).default('startedAt'), + order: z.enum(['asc', 'desc']).default('desc'), + }), + ) + .query(async ({ ctx, input }) => { + return listRuns({ + orgId: ctx.user.orgId, + projectId: input.projectId, + status: input.status, + agentType: input.agentType, + startedAfter: input.startedAfter ? new Date(input.startedAfter) : undefined, + startedBefore: input.startedBefore ? new Date(input.startedBefore) : undefined, + limit: input.limit, + offset: input.offset, + sort: input.sort, + order: input.order, + }); + }), + + getById: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .query(async ({ ctx, input }) => { + const run = await getRunById(input.id); + if (!run) throw new TRPCError({ code: 'NOT_FOUND' }); + + // Verify org access + if (run.projectId) { + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, run.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + } + + return run; + }), + + getLogs: protectedProcedure + .input(z.object({ runId: z.string().uuid() })) + .query(async ({ input }) => { + const logs = await getRunLogs(input.runId); + return logs; + }), + + listLlmCalls: protectedProcedure + .input(z.object({ runId: z.string().uuid() })) + .query(async ({ input }) => { + return listLlmCallsMeta(input.runId); + }), + + getLlmCall: protectedProcedure + .input(z.object({ runId: z.string().uuid(), callNumber: z.number() })) + .query(async ({ input }) => { + const call = await getLlmCallByNumber(input.runId, input.callNumber); + if (!call) throw new TRPCError({ code: 'NOT_FOUND' }); + return call; + }), + + getDebugAnalysis: protectedProcedure + .input(z.object({ runId: z.string().uuid() })) + .query(async ({ input }) => { + const analysis = await getDebugAnalysisByRunId(input.runId); + return analysis; + }), +}); diff --git a/src/api/trpc.ts b/src/api/trpc.ts new file mode 100644 index 00000000..857ac42c --- /dev/null +++ b/src/api/trpc.ts @@ -0,0 +1,24 @@ +import { TRPCError, initTRPC } from '@trpc/server'; + +export interface TRPCUser { + id: string; + orgId: string; + email: string; + name: string; + role: string; +} + +export interface TRPCContext { + user: TRPCUser | null; +} + +const t = initTRPC.context().create(); + +export const router = t.router; +export const publicProcedure = t.procedure; +export const protectedProcedure = t.procedure.use(async (opts) => { + if (!opts.ctx.user) { + throw new TRPCError({ code: 'UNAUTHORIZED' }); + } + return opts.next({ ctx: { user: opts.ctx.user } }); +}); diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index cc5e8de2..81e190c3 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -299,7 +299,18 @@ async function buildBackendInput( maxIterations, budgetUsd: input.remainingBudgetUsd as number | undefined, model, - logWriter: fileLogger.write.bind(fileLogger), + logWriter: (level: string, message: string, context?: Record) => { + fileLogger.write(level, message, context); + const logFn = + level === 'ERROR' + ? logger.error + : level === 'WARN' + ? logger.warn + : level === 'DEBUG' + ? logger.debug + : logger.info; + logFn.call(logger, message, context); + }, agentInput: input, ...(Object.keys(projectSecrets).length > 0 && { projectSecrets }), }; @@ -482,7 +493,18 @@ export async function executeWithBackend( ); const monitor = createProgressMonitor({ - logWriter: fileLogger.write.bind(fileLogger), + logWriter: (level: string, message: string, context?: Record) => { + fileLogger.write(level, message, context); + const logFn = + level === 'ERROR' + ? logger.error + : level === 'WARN' + ? logger.warn + : level === 'DEBUG' + ? logger.debug + : logger.info; + logFn.call(logger, message, context); + }, agentType, taskDescription: cardId ? `Trello card ${cardId}` : 'Unknown task', progressModel: input.config.defaults.progressModel, diff --git a/src/db/migrations/0006_users_and_sessions.sql b/src/db/migrations/0006_users_and_sessions.sql new file mode 100644 index 00000000..7a727150 --- /dev/null +++ b/src/db/migrations/0006_users_and_sessions.sql @@ -0,0 +1,24 @@ +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +CREATE INDEX idx_users_org_id ON users(org_id); +CREATE INDEX idx_users_email ON users(email); + +CREATE TABLE sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT now() +); + +CREATE INDEX idx_sessions_token ON sessions(token); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 953a35e2..900de8dd 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1740000000000, "tag": "0005_config_schema_cleanup", "breakpoints": false + }, + { + "idx": 5, + "version": "7", + "when": 1741000000000, + "tag": "0006_users_and_sessions", + "breakpoints": false } ] } diff --git a/src/db/repositories/runsRepository.ts b/src/db/repositories/runsRepository.ts index bb40c6bb..f466d630 100644 --- a/src/db/repositories/runsRepository.ts +++ b/src/db/repositories/runsRepository.ts @@ -1,6 +1,12 @@ -import { desc, eq } from 'drizzle-orm'; +import { type SQL, and, asc, count, desc, eq, gte, inArray, lte } from 'drizzle-orm'; import { getDb } from '../client.js'; -import { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from '../schema/index.js'; +import { + agentRunLlmCalls, + agentRunLogs, + agentRuns, + debugAnalyses, + projects, +} from '../schema/index.js'; // ============================================================================ // Types @@ -211,3 +217,124 @@ export async function getDebugAnalysisByDebugRunId(debugRunId: string) { .where(eq(debugAnalyses.debugRunId, debugRunId)); return row ?? null; } + +// ============================================================================ +// Dashboard queries +// ============================================================================ + +export interface ListRunsInput { + orgId: string; + projectId?: string; + status?: string[]; + agentType?: string; + startedAfter?: Date; + startedBefore?: Date; + limit: number; + offset: number; + sort?: 'startedAt' | 'durationMs' | 'costUsd'; + order?: 'asc' | 'desc'; +} + +export async function listRuns(input: ListRunsInput) { + const db = getDb(); + + const conditions: SQL[] = [eq(projects.orgId, input.orgId)]; + + if (input.projectId) { + conditions.push(eq(agentRuns.projectId, input.projectId)); + } + if (input.status && input.status.length > 0) { + conditions.push(inArray(agentRuns.status, input.status)); + } + if (input.agentType) { + conditions.push(eq(agentRuns.agentType, input.agentType)); + } + if (input.startedAfter) { + conditions.push(gte(agentRuns.startedAt, input.startedAfter)); + } + if (input.startedBefore) { + conditions.push(lte(agentRuns.startedAt, input.startedBefore)); + } + + const where = and(...conditions); + + const sortColumn = + input.sort === 'durationMs' + ? agentRuns.durationMs + : input.sort === 'costUsd' + ? agentRuns.costUsd + : agentRuns.startedAt; + const orderFn = input.order === 'asc' ? asc : desc; + + const [data, [{ total }]] = await Promise.all([ + db + .select({ + id: agentRuns.id, + projectId: agentRuns.projectId, + projectName: projects.name, + cardId: agentRuns.cardId, + prNumber: agentRuns.prNumber, + agentType: agentRuns.agentType, + backend: agentRuns.backend, + triggerType: agentRuns.triggerType, + status: agentRuns.status, + model: agentRuns.model, + startedAt: agentRuns.startedAt, + completedAt: agentRuns.completedAt, + durationMs: agentRuns.durationMs, + llmIterations: agentRuns.llmIterations, + gadgetCalls: agentRuns.gadgetCalls, + costUsd: agentRuns.costUsd, + success: agentRuns.success, + prUrl: agentRuns.prUrl, + }) + .from(agentRuns) + .innerJoin(projects, eq(agentRuns.projectId, projects.id)) + .where(where) + .orderBy(orderFn(sortColumn)) + .limit(input.limit) + .offset(input.offset), + db + .select({ total: count() }) + .from(agentRuns) + .innerJoin(projects, eq(agentRuns.projectId, projects.id)) + .where(where), + ]); + + return { data, total }; +} + +export async function getLlmCallByNumber(runId: string, callNumber: number) { + const db = getDb(); + const [row] = await db + .select() + .from(agentRunLlmCalls) + .where(and(eq(agentRunLlmCalls.runId, runId), eq(agentRunLlmCalls.callNumber, callNumber))); + return row ?? null; +} + +export async function listLlmCallsMeta(runId: string) { + const db = getDb(); + return db + .select({ + id: agentRunLlmCalls.id, + runId: agentRunLlmCalls.runId, + callNumber: agentRunLlmCalls.callNumber, + inputTokens: agentRunLlmCalls.inputTokens, + outputTokens: agentRunLlmCalls.outputTokens, + cachedTokens: agentRunLlmCalls.cachedTokens, + costUsd: agentRunLlmCalls.costUsd, + durationMs: agentRunLlmCalls.durationMs, + }) + .from(agentRunLlmCalls) + .where(eq(agentRunLlmCalls.runId, runId)) + .orderBy(agentRunLlmCalls.callNumber); +} + +export async function listProjectsForOrg(orgId: string) { + const db = getDb(); + return db + .select({ id: projects.id, name: projects.name }) + .from(projects) + .where(eq(projects.orgId, orgId)); +} diff --git a/src/db/repositories/usersRepository.ts b/src/db/repositories/usersRepository.ts new file mode 100644 index 00000000..b729911c --- /dev/null +++ b/src/db/repositories/usersRepository.ts @@ -0,0 +1,69 @@ +import { and, eq, gt, lt } from 'drizzle-orm'; +import { getDb } from '../client.js'; +import { sessions, users } from '../schema/index.js'; + +export interface DashboardUser { + id: string; + orgId: string; + email: string; + name: string; + role: string; +} + +export async function getUserByEmail(email: string) { + const db = getDb(); + const [row] = await db.select().from(users).where(eq(users.email, email)); + return row ?? null; +} + +export async function getUserById(id: string): Promise { + const db = getDb(); + const [row] = await db + .select({ + id: users.id, + orgId: users.orgId, + email: users.email, + name: users.name, + role: users.role, + }) + .from(users) + .where(eq(users.id, id)); + return row ?? null; +} + +export async function createSession( + userId: string, + token: string, + expiresAt: Date, +): Promise { + const db = getDb(); + const [row] = await db + .insert(sessions) + .values({ userId, token, expiresAt }) + .returning({ id: sessions.id }); + return row.id; +} + +export async function getSessionByToken(token: string) { + const db = getDb(); + const now = new Date(); + const [row] = await db + .select({ + sessionId: sessions.id, + userId: sessions.userId, + expiresAt: sessions.expiresAt, + }) + .from(sessions) + .where(and(eq(sessions.token, token), gt(sessions.expiresAt, now))); + return row ?? null; +} + +export async function deleteSession(token: string): Promise { + const db = getDb(); + await db.delete(sessions).where(eq(sessions.token, token)); +} + +export async function deleteExpiredSessions(): Promise { + const db = getDb(); + await db.delete(sessions).where(lt(sessions.expiresAt, new Date())); +} diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index c4dc28ea..f00ac855 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -5,3 +5,4 @@ export { agentConfigs } from './agentConfigs.js'; export { projectIntegrations } from './integrations.js'; export { projects } from './projects.js'; export { agentRunLlmCalls, agentRunLogs, agentRuns, debugAnalyses } from './runs.js'; +export { sessions, users } from './users.js'; diff --git a/src/db/schema/users.ts b/src/db/schema/users.ts new file mode 100644 index 00000000..d2988c2c --- /dev/null +++ b/src/db/schema/users.ts @@ -0,0 +1,38 @@ +import { index, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core'; +import { organizations } from './organizations.js'; + +export const users = pgTable( + 'users', + { + id: uuid('id').primaryKey().defaultRandom(), + orgId: text('org_id') + .notNull() + .references(() => organizations.id, { onDelete: 'cascade' }), + email: text('email').notNull().unique(), + passwordHash: text('password_hash').notNull(), + name: text('name').notNull(), + role: text('role').notNull().default('member'), + createdAt: timestamp('created_at').defaultNow(), + updatedAt: timestamp('updated_at') + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => [index('idx_users_org_id').on(table.orgId), index('idx_users_email').on(table.email)], +); + +export const sessions = pgTable( + 'sessions', + { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + token: text('token').notNull().unique(), + expiresAt: timestamp('expires_at').notNull(), + createdAt: timestamp('created_at').defaultNow(), + }, + (table) => [ + index('idx_sessions_token').on(table.token), + index('idx_sessions_expires_at').on(table.expiresAt), + ], +); diff --git a/src/server.ts b/src/server.ts index 81eab9d6..502abc7e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,6 +1,15 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { trpcServer } from '@hono/trpc-server'; import { Hono } from 'hono'; +import { getCookie } from 'hono/cookie'; import { cors } from 'hono/cors'; import { logger as honoLogger } from 'hono/logger'; +import { loginHandler } from './api/auth/login.js'; +import { logoutHandler } from './api/auth/logout.js'; +import { resolveUserFromSession } from './api/auth/session.js'; +import { appRouter } from './api/router.js'; import type { CascadeConfig } from './types/index.js'; import { canAcceptWebhook, isCurrentlyProcessing, logger } from './utils/index.js'; @@ -26,6 +35,32 @@ export function createServer(deps: ServerDependencies): Hono { }); }); + // ========================================================================= + // Dashboard auth routes (plain Hono — they set cookies) + // ========================================================================= + app.post('/api/auth/login', loginHandler); + app.post('/api/auth/logout', logoutHandler); + + // ========================================================================= + // tRPC (all dashboard data queries) + // ========================================================================= + app.use( + '/trpc/*', + trpcServer({ + endpoint: '/trpc', + router: appRouter, + createContext: async (_opts, c) => { + const token = getCookie(c, 'cascade_session'); + const user = token ? await resolveUserFromSession(token) : null; + return { user }; + }, + }), + ); + + // ========================================================================= + // Webhooks + // ========================================================================= + // Trello webhook - GET/HEAD for verification (Trello sends HEAD to verify) app.get('/trello/webhook', (c) => { return c.text('OK', 200); @@ -122,8 +157,30 @@ export function createServer(deps: ServerDependencies): Hono { } }); - // 404 handler + // ========================================================================= + // Static file serving (production — built frontend) + // ========================================================================= + const webDistPath = join(import.meta.dirname, '..', 'dist', 'web'); + const webDistExists = existsSync(webDistPath); + + if (webDistExists) { + app.use('/*', serveStatic({ root: './dist/web' })); + } + + // SPA fallback — serve index.html for unmatched routes app.notFound((c) => { + if (webDistExists) { + const accept = c.req.header('Accept') ?? ''; + if (accept.includes('text/html')) { + const indexPath = join(webDistPath, 'index.html'); + try { + const html = readFileSync(indexPath, 'utf-8'); + return c.html(html); + } catch { + // fall through to JSON 404 + } + } + } return c.json({ error: 'Not Found' }, 404); }); diff --git a/tests/unit/api/auth/login.test.ts b/tests/unit/api/auth/login.test.ts new file mode 100644 index 00000000..a7c6fdc2 --- /dev/null +++ b/tests/unit/api/auth/login.test.ts @@ -0,0 +1,129 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetUserByEmail = vi.fn(); +const mockCreateSession = vi.fn(); +const mockBcryptCompare = vi.fn(); + +vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ + getUserByEmail: (...args: unknown[]) => mockGetUserByEmail(...args), + createSession: (...args: unknown[]) => mockCreateSession(...args), +})); + +vi.mock('bcrypt', () => ({ + default: { + compare: (...args: unknown[]) => mockBcryptCompare(...args), + }, +})); + +import { loginHandler } from '../../../../src/api/auth/login.js'; + +function createTestApp() { + const app = new Hono(); + app.post('/api/auth/login', loginHandler); + return app; +} + +function postLogin(app: Hono, body: Record) { + return app.request('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + passwordHash: '$2b$10$hash', + name: 'Test User', + role: 'admin', +}; + +describe('loginHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 400 when email is missing', async () => { + const app = createTestApp(); + const res = await postLogin(app, { password: 'pass' }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('Email and password are required'); + }); + + it('returns 400 when password is missing', async () => { + const app = createTestApp(); + const res = await postLogin(app, { email: 'a@b.com' }); + + expect(res.status).toBe(400); + }); + + it('returns 401 when user not found', async () => { + mockGetUserByEmail.mockResolvedValue(null); + const app = createTestApp(); + + const res = await postLogin(app, { email: 'noone@b.com', password: 'pass' }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('Invalid credentials'); + }); + + it('returns 401 when password does not match', async () => { + mockGetUserByEmail.mockResolvedValue(mockUser); + mockBcryptCompare.mockResolvedValue(false); + const app = createTestApp(); + + const res = await postLogin(app, { email: 'test@example.com', password: 'wrong' }); + + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('Invalid credentials'); + }); + + it('returns 200 with user data and sets session cookie on success', async () => { + mockGetUserByEmail.mockResolvedValue(mockUser); + mockBcryptCompare.mockResolvedValue(true); + mockCreateSession.mockResolvedValue('session-id'); + const app = createTestApp(); + + const res = await postLogin(app, { email: 'test@example.com', password: 'correct' }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'admin', + }); + + // Check Set-Cookie header + const cookie = res.headers.get('set-cookie'); + expect(cookie).toBeTruthy(); + expect(cookie).toContain('cascade_session='); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('Path=/'); + }); + + it('creates session with 30-day expiry', async () => { + mockGetUserByEmail.mockResolvedValue(mockUser); + mockBcryptCompare.mockResolvedValue(true); + mockCreateSession.mockResolvedValue('session-id'); + const app = createTestApp(); + + await postLogin(app, { email: 'test@example.com', password: 'correct' }); + + expect(mockCreateSession).toHaveBeenCalledTimes(1); + const [userId, _token, expiresAt] = mockCreateSession.mock.calls[0]; + expect(userId).toBe('user-1'); + // Expiry should be ~30 days from now + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + const expectedExpiry = Date.now() + thirtyDaysMs; + expect(Math.abs(expiresAt.getTime() - expectedExpiry)).toBeLessThan(5000); + }); +}); diff --git a/tests/unit/api/auth/logout.test.ts b/tests/unit/api/auth/logout.test.ts new file mode 100644 index 00000000..bd0ae680 --- /dev/null +++ b/tests/unit/api/auth/logout.test.ts @@ -0,0 +1,56 @@ +import { Hono } from 'hono'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockDeleteSession = vi.fn(); + +vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ + deleteSession: (...args: unknown[]) => mockDeleteSession(...args), +})); + +import { logoutHandler } from '../../../../src/api/auth/logout.js'; + +function createTestApp() { + const app = new Hono(); + app.post('/api/auth/logout', logoutHandler); + return app; +} + +describe('logoutHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deletes session and clears cookie when session cookie is present', async () => { + mockDeleteSession.mockResolvedValue(undefined); + const app = createTestApp(); + + const res = await app.request('/api/auth/logout', { + method: 'POST', + headers: { Cookie: 'cascade_session=abc123' }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true }); + + expect(mockDeleteSession).toHaveBeenCalledWith('abc123'); + + // Cookie should be cleared + const cookie = res.headers.get('set-cookie'); + expect(cookie).toContain('cascade_session='); + }); + + it('returns ok even when no session cookie is present', async () => { + const app = createTestApp(); + + const res = await app.request('/api/auth/logout', { + method: 'POST', + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ ok: true }); + + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/auth/session.test.ts b/tests/unit/api/auth/session.test.ts new file mode 100644 index 00000000..cb0360bf --- /dev/null +++ b/tests/unit/api/auth/session.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockGetSessionByToken = vi.fn(); +const mockGetUserById = vi.fn(); + +vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({ + getSessionByToken: (...args: unknown[]) => mockGetSessionByToken(...args), + getUserById: (...args: unknown[]) => mockGetUserById(...args), +})); + +import { resolveUserFromSession } from '../../../../src/api/auth/session.js'; + +describe('resolveUserFromSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns DashboardUser when token maps to valid session and user', async () => { + const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test User', + role: 'admin', + }; + mockGetSessionByToken.mockResolvedValue({ + sessionId: 'session-1', + userId: 'user-1', + expiresAt: new Date('2099-01-01'), + }); + mockGetUserById.mockResolvedValue(mockUser); + + const result = await resolveUserFromSession('valid-token'); + + expect(mockGetSessionByToken).toHaveBeenCalledWith('valid-token'); + expect(mockGetUserById).toHaveBeenCalledWith('user-1'); + expect(result).toEqual(mockUser); + }); + + it('returns null when session not found', async () => { + mockGetSessionByToken.mockResolvedValue(null); + + const result = await resolveUserFromSession('invalid-token'); + + expect(result).toBeNull(); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('returns null when session exists but user not found', async () => { + mockGetSessionByToken.mockResolvedValue({ + sessionId: 'session-1', + userId: 'deleted-user', + expiresAt: new Date('2099-01-01'), + }); + mockGetUserById.mockResolvedValue(null); + + const result = await resolveUserFromSession('orphan-token'); + + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts new file mode 100644 index 00000000..acf0df64 --- /dev/null +++ b/tests/unit/api/router.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; + +// Mock all dependencies the routers pull in +vi.mock('../../../src/db/client.js', () => ({ + getDb: () => ({}), +})); + +vi.mock('../../../src/db/schema/index.js', () => ({ + projects: {}, +})); + +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + listRuns: vi.fn(), + getRunById: vi.fn(), + getRunLogs: vi.fn(), + listLlmCallsMeta: vi.fn(), + getLlmCallByNumber: vi.fn(), + getDebugAnalysisByRunId: vi.fn(), + listProjectsForOrg: vi.fn(), +})); + +import { appRouter } from '../../../src/api/router.js'; + +describe('appRouter', () => { + it('has auth sub-router with me procedure', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('auth.me'); + }); + + it('has runs sub-router with all procedures', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('runs.list'); + expect(procedures).toContain('runs.getById'); + expect(procedures).toContain('runs.getLogs'); + expect(procedures).toContain('runs.listLlmCalls'); + expect(procedures).toContain('runs.getLlmCall'); + expect(procedures).toContain('runs.getDebugAnalysis'); + }); + + it('has projects sub-router with list procedure', () => { + const procedures = Object.keys(appRouter._def.procedures); + expect(procedures).toContain('projects.list'); + }); +}); diff --git a/tests/unit/api/routers/auth.test.ts b/tests/unit/api/routers/auth.test.ts new file mode 100644 index 00000000..c5e22945 --- /dev/null +++ b/tests/unit/api/routers/auth.test.ts @@ -0,0 +1,42 @@ +import { TRPCError } from '@trpc/server'; +import { describe, expect, it } from 'vitest'; +import { authRouter } from '../../../../src/api/routers/auth.js'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +function createCaller(ctx: TRPCContext) { + return authRouter.createCaller(ctx); +} + +describe('authRouter', () => { + describe('me', () => { + it('returns user data from context', async () => { + const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test User', + role: 'admin', + }; + const caller = createCaller({ user: mockUser }); + + const result = await caller.me(); + + expect(result).toEqual({ + id: 'user-1', + email: 'test@example.com', + name: 'Test User', + role: 'admin', + orgId: 'org-1', + }); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + + await expect(caller.me()).rejects.toThrow(TRPCError); + await expect(caller.me()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/api/routers/projects.test.ts b/tests/unit/api/routers/projects.test.ts new file mode 100644 index 00000000..91e33d4c --- /dev/null +++ b/tests/unit/api/routers/projects.test.ts @@ -0,0 +1,64 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +const mockListProjectsForOrg = vi.fn(); + +vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ + listProjectsForOrg: (...args: unknown[]) => mockListProjectsForOrg(...args), +})); + +import { projectsRouter } from '../../../../src/api/routers/projects.js'; + +function createCaller(ctx: TRPCContext) { + return projectsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('projectsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('list', () => { + it('calls listProjectsForOrg with orgId from user context', async () => { + mockListProjectsForOrg.mockResolvedValue([ + { id: 'p1', name: 'Project 1' }, + { id: 'p2', name: 'Project 2' }, + ]); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list(); + + expect(mockListProjectsForOrg).toHaveBeenCalledWith('org-1'); + expect(result).toEqual([ + { id: 'p1', name: 'Project 1' }, + { id: 'p2', name: 'Project 2' }, + ]); + }); + + it('returns empty array when org has no projects', async () => { + mockListProjectsForOrg.mockResolvedValue([]); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list(); + expect(result).toEqual([]); + }); + + it('throws UNAUTHORIZED when not authenticated', async () => { + const caller = createCaller({ user: null }); + + await expect(caller.list()).rejects.toThrow(TRPCError); + await expect(caller.list()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); +}); diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts new file mode 100644 index 00000000..917fac35 --- /dev/null +++ b/tests/unit/api/routers/runs.test.ts @@ -0,0 +1,295 @@ +import { TRPCError } from '@trpc/server'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TRPCContext } from '../../../../src/api/trpc.js'; + +// Mock repository functions +const mockListRuns = vi.fn(); +const mockGetRunById = vi.fn(); +const mockGetRunLogs = vi.fn(); +const mockListLlmCallsMeta = vi.fn(); +const mockGetLlmCallByNumber = vi.fn(); +const mockGetDebugAnalysisByRunId = vi.fn(); + +vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ + listRuns: (...args: unknown[]) => mockListRuns(...args), + getRunById: (...args: unknown[]) => mockGetRunById(...args), + getRunLogs: (...args: unknown[]) => mockGetRunLogs(...args), + listLlmCallsMeta: (...args: unknown[]) => mockListLlmCallsMeta(...args), + getLlmCallByNumber: (...args: unknown[]) => mockGetLlmCallByNumber(...args), + getDebugAnalysisByRunId: (...args: unknown[]) => mockGetDebugAnalysisByRunId(...args), +})); + +// Mock getDb for the inline org-access check in getById +const mockDbSelect = vi.fn(); +const mockDbFrom = vi.fn(); +const mockDbWhere = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: mockDbSelect, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + projects: { id: 'id', orgId: 'org_id' }, +})); + +import { runsRouter } from '../../../../src/api/routers/runs.js'; + +function createCaller(ctx: TRPCContext) { + return runsRouter.createCaller(ctx); +} + +const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', +}; + +describe('runsRouter', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Set up DB chain for getById org check + mockDbSelect.mockReturnValue({ from: mockDbFrom }); + mockDbFrom.mockReturnValue({ where: mockDbWhere }); + }); + + describe('list', () => { + it('calls listRuns with orgId from context and forwarded filters', async () => { + mockListRuns.mockResolvedValue({ data: [{ id: 'run-1' }], total: 1 }); + const caller = createCaller({ user: mockUser }); + + const result = await caller.list({ + projectId: 'p1', + status: ['completed'], + agentType: 'implementation', + limit: 10, + offset: 5, + sort: 'costUsd', + order: 'asc', + }); + + expect(mockListRuns).toHaveBeenCalledWith({ + orgId: 'org-1', + projectId: 'p1', + status: ['completed'], + agentType: 'implementation', + limit: 10, + offset: 5, + sort: 'costUsd', + order: 'asc', + startedAfter: undefined, + startedBefore: undefined, + }); + expect(result).toEqual({ data: [{ id: 'run-1' }], total: 1 }); + }); + + it('converts startedAfter/startedBefore strings to Date objects', async () => { + mockListRuns.mockResolvedValue({ data: [], total: 0 }); + const caller = createCaller({ user: mockUser }); + + await caller.list({ + startedAfter: '2025-06-01T00:00:00.000Z', + startedBefore: '2025-12-31T23:59:59.000Z', + }); + + expect(mockListRuns).toHaveBeenCalledWith( + expect.objectContaining({ + startedAfter: new Date('2025-06-01T00:00:00.000Z'), + startedBefore: new Date('2025-12-31T23:59:59.000Z'), + }), + ); + }); + + it('uses defaults for limit/offset/sort/order when not provided', async () => { + mockListRuns.mockResolvedValue({ data: [], total: 0 }); + const caller = createCaller({ user: mockUser }); + + await caller.list({}); + + expect(mockListRuns).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 50, + offset: 0, + sort: 'startedAt', + order: 'desc', + }), + ); + }); + + it('rejects limit > 100', async () => { + const caller = createCaller({ user: mockUser }); + await expect(caller.list({ limit: 200 })).rejects.toThrow(); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.list({})).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('getById', () => { + it('returns run when found and org matches', async () => { + const mockRun = { + id: 'aaaaaaaa-1111-2222-3333-444444444444', + projectId: 'p1', + agentType: 'implementation', + }; + mockGetRunById.mockResolvedValue(mockRun); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }); + + expect(result).toEqual(mockRun); + }); + + it('throws NOT_FOUND when run does not exist', async () => { + mockGetRunById.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + await expect( + caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when project org does not match user org', async () => { + mockGetRunById.mockResolvedValue({ + id: 'aaaaaaaa-1111-2222-3333-444444444444', + projectId: 'p1', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when project not found for run', async () => { + mockGetRunById.mockResolvedValue({ + id: 'aaaaaaaa-1111-2222-3333-444444444444', + projectId: 'p-missing', + }); + mockDbWhere.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('returns run when run has no projectId', async () => { + const mockRun = { + id: 'aaaaaaaa-1111-2222-3333-444444444444', + projectId: null, + agentType: 'debug', + }; + mockGetRunById.mockResolvedValue(mockRun); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }); + + expect(result).toEqual(mockRun); + expect(mockDbSelect).not.toHaveBeenCalled(); + }); + + it('rejects non-UUID id', async () => { + const caller = createCaller({ user: mockUser }); + await expect(caller.getById({ id: 'not-a-uuid' })).rejects.toThrow(); + }); + }); + + describe('getLogs', () => { + it('returns logs for given runId', async () => { + const mockLogs = { cascadeLog: 'log text', llmistLog: null }; + mockGetRunLogs.mockResolvedValue(mockLogs); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getLogs({ runId: 'aaaaaaaa-1111-2222-3333-444444444444' }); + + expect(mockGetRunLogs).toHaveBeenCalledWith('aaaaaaaa-1111-2222-3333-444444444444'); + expect(result).toEqual(mockLogs); + }); + + it('returns null when no logs found', async () => { + mockGetRunLogs.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getLogs({ runId: 'aaaaaaaa-1111-2222-3333-444444444444' }); + expect(result).toBeNull(); + }); + }); + + describe('listLlmCalls', () => { + it('returns LLM call metadata list', async () => { + const mockMeta = [ + { callNumber: 1, inputTokens: 100 }, + { callNumber: 2, inputTokens: 200 }, + ]; + mockListLlmCallsMeta.mockResolvedValue(mockMeta); + + const caller = createCaller({ user: mockUser }); + const result = await caller.listLlmCalls({ runId: 'aaaaaaaa-1111-2222-3333-444444444444' }); + + expect(result).toEqual(mockMeta); + }); + }); + + describe('getLlmCall', () => { + it('returns specific LLM call by runId + callNumber', async () => { + const mockCall = { callNumber: 3, request: '{}', response: '{}' }; + mockGetLlmCallByNumber.mockResolvedValue(mockCall); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getLlmCall({ + runId: 'aaaaaaaa-1111-2222-3333-444444444444', + callNumber: 3, + }); + + expect(mockGetLlmCallByNumber).toHaveBeenCalledWith( + 'aaaaaaaa-1111-2222-3333-444444444444', + 3, + ); + expect(result).toEqual(mockCall); + }); + + it('throws NOT_FOUND when call does not exist', async () => { + mockGetLlmCallByNumber.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + await expect( + caller.getLlmCall({ + runId: 'aaaaaaaa-1111-2222-3333-444444444444', + callNumber: 999, + }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + }); + + describe('getDebugAnalysis', () => { + it('returns debug analysis for runId', async () => { + const mockAnalysis = { summary: 'Agent failed', issues: 'Issue 1' }; + mockGetDebugAnalysisByRunId.mockResolvedValue(mockAnalysis); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getDebugAnalysis({ + runId: 'aaaaaaaa-1111-2222-3333-444444444444', + }); + + expect(result).toEqual(mockAnalysis); + }); + + it('returns null when no analysis exists', async () => { + mockGetDebugAnalysisByRunId.mockResolvedValue(null); + const caller = createCaller({ user: mockUser }); + + const result = await caller.getDebugAnalysis({ + runId: 'aaaaaaaa-1111-2222-3333-444444444444', + }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/unit/api/trpc.test.ts b/tests/unit/api/trpc.test.ts new file mode 100644 index 00000000..22166f68 --- /dev/null +++ b/tests/unit/api/trpc.test.ts @@ -0,0 +1,37 @@ +import { TRPCError } from '@trpc/server'; +import { describe, expect, it } from 'vitest'; +import { type TRPCContext, protectedProcedure, router } from '../../../src/api/trpc.js'; + +// Create a minimal test router +const testRouter = router({ + whoami: protectedProcedure.query(({ ctx }) => ctx.user), +}); + +function createCaller(ctx: TRPCContext) { + return testRouter.createCaller(ctx); +} + +describe('tRPC protectedProcedure', () => { + it('throws UNAUTHORIZED when ctx.user is null', async () => { + const caller = createCaller({ user: null }); + + await expect(caller.whoami()).rejects.toThrow(TRPCError); + await expect(caller.whoami()).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it('passes through when ctx.user is present', async () => { + const mockUser = { + id: 'user-1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', + }; + const caller = createCaller({ user: mockUser }); + + const result = await caller.whoami(); + expect(result).toEqual(mockUser); + }); +}); diff --git a/tests/unit/db/repositories/runsRepository.dashboard.test.ts b/tests/unit/db/repositories/runsRepository.dashboard.test.ts new file mode 100644 index 00000000..acefaef0 --- /dev/null +++ b/tests/unit/db/repositories/runsRepository.dashboard.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSelect = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + select: (...args: unknown[]) => mockSelect(...args), + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + agentRuns: { + id: 'id', + projectId: 'project_id', + cardId: 'card_id', + agentType: 'agent_type', + status: 'status', + startedAt: 'started_at', + completedAt: 'completed_at', + durationMs: 'duration_ms', + costUsd: 'cost_usd', + backend: 'backend', + model: 'model', + triggerType: 'trigger_type', + llmIterations: 'llm_iterations', + gadgetCalls: 'gadget_calls', + prNumber: 'pr_number', + prUrl: 'pr_url', + success: 'success', + }, + agentRunLlmCalls: { + id: 'id', + runId: 'run_id', + callNumber: 'call_number', + inputTokens: 'input_tokens', + outputTokens: 'output_tokens', + cachedTokens: 'cached_tokens', + costUsd: 'cost_usd', + durationMs: 'duration_ms', + request: 'request', + response: 'response', + }, + projects: { + id: 'id', + name: 'name', + orgId: 'org_id', + }, +})); + +import { + getLlmCallByNumber, + listLlmCallsMeta, + listProjectsForOrg, + listRuns, +} from '../../../../src/db/repositories/runsRepository.js'; + +// Helper: creates a chainable mock that resolves when awaited. +// Each method returns the chain (sync), and the chain itself is thenable. +function createChain(resolveValue: unknown = []) { + const chain: Record = {}; + const methods = ['from', 'innerJoin', 'where', 'orderBy', 'limit', 'offset']; + for (const method of methods) { + chain[method] = vi.fn().mockReturnValue(chain); + } + // Make the chain thenable so it resolves when awaited + // biome-ignore lint/suspicious/noThenProperty: intentional thenable mock for Drizzle chain + chain.then = (resolve: (v: unknown) => unknown) => Promise.resolve(resolveValue).then(resolve); + return chain as Record> & { then: unknown }; +} + +describe('runsRepository - dashboard queries', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('listRuns', () => { + it('returns data and total count', async () => { + const dataChain = createChain([{ id: 'run-1', agentType: 'impl' }]); + const countChain = createChain([{ total: 1 }]); + + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + const result = await listRuns({ + orgId: 'org-1', + limit: 50, + offset: 0, + }); + + expect(result.data).toEqual([{ id: 'run-1', agentType: 'impl' }]); + expect(result.total).toBe(1); + }); + + it('passes limit and offset to the data query', async () => { + const dataChain = createChain([]); + const countChain = createChain([{ total: 0 }]); + + mockSelect.mockReturnValueOnce(dataChain).mockReturnValueOnce(countChain); + + await listRuns({ + orgId: 'org-1', + limit: 10, + offset: 20, + }); + + expect(dataChain.limit).toHaveBeenCalledWith(10); + expect(dataChain.offset).toHaveBeenCalledWith(20); + }); + }); + + describe('getLlmCallByNumber', () => { + it('returns LLM call row when found', async () => { + const mockCall = { id: 'c1', runId: 'run-1', callNumber: 3, request: '{}', response: '{}' }; + const chain = createChain([mockCall]); + mockSelect.mockReturnValue(chain); + + const result = await getLlmCallByNumber('run-1', 3); + expect(result).toEqual(mockCall); + }); + + it('returns null when no matching call', async () => { + const chain = createChain([]); + mockSelect.mockReturnValue(chain); + + const result = await getLlmCallByNumber('run-1', 999); + expect(result).toBeNull(); + }); + }); + + describe('listLlmCallsMeta', () => { + it('returns metadata fields ordered by callNumber', async () => { + const mockMeta = [ + { id: 'c1', callNumber: 1, inputTokens: 100 }, + { id: 'c2', callNumber: 2, inputTokens: 200 }, + ]; + const chain = createChain(mockMeta); + mockSelect.mockReturnValue(chain); + + const result = await listLlmCallsMeta('run-1'); + expect(result).toEqual(mockMeta); + }); + + it('returns empty array when no calls for run', async () => { + const chain = createChain([]); + mockSelect.mockReturnValue(chain); + + const result = await listLlmCallsMeta('run-no-calls'); + expect(result).toEqual([]); + }); + }); + + describe('listProjectsForOrg', () => { + it('returns project id and name for the given org', async () => { + const mockProjects = [ + { id: 'p1', name: 'Project 1' }, + { id: 'p2', name: 'Project 2' }, + ]; + const chain = createChain(mockProjects); + mockSelect.mockReturnValue(chain); + + const result = await listProjectsForOrg('org-1'); + expect(result).toEqual(mockProjects); + }); + + it('returns empty array when org has no projects', async () => { + const chain = createChain([]); + mockSelect.mockReturnValue(chain); + + const result = await listProjectsForOrg('empty-org'); + expect(result).toEqual([]); + }); + }); +}); diff --git a/tests/unit/db/repositories/usersRepository.test.ts b/tests/unit/db/repositories/usersRepository.test.ts new file mode 100644 index 00000000..b2beac1e --- /dev/null +++ b/tests/unit/db/repositories/usersRepository.test.ts @@ -0,0 +1,157 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockInsert = vi.fn(); +const mockSelect = vi.fn(); +const mockDelete = vi.fn(); +const mockValues = vi.fn(); +const mockReturning = vi.fn(); +const mockWhere = vi.fn(); +const mockFrom = vi.fn(); + +vi.mock('../../../../src/db/client.js', () => ({ + getDb: () => ({ + insert: mockInsert, + select: mockSelect, + delete: mockDelete, + }), +})); + +vi.mock('../../../../src/db/schema/index.js', () => ({ + users: { + id: 'id', + orgId: 'org_id', + email: 'email', + passwordHash: 'password_hash', + name: 'name', + role: 'role', + }, + sessions: { + id: 'id', + userId: 'user_id', + token: 'token', + expiresAt: 'expires_at', + }, +})); + +import { + createSession, + deleteExpiredSessions, + deleteSession, + getSessionByToken, + getUserByEmail, + getUserById, +} from '../../../../src/db/repositories/usersRepository.js'; + +describe('usersRepository', () => { + beforeEach(() => { + vi.clearAllMocks(); + + mockInsert.mockReturnValue({ values: mockValues }); + mockValues.mockReturnValue({ returning: mockReturning }); + mockSelect.mockReturnValue({ from: mockFrom }); + mockFrom.mockReturnValue({ where: mockWhere }); + mockDelete.mockReturnValue({ where: mockWhere }); + }); + + describe('getUserByEmail', () => { + it('returns user row when found', async () => { + const mockUser = { + id: 'u1', + orgId: 'org-1', + email: 'test@example.com', + passwordHash: '$2b$10$hash', + name: 'Test', + role: 'admin', + }; + mockWhere.mockResolvedValue([mockUser]); + + const result = await getUserByEmail('test@example.com'); + expect(result).toEqual(mockUser); + }); + + it('returns null when no user matches', async () => { + mockWhere.mockResolvedValue([]); + + const result = await getUserByEmail('noone@example.com'); + expect(result).toBeNull(); + }); + }); + + describe('getUserById', () => { + it('returns DashboardUser shape when found', async () => { + const dashboardUser = { + id: 'u1', + orgId: 'org-1', + email: 'test@example.com', + name: 'Test', + role: 'admin', + }; + mockWhere.mockResolvedValue([dashboardUser]); + + const result = await getUserById('u1'); + expect(result).toEqual(dashboardUser); + }); + + it('returns null when not found', async () => { + mockWhere.mockResolvedValue([]); + + const result = await getUserById('nonexistent'); + expect(result).toBeNull(); + }); + }); + + describe('createSession', () => { + it('inserts session and returns id', async () => { + mockReturning.mockResolvedValue([{ id: 'session-uuid' }]); + const expiresAt = new Date('2099-01-01'); + + const result = await createSession('user-1', 'token-abc', expiresAt); + + expect(result).toBe('session-uuid'); + expect(mockValues).toHaveBeenCalledWith({ + userId: 'user-1', + token: 'token-abc', + expiresAt, + }); + }); + }); + + describe('getSessionByToken', () => { + it('returns session data when token is valid', async () => { + const sessionRow = { + sessionId: 's1', + userId: 'u1', + expiresAt: new Date('2099-01-01'), + }; + mockWhere.mockResolvedValue([sessionRow]); + + const result = await getSessionByToken('valid-token'); + expect(result).toEqual(sessionRow); + }); + + it('returns null when no matching session', async () => { + mockWhere.mockResolvedValue([]); + + const result = await getSessionByToken('expired-token'); + expect(result).toBeNull(); + }); + }); + + describe('deleteSession', () => { + it('deletes session by token', async () => { + mockWhere.mockResolvedValue(undefined); + + await deleteSession('token-to-delete'); + expect(mockDelete).toHaveBeenCalled(); + }); + }); + + describe('deleteExpiredSessions', () => { + it('deletes sessions with past expiresAt', async () => { + mockWhere.mockResolvedValue(undefined); + + await deleteExpiredSessions(); + expect(mockDelete).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/web/utils.test.ts b/tests/unit/web/utils.test.ts new file mode 100644 index 00000000..0756ce8b --- /dev/null +++ b/tests/unit/web/utils.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('clsx', () => ({ clsx: (...args: unknown[]) => args.join(' ') })); +vi.mock('tailwind-merge', () => ({ twMerge: (s: string) => s })); + +import { formatCost, formatDuration, formatRelativeTime } from '../../../web/src/lib/utils.js'; + +describe('formatDuration', () => { + it('returns "-" for null', () => { + expect(formatDuration(null)).toBe('-'); + }); + + it('returns "-" for undefined', () => { + expect(formatDuration(undefined)).toBe('-'); + }); + + it('returns "0ms" for 0', () => { + expect(formatDuration(0)).toBe('0ms'); + }); + + it('returns milliseconds for values under 1000', () => { + expect(formatDuration(500)).toBe('500ms'); + }); + + it('returns seconds for values under 60s', () => { + expect(formatDuration(1000)).toBe('1s'); + expect(formatDuration(45000)).toBe('45s'); + expect(formatDuration(59000)).toBe('59s'); + }); + + it('returns minutes and seconds for values >= 60s', () => { + expect(formatDuration(60000)).toBe('1m 0s'); + expect(formatDuration(90000)).toBe('1m 30s'); + expect(formatDuration(300000)).toBe('5m 0s'); + }); +}); + +describe('formatCost', () => { + it('returns "-" for null', () => { + expect(formatCost(null)).toBe('-'); + }); + + it('returns "-" for undefined', () => { + expect(formatCost(undefined)).toBe('-'); + }); + + it('formats number with 4 decimal places', () => { + expect(formatCost(0.001)).toBe('$0.0010'); + expect(formatCost(1.23456)).toBe('$1.2346'); + expect(formatCost(0)).toBe('$0.0000'); + }); + + it('handles string input', () => { + expect(formatCost('0.5')).toBe('$0.5000'); + expect(formatCost('1.23456')).toBe('$1.2346'); + }); + + it('returns "-" for NaN string input', () => { + expect(formatCost('not-a-number')).toBe('-'); + }); +}); + +describe('formatRelativeTime', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2025-06-15T12:00:00Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "-" for null', () => { + expect(formatRelativeTime(null)).toBe('-'); + }); + + it('returns "-" for undefined', () => { + expect(formatRelativeTime(undefined)).toBe('-'); + }); + + it('returns "just now" for <60 seconds ago', () => { + expect(formatRelativeTime(new Date('2025-06-15T11:59:30Z'))).toBe('just now'); + }); + + it('returns minutes ago for <60 minutes', () => { + expect(formatRelativeTime(new Date('2025-06-15T11:55:00Z'))).toBe('5m ago'); + }); + + it('returns hours ago for <24 hours', () => { + expect(formatRelativeTime(new Date('2025-06-15T10:00:00Z'))).toBe('2h ago'); + }); + + it('returns days ago for <7 days', () => { + expect(formatRelativeTime(new Date('2025-06-12T12:00:00Z'))).toBe('3d ago'); + }); + + it('returns locale date string for 7+ days ago', () => { + const result = formatRelativeTime(new Date('2025-06-01T12:00:00Z')); + // Should be a date string, not a relative time + expect(result).not.toContain('ago'); + expect(result).not.toBe('-'); + }); + + it('handles string date input', () => { + expect(formatRelativeTime('2025-06-15T11:55:00Z')).toBe('5m ago'); + }); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..66aad993 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + CASCADE Dashboard + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 00000000..2cbfddf8 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2702 @@ +{ + "name": "cascade-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "cascade-web", + "dependencies": { + "@tanstack/react-query": "^5.75.5", + "@tanstack/react-router": "^1.121.0", + "@trpc/client": "^11.1.2", + "@trpc/tanstack-react-query": "^11.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.475.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.3.0", + "tw-animate-css": "^1.2.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.7", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.5", + "@vitejs/plugin-react": "^4.5.2", + "tailwindcss": "^4.1.7", + "typescript": "^5.7.2", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/history": { + "version": "1.154.14", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.154.14.tgz", + "integrity": "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.90.20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.160.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.160.0.tgz", + "integrity": "sha512-leT/nymh9rKFVivy4b/F8/PZiMrLpotNiyemNg0/KjdZNzo5oVEdFnsXVFnBI1lL4WXRbiq7RK8+fI0SKsT6ww==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.154.14", + "@tanstack/react-store": "^0.8.0", + "@tanstack/router-core": "1.160.0", + "isbot": "^5.1.22", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.8.1.tgz", + "integrity": "sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.8.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "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" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.160.0", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.160.0.tgz", + "integrity": "sha512-vbh6OsE0MG+0c+SKh2uk5yEEZlWsxT96Ub2JaTs7ixOvZp3Wu9PTEIe2BA3cShNZhEsDI0Le4NqgY4XIaHLLvA==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.154.14", + "@tanstack/store": "^0.8.0", + "cookie-es": "^2.0.0", + "seroval": "^1.4.2", + "seroval-plugins": "^1.4.2", + "tiny-invariant": "^1.3.3", + "tiny-warning": "^1.0.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.8.1.tgz", + "integrity": "sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@trpc/client": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@trpc/client/-/client-11.10.0.tgz", + "integrity": "sha512-h0s2AwDtuhS8INRb4hlo4z3RKCkarWqlOy+3ffJgrlDxzzW6aLUN+9nDrcN4huPje1Em15tbCOqhIc6oaKYTRw==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@trpc/server": "11.10.0", + "typescript": ">=5.7.2" + } + }, + "node_modules/@trpc/server": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.10.0.tgz", + "integrity": "sha512-zZjTrR6He61e5TiT7e/bQqab/jRcXBZM8Fg78Yoo8uh5pz60dzzbYuONNUCOkafv5ppXVMms4NHYfNZgzw50vg==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peer": true, + "peerDependencies": { + "typescript": ">=5.7.2" + } + }, + "node_modules/@trpc/tanstack-react-query": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@trpc/tanstack-react-query/-/tanstack-react-query-11.10.0.tgz", + "integrity": "sha512-fXkkhH6UDFAFMwlXePkgbmUAiDgflpbWx4EbzRANKFzMtxyFrBjbSHQrAPrm4ZLjZdJcIeHK0oAIDPhiOh4VYg==", + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "@tanstack/react-query": "^5.80.3", + "@trpc/client": "11.10.0", + "@trpc/server": "11.10.0", + "react": ">=18.2.0", + "typescript": ">=5.7.2" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz", + "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/isbot": { + "version": "5.1.35", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.35.tgz", + "integrity": "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz", + "integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.0.tgz", + "integrity": "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwind-merge": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.1.tgz", + "integrity": "sha512-2OA0rFqWOkITEAOFWSBSApYkDeH9t2B3XSJuI4YztKBzK3mX0737A2qtxDZ7xkw9Zfh0bWl+r34sF3HXV+Ig7Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..4d8704a7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "cascade-web", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.75.5", + "@tanstack/react-router": "^1.121.0", + "@trpc/client": "^11.1.2", + "@trpc/tanstack-react-query": "^11.1.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^0.475.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.3.0", + "tw-animate-css": "^1.2.9", + "zod": "^4.3.6" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.1.7", + "@types/react": "^19.1.4", + "@types/react-dom": "^19.1.5", + "@vitejs/plugin-react": "^4.5.2", + "tailwindcss": "^4.1.7", + "typescript": "^5.7.2", + "vite": "^6.3.5" + } +} diff --git a/web/src/app.tsx b/web/src/app.tsx new file mode 100644 index 00000000..de3a4347 --- /dev/null +++ b/web/src/app.tsx @@ -0,0 +1,20 @@ +import { QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, createRouter } from '@tanstack/react-router'; +import { queryClient } from './lib/query-client.js'; +import { routeTree } from './routes/route-tree.js'; + +const router = createRouter({ routeTree }); + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +export function App() { + return ( + + + + ); +} diff --git a/web/src/components/debug/debug-analysis.tsx b/web/src/components/debug/debug-analysis.tsx new file mode 100644 index 00000000..3686af31 --- /dev/null +++ b/web/src/components/debug/debug-analysis.tsx @@ -0,0 +1,50 @@ +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; + +interface DebugAnalysisProps { + runId: string; +} + +function Section({ title, content }: { title: string; content: string | null | undefined }) { + if (!content) return null; + return ( +
+

{title}

+
{content}
+
+ ); +} + +export function DebugAnalysis({ runId }: DebugAnalysisProps) { + const analysisQuery = useQuery(trpc.runs.getDebugAnalysis.queryOptions({ runId })); + + if (analysisQuery.isLoading) { + return
Loading analysis...
; + } + + if (!analysisQuery.data) { + return ( +
+ No debug analysis available for this run +
+ ); + } + + const analysis = analysisQuery.data; + + return ( +
+ {analysis.severity && ( +
+ Severity: + {analysis.severity} +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx new file mode 100644 index 00000000..a635a20d --- /dev/null +++ b/web/src/components/layout/header.tsx @@ -0,0 +1,35 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { LogOut } from 'lucide-react'; + +interface HeaderProps { + user: { name: string } | undefined; +} + +export function Header({ user }: HeaderProps) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + async function handleLogout() { + await fetch('/api/auth/logout', { method: 'POST' }); + queryClient.clear(); + navigate({ to: '/login' }); + } + + return ( +
+
+
+ {user && {user.name}} + +
+
+ ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx new file mode 100644 index 00000000..f167438c --- /dev/null +++ b/web/src/components/layout/sidebar.tsx @@ -0,0 +1,48 @@ +import { cn } from '@/lib/utils.js'; +import { Link, useRouterState } from '@tanstack/react-router'; +import { Activity, LayoutDashboard } from 'lucide-react'; + +interface SidebarProps { + user: { name: string; email: string } | undefined; +} + +const navItems = [{ to: '/' as const, label: 'Runs', icon: Activity }]; + +export function Sidebar({ user }: SidebarProps) { + const routerState = useRouterState(); + const currentPath = routerState.location.pathname; + + return ( +
+
+ + CASCADE +
+ + + + {user && ( +
+
{user.name}
+
{user.email}
+
+ )} +
+ ); +} diff --git a/web/src/components/llm-calls/llm-call-detail.tsx b/web/src/components/llm-calls/llm-call-detail.tsx new file mode 100644 index 00000000..794f1581 --- /dev/null +++ b/web/src/components/llm-calls/llm-call-detail.tsx @@ -0,0 +1,59 @@ +import { trpc } from '@/lib/trpc.js'; +import { cn } from '@/lib/utils.js'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; + +interface LlmCallDetailProps { + runId: string; + callNumber: number; +} + +type DetailTab = 'request' | 'response'; + +export function LlmCallDetail({ runId, callNumber }: LlmCallDetailProps) { + const [tab, setTab] = useState('response'); + + const callQuery = useQuery(trpc.runs.getLlmCall.queryOptions({ runId, callNumber })); + + if (callQuery.isLoading) { + return
Loading call detail...
; + } + + if (!callQuery.data) { + return
Failed to load call
; + } + + const content = tab === 'request' ? callQuery.data.request : callQuery.data.response; + + let formattedContent: string; + try { + formattedContent = content ? JSON.stringify(JSON.parse(content), null, 2) : 'No content'; + } catch { + formattedContent = content ?? 'No content'; + } + + return ( +
+
+ {(['request', 'response'] as const).map((t) => ( + + ))} +
+
+				{formattedContent}
+			
+
+ ); +} diff --git a/web/src/components/llm-calls/llm-call-list.tsx b/web/src/components/llm-calls/llm-call-list.tsx new file mode 100644 index 00000000..8dd2573e --- /dev/null +++ b/web/src/components/llm-calls/llm-call-list.tsx @@ -0,0 +1,120 @@ +import { trpc } from '@/lib/trpc.js'; +import { formatCost, formatDuration } from '@/lib/utils.js'; +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { LlmCallDetail } from './llm-call-detail.js'; + +interface LlmCallListProps { + runId: string; +} + +export function LlmCallList({ runId }: LlmCallListProps) { + const [expandedCall, setExpandedCall] = useState(null); + + const callsQuery = useQuery(trpc.runs.listLlmCalls.queryOptions({ runId })); + + if (callsQuery.isLoading) { + return
Loading LLM calls...
; + } + + const calls = callsQuery.data ?? []; + + if (calls.length === 0) { + return
No LLM calls recorded
; + } + + const totalTokensIn = calls.reduce((sum, c) => sum + (c.inputTokens ?? 0), 0); + const totalTokensOut = calls.reduce((sum, c) => sum + (c.outputTokens ?? 0), 0); + const totalCachedTokens = calls.reduce((sum, c) => sum + (c.cachedTokens ?? 0), 0); + const totalCost = calls.reduce( + (sum, c) => sum + (c.costUsd ? Number.parseFloat(c.costUsd) : 0), + 0, + ); + + return ( +
+
+
+
Total Calls
+
{calls.length}
+
+
+
Input Tokens
+
{totalTokensIn.toLocaleString()}
+
+
+
Output Tokens
+
{totalTokensOut.toLocaleString()}
+
+
+
Total Cost
+
{formatCost(totalCost)}
+
+
+ +
+ + + + + + + + + + + + + {calls.map((call) => ( + <> + + setExpandedCall(expandedCall === call.callNumber ? null : call.callNumber) + } + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') + setExpandedCall(expandedCall === call.callNumber ? null : call.callNumber); + }} + className="cursor-pointer border-b border-border transition-colors hover:bg-muted/30" + > + + + + + + + + {expandedCall === call.callNumber && ( + + + + )} + + ))} + +
# + Input Tokens + + Output Tokens + CachedCostDuration
{call.callNumber} + {call.inputTokens?.toLocaleString() ?? '-'} + + {call.outputTokens?.toLocaleString() ?? '-'} + + {call.cachedTokens?.toLocaleString() ?? '-'} + {formatCost(call.costUsd)} + {formatDuration(call.durationMs)} +
+ +
+
+ + {totalCachedTokens > 0 && ( +
+ Cache hit rate: {((totalCachedTokens / totalTokensIn) * 100).toFixed(1)}% of input tokens +
+ )} +
+ ); +} diff --git a/web/src/components/runs/run-filters.tsx b/web/src/components/runs/run-filters.tsx new file mode 100644 index 00000000..287bc2a0 --- /dev/null +++ b/web/src/components/runs/run-filters.tsx @@ -0,0 +1,79 @@ +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; + +interface RunFiltersProps { + projectId: string; + status: string; + agentType: string; + onProjectChange: (v: string) => void; + onStatusChange: (v: string) => void; + onAgentTypeChange: (v: string) => void; +} + +const statuses = ['running', 'completed', 'failed', 'timed_out']; +const agentTypes = [ + 'briefing', + 'planning', + 'implementation', + 'review', + 'debug', + 'respond-to-review', + 'respond-to-pr-comment', +]; + +export function RunFilters({ + projectId, + status, + agentType, + onProjectChange, + onStatusChange, + onAgentTypeChange, +}: RunFiltersProps) { + const projectsQuery = useQuery(trpc.projects.list.queryOptions()); + + const selectClass = + 'h-9 rounded-md border border-input bg-transparent px-3 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring'; + + return ( +
+ + + + + +
+ ); +} diff --git a/web/src/components/runs/run-status-badge.tsx b/web/src/components/runs/run-status-badge.tsx new file mode 100644 index 00000000..8d85e494 --- /dev/null +++ b/web/src/components/runs/run-status-badge.tsx @@ -0,0 +1,21 @@ +import { cn } from '@/lib/utils.js'; + +const statusStyles: Record = { + running: 'bg-blue-100 text-blue-700', + completed: 'bg-green-100 text-green-700', + failed: 'bg-red-100 text-red-700', + timed_out: 'bg-amber-100 text-amber-700', +}; + +export function RunStatusBadge({ status }: { status: string }) { + return ( + + {status === 'timed_out' ? 'timed out' : status} + + ); +} diff --git a/web/src/components/runs/run-summary-card.tsx b/web/src/components/runs/run-summary-card.tsx new file mode 100644 index 00000000..f8279e41 --- /dev/null +++ b/web/src/components/runs/run-summary-card.tsx @@ -0,0 +1,93 @@ +import { formatCost, formatDuration } from '@/lib/utils.js'; +import { ExternalLink } from 'lucide-react'; + +interface RunSummaryProps { + run: { + id: string; + projectId: string | null; + cardId: string | null; + prNumber: number | null; + agentType: string; + backend: string; + triggerType: string | null; + status: string; + model: string | null; + maxIterations: number | null; + startedAt: string | null; + completedAt: string | null; + durationMs: number | null; + llmIterations: number | null; + gadgetCalls: number | null; + costUsd: string | null; + success: boolean | null; + error: string | null; + prUrl: string | null; + outputSummary: string | null; + }; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +export function RunSummaryCard({ run }: RunSummaryProps) { + return ( +
+
+ {run.status} + {run.backend} + {run.model ?? '-'} + {run.triggerType ?? '-'} + {formatDuration(run.durationMs)} + {formatCost(run.costUsd)} + + {run.llmIterations != null + ? `${run.llmIterations}${run.maxIterations ? ` / ${run.maxIterations}` : ''}` + : '-'} + + {run.gadgetCalls ?? '-'} + + {run.startedAt ? new Date(run.startedAt).toLocaleString() : '-'} + + + {run.completedAt ? new Date(run.completedAt).toLocaleString() : '-'} + + {run.cardId ?? '-'} + {run.prUrl && ( + + + PR #{run.prNumber ?? 'link'} + + + + )} +
+ + {run.error && ( +
+

Error

+
{run.error}
+
+ )} + + {run.outputSummary && ( +
+

Output Summary

+
+						{run.outputSummary}
+					
+
+ )} +
+ ); +} diff --git a/web/src/components/runs/runs-table.tsx b/web/src/components/runs/runs-table.tsx new file mode 100644 index 00000000..d0cf5e1b --- /dev/null +++ b/web/src/components/runs/runs-table.tsx @@ -0,0 +1,130 @@ +import { formatCost, formatDuration, formatRelativeTime } from '@/lib/utils.js'; +import { Link } from '@tanstack/react-router'; +import { ExternalLink } from 'lucide-react'; +import { RunStatusBadge } from './run-status-badge.js'; + +interface Run { + id: string; + projectName: string | null; + agentType: string; + status: string; + startedAt: string | null; + durationMs: number | null; + costUsd: string | null; + llmIterations: number | null; + prUrl: string | null; +} + +interface RunsTableProps { + runs: Run[]; + total: number; + offset: number; + limit: number; + onPageChange: (offset: number) => void; +} + +export function RunsTable({ runs, total, offset, limit, onPageChange }: RunsTableProps) { + const totalPages = Math.ceil(total / limit); + const currentPage = Math.floor(offset / limit) + 1; + + return ( +
+
+ + + + + + + + + + + + + + + {runs.length === 0 && ( + + + + )} + {runs.map((run) => ( + + + + + + + + + + + ))} + +
AgentProjectStatusStartedDurationCostIterationsPR
+ No runs found +
+ + {run.agentType} + + {run.projectName ?? '-'} + + + {formatRelativeTime(run.startedAt)} + + {formatDuration(run.durationMs)} + {formatCost(run.costUsd)}{run.llmIterations ?? '-'} + {run.prUrl ? ( + + + + ) : ( + '-' + )} +
+
+ + {total > limit && ( +
+
+ Showing {offset + 1}-{Math.min(offset + limit, total)} of {total} +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} +
+ ); +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 00000000..3483b14f --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,59 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +@custom-variant dark (&:is(.dark *)); + +@theme inline { + --color-background: oklch(1 0 0); + --color-foreground: oklch(0.145 0 0); + --color-card: oklch(1 0 0); + --color-card-foreground: oklch(0.145 0 0); + --color-popover: oklch(1 0 0); + --color-popover-foreground: oklch(0.145 0 0); + --color-primary: oklch(0.205 0 0); + --color-primary-foreground: oklch(0.985 0 0); + --color-secondary: oklch(0.97 0 0); + --color-secondary-foreground: oklch(0.205 0 0); + --color-muted: oklch(0.97 0 0); + --color-muted-foreground: oklch(0.556 0 0); + --color-accent: oklch(0.97 0 0); + --color-accent-foreground: oklch(0.205 0 0); + --color-destructive: oklch(0.577 0.245 27.325); + --color-destructive-foreground: oklch(0.577 0.245 27.325); + --color-border: oklch(0.922 0 0); + --color-input: oklch(0.922 0 0); + --color-ring: oklch(0.708 0 0); + --color-chart-1: oklch(0.646 0.222 41.116); + --color-chart-2: oklch(0.6 0.118 184.704); + --color-chart-3: oklch(0.398 0.07 227.392); + --color-chart-4: oklch(0.828 0.189 84.429); + --color-chart-5: oklch(0.769 0.188 70.08); + --color-sidebar: oklch(0.985 0 0); + --color-sidebar-foreground: oklch(0.145 0 0); + --color-sidebar-primary: oklch(0.205 0 0); + --color-sidebar-primary-foreground: oklch(0.985 0 0); + --color-sidebar-accent: oklch(0.97 0 0); + --color-sidebar-accent-foreground: oklch(0.205 0 0); + --color-sidebar-border: oklch(0.922 0 0); + --color-sidebar-ring: oklch(0.708 0 0); + --radius-sm: 0.25rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-xl: 0.75rem; +} + +@layer base { + *, + ::after, + ::before, + ::backdrop, + ::file-selector-button { + border-color: var(--color-border); + } + body { + background-color: var(--color-background); + color: var(--color-foreground); + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; + } +} diff --git a/web/src/lib/query-client.ts b/web/src/lib/query-client.ts new file mode 100644 index 00000000..c89dd709 --- /dev/null +++ b/web/src/lib/query-client.ts @@ -0,0 +1,10 @@ +import { QueryClient } from '@tanstack/react-query'; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + retry: 1, + }, + }, +}); diff --git a/web/src/lib/trpc.ts b/web/src/lib/trpc.ts new file mode 100644 index 00000000..48f01384 --- /dev/null +++ b/web/src/lib/trpc.ts @@ -0,0 +1,17 @@ +import { createTRPCClient, httpBatchLink } from '@trpc/client'; +import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'; +import type { AppRouter } from '../../../src/api/router'; +import { queryClient } from './query-client'; + +export const trpcClient = createTRPCClient({ + links: [ + httpBatchLink({ + url: '/trpc', + }), + ], +}); + +export const trpc = createTRPCOptionsProxy({ + client: trpcClient, + queryClient, +}); diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts new file mode 100644 index 00000000..136398d5 --- /dev/null +++ b/web/src/lib/utils.ts @@ -0,0 +1,41 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +export function formatDuration(ms: number | null | undefined): string { + if (ms == null) return '-'; + if (ms < 1000) return `${ms}ms`; + const seconds = Math.floor(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remaining = seconds % 60; + return `${minutes}m ${remaining}s`; +} + +export function formatCost(usd: string | number | null | undefined): string { + if (usd == null) return '-'; + const n = typeof usd === 'string' ? Number.parseFloat(usd) : usd; + if (Number.isNaN(n)) return '-'; + return `$${n.toFixed(4)}`; +} + +export function formatRelativeTime(date: Date | string | null | undefined): string { + if (!date) return '-'; + const d = typeof date === 'string' ? new Date(date) : date; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + + if (diffSeconds < 60) return 'just now'; + const diffMinutes = Math.floor(diffSeconds / 60); + if (diffMinutes < 60) return `${diffMinutes}m ago`; + const diffHours = Math.floor(diffMinutes / 60); + if (diffHours < 24) return `${diffHours}h ago`; + const diffDays = Math.floor(diffHours / 24); + if (diffDays < 7) return `${diffDays}d ago`; + + return d.toLocaleDateString(); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 00000000..a7d58500 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './app.js'; +import './index.css'; + +// biome-ignore lint/style/noNonNullAssertion: root element always exists +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx new file mode 100644 index 00000000..37ce8204 --- /dev/null +++ b/web/src/routes/__root.tsx @@ -0,0 +1,51 @@ +import { Header } from '@/components/layout/header.js'; +import { Sidebar } from '@/components/layout/sidebar.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'; +import { useEffect } from 'react'; + +function RootLayout() { + const navigate = useNavigate(); + const meQuery = useQuery(trpc.auth.me.queryOptions()); + + const isLoginPage = window.location.pathname === '/login'; + + useEffect(() => { + if (meQuery.isError && !isLoginPage) { + navigate({ to: '/login' }); + } + }, [meQuery.isError, isLoginPage, navigate]); + + if (isLoginPage) { + return ; + } + + if (meQuery.isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (meQuery.isError) { + return null; + } + + return ( +
+ +
+
+
+ +
+
+
+ ); +} + +export const rootRoute = createRootRoute({ + component: RootLayout, +}); diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx new file mode 100644 index 00000000..0ab41bae --- /dev/null +++ b/web/src/routes/index.tsx @@ -0,0 +1,96 @@ +import { RunFilters } from '@/components/runs/run-filters.js'; +import { RunsTable } from '@/components/runs/runs-table.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { z } from 'zod'; +import { rootRoute } from './__root.js'; + +const searchSchema = z.object({ + projectId: z.string().optional().catch(undefined), + status: z.string().optional().catch(undefined), + agentType: z.string().optional().catch(undefined), + offset: z.number().optional().catch(0), +}); + +function RunsListPage() { + const navigate = useNavigate({ from: '/' }); + const search = useSearch({ from: '/' }); + + const projectId = search.projectId ?? ''; + const status = search.status ?? ''; + const agentType = search.agentType ?? ''; + const offset = search.offset ?? 0; + const limit = 50; + + const runsQuery = useQuery({ + ...trpc.runs.list.queryOptions({ + projectId: projectId || undefined, + status: status ? [status] : undefined, + agentType: agentType || undefined, + limit, + offset, + }), + refetchInterval: (query) => { + const hasRunning = query.state.data?.data?.some((r) => r.status === 'running'); + return hasRunning ? 5000 : false; + }, + }); + + function updateSearch(updates: Record) { + navigate({ + search: (prev) => ({ + ...prev, + ...updates, + offset: updates.offset !== undefined ? updates.offset : 0, + }), + }); + } + + return ( +
+
+

Agent Runs

+ {runsQuery.data && ( + {runsQuery.data.total} total + )} +
+ + updateSearch({ projectId: v || undefined })} + onStatusChange={(v) => updateSearch({ status: v || undefined })} + onAgentTypeChange={(v) => updateSearch({ agentType: v || undefined })} + /> + + {runsQuery.isLoading && ( +
Loading runs...
+ )} + + {runsQuery.isError && ( +
+ Failed to load runs: {runsQuery.error.message} +
+ )} + + {runsQuery.data && ( + updateSearch({ offset: newOffset })} + /> + )} +
+ ); +} + +export const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: RunsListPage, + validateSearch: searchSchema, +}); diff --git a/web/src/routes/login.tsx b/web/src/routes/login.tsx new file mode 100644 index 00000000..d006f8e2 --- /dev/null +++ b/web/src/routes/login.tsx @@ -0,0 +1,102 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { createRoute, useNavigate } from '@tanstack/react-router'; +import { useState } from 'react'; +import { rootRoute } from './__root.js'; + +function LoginPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(''); + setLoading(true); + + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + + if (!res.ok) { + const data = await res.json(); + setError(data.error || 'Login failed'); + return; + } + + await queryClient.invalidateQueries(); + navigate({ to: '/' }); + } catch { + setError('Network error'); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

CASCADE

+

Sign in to your dashboard

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + setEmail(e.target.value)} + required + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + placeholder="you@example.com" + /> +
+ +
+ + setPassword(e.target.value)} + required + className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" + /> +
+ + +
+
+
+ ); +} + +export const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/login', + component: LoginPage, +}); diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts new file mode 100644 index 00000000..cc046dbd --- /dev/null +++ b/web/src/routes/route-tree.ts @@ -0,0 +1,6 @@ +import { rootRoute } from './__root.js'; +import { indexRoute } from './index.js'; +import { loginRoute } from './login.js'; +import { runDetailRoute } from './runs/$runId.js'; + +export const routeTree = rootRoute.addChildren([loginRoute, indexRoute, runDetailRoute]); diff --git a/web/src/routes/runs/$runId.tsx b/web/src/routes/runs/$runId.tsx new file mode 100644 index 00000000..79422f78 --- /dev/null +++ b/web/src/routes/runs/$runId.tsx @@ -0,0 +1,86 @@ +import { DebugAnalysis } from '@/components/debug/debug-analysis.js'; +import { LlmCallList } from '@/components/llm-calls/llm-call-list.js'; +import { LogViewer } from '@/components/logs/log-viewer.js'; +import { RunStatusBadge } from '@/components/runs/run-status-badge.js'; +import { RunSummaryCard } from '@/components/runs/run-summary-card.js'; +import { trpc } from '@/lib/trpc.js'; +import { cn } from '@/lib/utils.js'; +import { useQuery } from '@tanstack/react-query'; +import { Link, createRoute } from '@tanstack/react-router'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; +import { rootRoute } from '../__root.js'; + +type Tab = 'overview' | 'logs' | 'llm-calls' | 'debug'; + +function RunDetailPage() { + const { runId } = runDetailRoute.useParams(); + const [activeTab, setActiveTab] = useState('overview'); + + const runQuery = useQuery(trpc.runs.getById.queryOptions({ id: runId })); + + if (runQuery.isLoading) { + return
Loading run...
; + } + + if (runQuery.isError || !runQuery.data) { + return
Run not found
; + } + + const run = runQuery.data; + + const tabs: { id: Tab; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'logs', label: 'Logs' }, + { id: 'llm-calls', label: 'LLM Calls' }, + { id: 'debug', label: 'Debug Analysis' }, + ]; + + return ( +
+
+ + + Back + + / +

{run.agentType}

+ +
+ +
+ +
+ + {activeTab === 'overview' && } + {activeTab === 'logs' && } + {activeTab === 'llm-calls' && } + {activeTab === 'debug' && } +
+ ); +} + +export const runDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/runs/$runId', + component: RunDetailPage, +}); diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 00000000..318c15c2 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "isolatedModules": true, + "jsx": "react-jsx", + "noEmit": true, + "resolveJsonModule": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "../src/api/**/*", "../src/db/**/*"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 00000000..8ea4c425 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,24 @@ +import path from 'node:path'; +import tailwindcss from '@tailwindcss/vite'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5173, + proxy: { + '/trpc': 'http://localhost:3000', + '/api': 'http://localhost:3000', + }, + }, + build: { + outDir: '../dist/web', + emptyOutDir: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});