diff --git a/.gitignore b/.gitignore index 5daf11b..411e03f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ vendor/ local-dev/postgres-data-fresh tmp/ +.cache/ +worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8f4aa8b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,202 @@ +# Coding Agent Guide + +This repository contains an OpenAI-compatible Go API proxy and a separate +Next.js analytics application. Keep changes focused, preserve work already in +the checkout, and verify the smallest affected surface before handing work +off. + +## Scope and precedence + +This file applies from the repository root. `app/AGENTS.md` is the more +specific instruction file for everything under `app/`; read and follow it when +working on the analytics frontend. A nested guide takes precedence over this +file where the instructions differ. Changes that cross the `app/` boundary +(for example, a shared database migration used by both applications) must +follow both scopes. + +Before editing, inspect `git status --short` and the relevant diff. Do not +overwrite, reformat, reset, stash, or revert unrelated user work. Make edits +only in files required by the request, and ask before broad refactors, +generated-file churn, data changes, or destructive/irreversible operations. + +## Repository map + +- `cmd/main.go` is the Go process entry point. It loads environment settings, + initializes the database, registers HTTP routes, and serves on port `8082`. +- `api/` contains backend API and usage/admin endpoints. `auth/` contains + authentication integration and API-key/web authentication behavior. +- `apiproxy/` implements upstream proxying, request validation, streaming, + response usage accounting, and reranking behavior. +- `db/` contains PostgreSQL access code, `schema.sql`, and ordered Atlas + migrations in `db/migrations/`. The database package applies migrations at + startup and then applies the schema SQL. +- `costs/` contains pricing/collection logic. `webui/` wires the server-side + web UI; `templates/` contains Go HTML templates and `public/` contains + static HTML/assets. +- `app/` is an independent Next.js 15 / React 19 / TypeScript analytics + frontend. It uses Drizzle against PostgreSQL and has its own `package.json`, + lockfile, and `app/AGENTS.md`. +- `local-dev/` contains the Docker Compose development stack and + `app.env.example`. `tools/azure-prices-helper/` is a separate Go helper; + `docs/` contains requirements/findings, and `nix-modules/` contains + formatting and test checks. + +## Backend development + +The root Go module is `openai-api-proxy`, requires Go `1.23`, and uses pgx for +PostgreSQL. The application reads `DATABASE_PATH` when present; otherwise it +builds a connection from `DATABASE_USERNAME`, `DATABASE_PASSWORD`, +`DATABASE_HOST`, and `DATABASE_NAME`. It also loads a `.env` file if one is +available, but secrets should normally be supplied through the environment. + +From the repository root: + +```bash +go build cmd/main.go +go run cmd/main.go +go test ./... +gofmt -l $(rg --files -g '*.go') +goimports -l $(rg --files -g '*.go') +``` + +`go build cmd/main.go` is the supported backend build. `go run cmd/main.go` +starts the server at `http://localhost:8082`; it requires valid database and +upstream/auth configuration. `go test ./...` runs the repository test suite; +tests currently live in `api/`, `apiproxy/`, and `db/`. The formatting checks +should produce no filenames. `goimports` is part of the repository's +pre-commit checks; use it when installed, and use `gofmt` for every Go change. +There is no repository-wide Go linter command documented here, so do not +invent one as a required check. + +## Analytics frontend + +When working under `app/`, first read `app/AGENTS.md`. Run frontend commands +from `app/`, not the repository root. With dependencies installed from the +committed lockfile (`npm ci`): + +```bash +npm run dev +npm run build +npm run typecheck +npm run check +npm run check:write +npm run db:generate +npm run db:migrate +npm run db:push +npm run db:studio +``` + +These scripts respectively start Next.js development mode, create a +production build, run TypeScript without emitting files, check/fix Biome +formatting and lint rules, and run Drizzle's migration generation, migration, +push, and studio workflows. Prefer `npm run check` for a read-only check; +`check:write` modifies files and must be limited to the intended frontend +scope. Database commands require the frontend's `DATABASE_URL` and an +appropriate database. Run `npm run typecheck` and the checks/build required by +`app/AGENTS.md` for TypeScript or frontend changes. + +## Local development with Docker Compose + +The supported integrated development stack is defined in +`local-dev/docker-compose.yml`. It starts PostgreSQL (`db`), the Go backend +(`app`), and the Next.js frontend (`next`). From the repository root: + +```bash +cp local-dev/app.env.example local-dev/app.env +# edit local-dev/app.env with suitable development credentials +cd local-dev +docker compose up --build +``` + +The backend is available at `http://localhost:8082`, the frontend at +`http://localhost:3000`, and PostgreSQL is exposed on host port `54329`. +Database data is persisted in `local-dev/postgres-data`; do not delete it +without explicit confirmation. The backend waits for the database healthcheck +and retries migrations by default up to 30 times with a 2-second delay. These +values can be changed with `MIGRATE_MAX_ATTEMPTS` and +`MIGRATE_RETRY_DELAY`. Go/database changes require rebuilding the Compose app; +frontend source is bind-mounted for development. Use `docker compose down` +when stopping the stack, and ask before removing volumes or persistent data. + +## Database and migration rules + +PostgreSQL is the database described by the backend and Compose configuration. +Atlas applies the ordered SQL files in `db/migrations/` during backend startup. +`db/schema.sql` is also loaded at initialization, so schema changes must keep +the migration history and schema definition consistent. + +For a schema change, add a new timestamped migration in `db/migrations/`; do +not rewrite or delete an applied migration. Review SQL for forward behavior, +indexes, constraints, nullability, existing-data compatibility, and +rollback/operational implications. Update related Go access code and frontend +Drizzle schema/code as needed, and test fresh and populated databases when +practical. Do not run `db:migrate`, `db:push`, Atlas, or destructive SQL +against a shared/production database without explicit authorization. Confirm +generated migration output and checksum changes before committing. + +## Coding and change guidance + +Keep handlers and proxy paths small, validate errors at boundaries, and keep +package responsibilities separated. Follow existing Go naming and formatting: +lowercase package names, PascalCase exported identifiers, camelCase locals, +and `gofmt`/`goimports`. Use parameterized SQL with the existing database APIs; +never construct SQL from request values. Use `html/template` escaping for HTML +and preserve existing response and streaming semantics. + +For frontend changes, follow `app/AGENTS.md` and existing TypeScript/React +patterns. Keep generated Drizzle metadata and lockfiles consistent with the +command that produced them. Add or update focused tests adjacent to changed Go +behavior, especially proxy validation, response accounting, authentication, +and database logic. + +## Security and operations + +Treat HTTP bodies, headers, query parameters, model names, URLs, file paths, +and tool/database inputs as untrusted. Validate and bound them against declared +schemas before use. In particular: + +- Use parameterized queries and allowlisted identifiers to prevent SQL + injection. Never pass untrusted input to a shell or command executor; avoid + command injection with direct APIs and strict argument validation. +- Protect outbound requests against SSRF: validate schemes, hosts, ports, + redirects, and destinations against explicit policy. Bound request/response + sizes and timeouts, and do not follow arbitrary user URLs. +- Prevent path traversal by resolving paths within an intended directory, + rejecting traversal/absolute-path escapes, and avoiding user-controlled file + names where possible. +- Enforce authentication and authorization separately. Check identity and + required scope/ownership/admin privilege at every sensitive operation; use + least-privilege database and upstream credentials. +- Keep API keys, OIDC secrets, database URLs/passwords, and upstream + credentials out of source, commits, errors, and logs. Redact authorization + headers, cookies, connection strings, payloads, and raw upstream responses. + Development-only verbose logging in `local-dev/app.env.example` is opt-in. +- Prefer TLS for database, authentication, and upstream connections outside + local development. Use secure, HttpOnly, appropriately scoped cookies and + explicit origin/CSRF protections when changing authentication flows. +- Require confirmation before irreversible actions such as deleting keys/data, + dropping columns, resetting persistent volumes, or changing production + configuration. Plan migrations and rollouts for partial failure. +- Add structured observability around outcomes, error classes, latency, + retries, and usage/cost counters without exposing credentials or payloads. + +When modifying operations, document new environment variables, limits, retry +behavior, and failure modes. Do not weaken validation, authorization, TLS, +redaction, or resource limits merely to make a test pass. + +## Agent workflow and handoff + +1. Read this guide and any more-specific guide, inspect status/diffs, and map + the request to the smallest set of packages/files. +2. Confirm assumptions from the checkout. Ask before broad refactors, + dependency upgrades, generated changes outside the task, data-destructive + commands, or external/production operations. +3. Implement the focused change while preserving unrelated worktree edits. +4. Format and run targeted validation. For backend changes use `gofmt`, + `goimports` where available, targeted Go tests, and + `go build cmd/main.go`; for frontend changes use the required scripts from + `app/AGENTS.md`. Do not claim a command passed unless it ran. +5. Review `git diff` and `git status --short` again. Report changed files, + validation performed, skipped checks and reasons, migration impact, and + remaining operational risk. Never include secrets in the handoff. + diff --git a/app/RELEASE_NOTES.md b/app/RELEASE_NOTES.md index e23e927..ab48b2a 100644 --- a/app/RELEASE_NOTES.md +++ b/app/RELEASE_NOTES.md @@ -1,6 +1,7 @@ # Release Notes ## Unreleased +- Made the usage dashboard the authenticated landing page. - Added an authenticated service-health dashboard with local range controls and admin-configurable retention with immediate purge. - Added lazy, exact hourly request-statistics caching for faster administrator reports, with pricing invalidation and manual UTC-range rebuilds. - Added separate chat-token and rerank-search reporting and dashboard charts. diff --git a/app/src/app/my-usage/page.tsx b/app/src/app/my-usage/page.tsx index adad165..ee83577 100644 --- a/app/src/app/my-usage/page.tsx +++ b/app/src/app/my-usage/page.tsx @@ -1,23 +1,11 @@ import { redirect } from "next/navigation"; +import { UsagePage } from "~/components/my-usage-page"; import { auth } from "~/server/auth"; -import { ReportingCreateClient } from "../reporting/create/reporting-create-client"; export default async function MyUsagePage() { const session = await auth(); if (!session?.user) redirect("/"); - return ( -
-
-

Meine Nutzung

-

- Eigene API-Nutzung und Kosten, ausschließlich für deine API-Schlüssel. -

-
-
- -
-
- ); + return ; } diff --git a/app/src/app/page.tsx b/app/src/app/page.tsx index 424fbf6..b68c7f2 100644 --- a/app/src/app/page.tsx +++ b/app/src/app/page.tsx @@ -1,3 +1,5 @@ +import { UsagePage } from "~/components/my-usage-page"; + export default function Home() { - return
; + return ; } diff --git a/app/src/app/reporting/create/reporting-create-client.tsx b/app/src/app/reporting/create/reporting-create-client.tsx index 463c14d..4708514 100644 --- a/app/src/app/reporting/create/reporting-create-client.tsx +++ b/app/src/app/reporting/create/reporting-create-client.tsx @@ -51,7 +51,7 @@ const chartColors = [ "var(--chart-5)", ]; -type Timeframe = "daily" | "monthly" | "quarterly" | "yearly"; +type Timeframe = "live" | "daily" | "monthly" | "quarterly" | "yearly"; type SortKey = | "name" @@ -70,12 +70,21 @@ type CumulativeCosts = { }; const timeframeOptions = [ + { value: "live", label: "Heute (live)" }, { value: "daily", label: "Täglich" }, { value: "monthly", label: "Monatlich" }, { value: "quarterly", label: "Quartal" }, { value: "yearly", label: "Jährlich" }, ] as const; +function localMidnightIso(date: Date) { + return new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ).toISOString(); +} + const pad = (value: number) => String(value).padStart(2, "0"); const utcDateTimeLocalValue = (date: Date) => @@ -98,13 +107,32 @@ export function ReportingCreateClient({ const [selectedApiKeyIds, setSelectedApiKeyIds] = useState([]); const [allApiKeys, setAllApiKeys] = useState(true); - const now = useMemo(() => new Date(), []); + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + let timeoutId: number; + const scheduleNextMidnight = () => { + const nextMidnight = new Date(); + nextMidnight.setHours(24, 0, 0, 0); + timeoutId = window.setTimeout( + () => { + setNow(new Date()); + scheduleNextMidnight(); + }, + Math.max(1_000, nextMidnight.getTime() - Date.now()), + ); + }; + + scheduleNextMidnight(); + return () => window.clearTimeout(timeoutId); + }, []); + const currentYear = now.getFullYear(); const currentMonth = now.getMonth() + 1; const currentDay = now.getDate(); const currentQuarter = Math.floor((now.getMonth() + 3) / 3); - const [timeframe, setTimeframe] = useState("monthly"); + const [timeframe, setTimeframe] = useState("live"); const [dailyDate, setDailyDate] = useState( `${currentYear}-${pad(currentMonth)}-${pad(currentDay)}`, ); @@ -144,6 +172,68 @@ export function ReportingCreateClient({ }, [groups, isAdmin]); const [selectedGroupId, setSelectedGroupId] = useState(""); + const [urlStateInitialized, setUrlStateInitialized] = useState(false); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const savedTimeframe = params.get("range") as Timeframe | null; + if ( + savedTimeframe && + timeframeOptions.some(({ value }) => value === savedTimeframe) + ) { + setTimeframe(savedTimeframe); + if (savedTimeframe === "daily") + setDailyDate( + params.get("date") ?? + `${currentYear}-${pad(currentMonth)}-${pad(currentDay)}`, + ); + if (savedTimeframe === "monthly") + setMonthValue( + params.get("month") ?? `${currentYear}-${pad(currentMonth)}`, + ); + if (savedTimeframe === "quarterly") { + setQuarterYear(Number(params.get("year")) || currentYear); + setQuarterValue(Number(params.get("quarter")) || currentQuarter); + } + if (savedTimeframe === "yearly") + setYearValue(Number(params.get("year")) || currentYear); + } + setUrlStateInitialized(true); + // Read the URL once; subsequent changes are written by the effect below. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentDay, currentMonth, currentQuarter, currentYear]); + + useEffect(() => { + if (!urlStateInitialized) return; + const params = new URLSearchParams(window.location.search); + for (const key of ["range", "date", "month", "year", "quarter"]) { + params.delete(key); + } + if (timeframe !== "live") { + params.set("range", timeframe); + if (timeframe === "daily") params.set("date", dailyDate); + if (timeframe === "monthly") params.set("month", monthValue); + if (timeframe === "quarterly") { + params.set("year", String(quarterYear)); + params.set("quarter", String(quarterValue)); + } + if (timeframe === "yearly") params.set("year", String(yearValue)); + } + const query = params.toString(); + window.history.replaceState( + null, + "", + query ? `?${query}` : window.location.pathname, + ); + }, [ + dailyDate, + monthValue, + quarterValue, + quarterYear, + timeframe, + urlStateInitialized, + yearValue, + ]); useEffect(() => { if (selectedGroupId) return; @@ -158,6 +248,8 @@ export function ReportingCreateClient({ const reportRange = useMemo(() => { switch (timeframe) { + case "live": + return { type: "live" as const, start: localMidnightIso(now) }; case "daily": return { type: "daily" as const, date: dailyDate }; case "monthly": @@ -171,11 +263,20 @@ export function ReportingCreateClient({ case "yearly": return { type: "yearly" as const, year: Number(yearValue) }; default: - return { type: "monthly" as const, month: monthValue }; + return { type: "live" as const, start: localMidnightIso(now) }; } - }, [dailyDate, monthValue, quarterValue, quarterYear, timeframe, yearValue]); + }, [ + dailyDate, + monthValue, + now, + quarterValue, + quarterYear, + timeframe, + yearValue, + ]); const selectedPeriodEnd = useMemo(() => { + if (timeframe === "live") return now; switch (timeframe) { case "daily": { const [year, month, day] = dailyDate.split("-").map(Number); @@ -197,7 +298,15 @@ export function ReportingCreateClient({ default: return null; } - }, [dailyDate, monthValue, quarterValue, quarterYear, timeframe, yearValue]); + }, [ + dailyDate, + monthValue, + now, + quarterValue, + quarterYear, + timeframe, + yearValue, + ]); const isPeriodOpen = useMemo(() => { if (!selectedPeriodEnd) return false; @@ -214,7 +323,10 @@ export function ReportingCreateClient({ allApiKeys, }, { - enabled: reportEnabled && (isSelf || isAdmin || groups.length > 0), + enabled: + urlStateInitialized && + reportEnabled && + (isSelf || isAdmin || groups.length > 0), }, ); diff --git a/app/src/components/my-usage-page.tsx b/app/src/components/my-usage-page.tsx new file mode 100644 index 0000000..37aafcf --- /dev/null +++ b/app/src/components/my-usage-page.tsx @@ -0,0 +1,17 @@ +import { ReportingCreateClient } from "~/app/reporting/create/reporting-create-client"; + +export function UsagePage() { + return ( +
+
+

Meine Nutzung

+

+ Eigene API-Nutzung und Kosten, ausschließlich für deine API-Schlüssel. +

+
+
+ +
+
+ ); +} diff --git a/app/src/server/api/routers/reporting.ts b/app/src/server/api/routers/reporting.ts index a89439a..69a6cda 100644 --- a/app/src/server/api/routers/reporting.ts +++ b/app/src/server/api/routers/reporting.ts @@ -41,6 +41,10 @@ const adminProcedure = protectedProcedure.use(({ ctx, next }) => { }); const reportRangeInput = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("live"), + start: z.string().datetime({ offset: true }), + }), z.object({ type: z.literal("daily"), date: z @@ -69,8 +73,18 @@ const reportRangeInput = z.discriminatedUnion("type", [ const normalize = (value: string | null | undefined) => (value ?? "").trim().toLowerCase(); -function getDateRange(input: z.infer) { +function getDateRange( + input: z.infer, + now = new Date(), +) { switch (input.type) { + case "live": { + const start = new Date(input.start); + if (!Number.isFinite(start.getTime()) || start >= now) { + throw new TRPCError({ code: "BAD_REQUEST" }); + } + return { start, end: now, isLive: true }; + } case "daily": { const parts = input.date.split("-").map(Number); const year = parts[0]; @@ -88,7 +102,7 @@ function getDateRange(input: z.infer) { } const start = new Date(Date.UTC(year, month - 1, day)); const end = new Date(Date.UTC(year, month - 1, day + 1)); - return { start, end }; + return { start, end, isLive: false }; } case "monthly": { const parts = input.month.split("-").map(Number); @@ -104,21 +118,21 @@ function getDateRange(input: z.infer) { } const start = new Date(Date.UTC(year, month - 1, 1)); const end = new Date(Date.UTC(year, month, 1)); - return { start, end }; + return { start, end, isLive: false }; } case "quarterly": { const startMonth = (input.quarter - 1) * 3; const start = new Date(Date.UTC(input.year, startMonth, 1)); const end = new Date(Date.UTC(input.year, startMonth + 3, 1)); - return { start, end }; + return { start, end, isLive: false }; } case "yearly": { const start = new Date(Date.UTC(input.year, 0, 1)); const end = new Date(Date.UTC(input.year + 1, 0, 1)); - return { start, end }; + return { start, end, isLive: false }; } default: - return { start: new Date(0), end: new Date() }; + return { start: new Date(0), end: now, isLive: false }; } } @@ -407,7 +421,7 @@ export const reportingRouter = createTRPCRouter({ } } - const { start, end } = getDateRange(input.range); + const { start, end, isLive } = getDateRange(input.range); const dayCount = Math.max( 1, Math.ceil((end.getTime() - start.getTime()) / 86_400_000), @@ -460,7 +474,9 @@ export const reportingRouter = createTRPCRouter({ } } - await ensureRequestStatisticsCache(ctx.db, start, end); + await ensureRequestStatisticsCache(ctx.db, start, end, { + refreshCurrentBucket: isLive, + }); type UserModel = { model: string; diff --git a/app/src/server/requestStatisticsCache.ts b/app/src/server/requestStatisticsCache.ts index 69a70f1..f7b4969 100644 --- a/app/src/server/requestStatisticsCache.ts +++ b/app/src/server/requestStatisticsCache.ts @@ -484,6 +484,7 @@ async function populate( start: Date, end: Date, force: boolean, + refreshCurrentBucket = false, ) { const buckets = listBuckets(start, end); if (!buckets.length) return; @@ -513,9 +514,15 @@ async function populate( .filter((marker) => marker.invalidatedAt === null) .map((marker) => new Date(marker.bucketStart).getTime()), ); + const currentBucket = floorUtcHour(new Date()); const missingBuckets = force ? buckets - : buckets.filter((bucket) => !validBuckets.has(bucket.getTime())); + : buckets.filter( + (bucket) => + (refreshCurrentBucket && + bucket.getTime() === currentBucket.getTime()) || + !validBuckets.has(bucket.getTime()), + ); if (!missingBuckets.length) return; const costStageRows = await loadCostRows(database); @@ -569,8 +576,15 @@ export async function ensureRequestStatisticsCache( database: AppDatabase, start: Date, end: Date, + options: { refreshCurrentBucket?: boolean } = {}, ) { - await populate(database, start, end, false); + await populate( + database, + start, + end, + false, + options.refreshCurrentBucket ?? false, + ); } export async function rebuildRequestStatisticsCache(