From 1a497f9a38eec88c2aa72053d5ee1a99880fc486 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:17:14 +1000 Subject: [PATCH 01/18] Add TOTP two-factor authentication --- .gitignore | 1 + web/app/two-factor/page.tsx | 17 + web/components/auth/sign-in-page.tsx | 16 +- .../auth/two-factor-challenge-page.tsx | 192 +++++++ web/components/settings/global-settings.tsx | 8 + .../settings/two-factor-settings.tsx | 489 ++++++++++++++++++ web/db/schema.ts | 32 +- web/lib/auth-client.ts | 27 +- web/lib/auth.ts | 7 +- web/lib/two-factor.ts | 39 ++ web/package.json | 4 +- web/pnpm-lock.yaml | 124 ++--- web/tests/two-factor.test.ts | 38 ++ 13 files changed, 922 insertions(+), 72 deletions(-) create mode 100644 web/app/two-factor/page.tsx create mode 100644 web/components/auth/two-factor-challenge-page.tsx create mode 100644 web/components/settings/two-factor-settings.tsx create mode 100644 web/lib/two-factor.ts create mode 100644 web/tests/two-factor.test.ts diff --git a/.gitignore b/.gitignore index 6aff4e36..e078189b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /node_modules /.pnp .pnp.js +.pnpm-store/ # testing /coverage diff --git a/web/app/two-factor/page.tsx b/web/app/two-factor/page.tsx new file mode 100644 index 00000000..922bc945 --- /dev/null +++ b/web/app/two-factor/page.tsx @@ -0,0 +1,17 @@ +import { Suspense } from "react"; +import { TwoFactorChallengePage } from "@/components/auth/two-factor-challenge-page"; +import { Spinner } from "@/components/ui/spinner"; + +export default function Page() { + return ( + + + + } + > + + + ); +} diff --git a/web/components/auth/sign-in-page.tsx b/web/components/auth/sign-in-page.tsx index 5e3da5a5..bad5a027 100644 --- a/web/components/auth/sign-in-page.tsx +++ b/web/components/auth/sign-in-page.tsx @@ -17,12 +17,13 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { signIn, useSession } from "@/lib/auth-client"; +import { getSafeAuthRedirect } from "@/lib/two-factor"; export function SignInPage() { const router = useRouter(); const searchParams = useSearchParams(); const { data: session, isPending } = useSession(); - const redirectTo = searchParams.get("redirect") || "/dashboard"; + const redirectTo = getSafeAuthRedirect(searchParams.get("redirect")); useEffect(() => { if (!isPending && session) { @@ -40,15 +41,22 @@ export function SignInPage() { setError(""); setLoading(true); - const { error } = await signIn.email({ + const response = await signIn.email({ email, password, }); setLoading(false); - if (error) { - setError(error.message || "Failed to sign in"); + if (response.error) { + setError(response.error.message || "Failed to sign in"); + return; + } + + if ( + (response.data as { twoFactorRedirect?: boolean } | null) + ?.twoFactorRedirect + ) { return; } diff --git a/web/components/auth/two-factor-challenge-page.tsx b/web/components/auth/two-factor-challenge-page.tsx new file mode 100644 index 00000000..f9eb22f0 --- /dev/null +++ b/web/components/auth/two-factor-challenge-page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import Image from "next/image"; +import Link from "next/link"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { authClient, useSession } from "@/lib/auth-client"; +import { getSafeAuthRedirect } from "@/lib/two-factor"; + +type AuthClientError = { + message?: string; + error_description?: string; +} | null; + +function getErrorMessage(error: AuthClientError, fallback: string) { + return error?.message || error?.error_description || fallback; +} + +function normalizeCode(value: string) { + return value.replace(/\s/g, ""); +} + +export function TwoFactorChallengePage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const { data: session, isPending } = useSession(); + const redirectTo = getSafeAuthRedirect(searchParams.get("redirect")); + const [mode, setMode] = useState<"totp" | "backup">("totp"); + const [code, setCode] = useState(""); + const [trustDevice, setTrustDevice] = useState(false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const normalizedCode = useMemo(() => normalizeCode(code), [code]); + + useEffect(() => { + if (!isPending && session) { + router.replace(redirectTo); + } + }, [isPending, redirectTo, router, session]); + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault(); + setError(""); + setLoading(true); + + const response = + mode === "totp" + ? await authClient.twoFactor.verifyTotp({ + code: normalizedCode, + trustDevice, + }) + : await authClient.twoFactor.verifyBackupCode({ + code: normalizedCode, + trustDevice, + }); + + setLoading(false); + + if (response.error) { + setError( + getErrorMessage( + response.error, + mode === "totp" + ? "Invalid authenticator code" + : "Invalid backup code", + ), + ); + return; + } + + router.push(redirectTo); + } + + if (isPending || session) { + return ( +
+ +
+ ); + } + + return ( +
+ Logo + + + Two-Factor Authentication + + Enter the code from your authenticator app to continue + + +
void handleSubmit(event)}> + + {error && ( +
+ {error} +
+ )} +
+ + +
+
+ + setCode(event.target.value)} + placeholder={mode === "totp" ? "123456" : "XXXX-XXXX"} + required + autoFocus + /> +
+
+
+ +

+ Skip 2FA on this browser for 30 days. +

+
+ +
+
+ + +

+ + Back to sign in + +

+
+
+
+
+ ); +} diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index 7b3d570b..189ef3ce 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -28,6 +28,7 @@ import { import { ApiKeySettings } from "@/components/settings/api-key-settings"; import { EmailSettings } from "@/components/settings/email-settings"; import { MemberSettings } from "@/components/settings/member-settings"; +import { TwoFactorSettings } from "@/components/settings/two-factor-settings"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -323,6 +324,9 @@ export function GlobalSettings({ Email + + Security + API Keys @@ -537,6 +541,10 @@ export function GlobalSettings({ /> + + + + diff --git a/web/components/settings/two-factor-settings.tsx b/web/components/settings/two-factor-settings.tsx new file mode 100644 index 00000000..1e693839 --- /dev/null +++ b/web/components/settings/two-factor-settings.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { + Copy, + KeyRound, + RefreshCw, + ShieldCheck, + ShieldOff, +} from "lucide-react"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient, useSession } from "@/lib/auth-client"; +import { getTotpSecret } from "@/lib/two-factor"; + +type AuthClientError = { + message?: string; + error_description?: string; +} | null; + +type TwoFactorSessionUser = { + twoFactorEnabled?: boolean | null; +}; + +type PendingSetup = { + totpURI: string; + secret: string; + backupCodes: string[]; +}; + +function getErrorMessage(error: AuthClientError, fallback: string) { + return error?.message || error?.error_description || fallback; +} + +function normalizeCode(value: string) { + return value.replace(/\s/g, ""); +} + +async function copyToClipboard(label: string, value: string) { + try { + await navigator.clipboard.writeText(value); + toast.success(`${label} copied`); + } catch { + toast.error(`Failed to copy ${label.toLowerCase()}`); + } +} + +function BackupCodes({ + codes, + title = "Backup codes", +}: { + codes: string[]; + title?: string; +}) { + if (codes.length === 0) return null; + + return ( + + + {title} + +

+ Store these codes somewhere safe. Each code can be used once if your + authenticator app is unavailable. +

+
+ {codes.map((code) => ( +
+ {code} +
+ ))} +
+ +
+
+ ); +} + +export function TwoFactorSettings() { + const { data: session, refetch } = useSession(); + const sessionUser = session?.user as TwoFactorSessionUser | undefined; + const [setupPassword, setSetupPassword] = useState(""); + const [verificationCode, setVerificationCode] = useState(""); + const [pendingSetup, setPendingSetup] = useState(null); + const [backupPassword, setBackupPassword] = useState(""); + const [disablePassword, setDisablePassword] = useState(""); + const [recoveryCodes, setRecoveryCodes] = useState([]); + const [isStartingSetup, setIsStartingSetup] = useState(false); + const [isVerifying, setIsVerifying] = useState(false); + const [isGeneratingCodes, setIsGeneratingCodes] = useState(false); + const [isDisabling, setIsDisabling] = useState(false); + + const isEnabled = Boolean( + sessionUser?.twoFactorEnabled || recoveryCodes.length, + ); + const formattedVerificationCode = useMemo( + () => normalizeCode(verificationCode), + [verificationCode], + ); + + function refreshSession() { + return refetch({ query: { disableCookieCache: true } }); + } + + async function handleStartSetup(event: React.FormEvent) { + event.preventDefault(); + setIsStartingSetup(true); + + try { + const response = await authClient.twoFactor.enable({ + password: setupPassword, + }); + + if (response.error || !response.data?.totpURI) { + throw new Error( + getErrorMessage(response.error, "Failed to start 2FA setup"), + ); + } + + setPendingSetup({ + totpURI: response.data.totpURI, + secret: getTotpSecret(response.data.totpURI), + backupCodes: response.data.backupCodes ?? [], + }); + setSetupPassword(""); + setVerificationCode(""); + setRecoveryCodes([]); + toast.success("Authenticator setup started"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start 2FA setup", + ); + } finally { + setIsStartingSetup(false); + } + } + + async function handleVerifySetup(event: React.FormEvent) { + event.preventDefault(); + if (!pendingSetup) return; + + setIsVerifying(true); + try { + const response = await authClient.twoFactor.verifyTotp({ + code: formattedVerificationCode, + }); + + if (response.error) { + throw new Error( + getErrorMessage( + response.error, + "Failed to verify authenticator code", + ), + ); + } + + setRecoveryCodes(pendingSetup.backupCodes); + setPendingSetup(null); + setVerificationCode(""); + await refreshSession(); + toast.success("Two-factor authentication enabled"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to verify authenticator code", + ); + } finally { + setIsVerifying(false); + } + } + + async function handleGenerateBackupCodes( + event: React.FormEvent, + ) { + event.preventDefault(); + setIsGeneratingCodes(true); + + try { + const response = await authClient.twoFactor.generateBackupCodes({ + password: backupPassword, + }); + + if (response.error || !response.data?.backupCodes) { + throw new Error( + getErrorMessage(response.error, "Failed to generate backup codes"), + ); + } + + setRecoveryCodes(response.data.backupCodes); + setBackupPassword(""); + toast.success("Backup codes regenerated"); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to generate backup codes", + ); + } finally { + setIsGeneratingCodes(false); + } + } + + async function handleDisable(event: React.FormEvent) { + event.preventDefault(); + setIsDisabling(true); + + try { + const response = await authClient.twoFactor.disable({ + password: disablePassword, + }); + + if (response.error || response.data?.status !== true) { + throw new Error( + getErrorMessage(response.error, "Failed to disable 2FA"), + ); + } + + setDisablePassword(""); + setBackupPassword(""); + setRecoveryCodes([]); + setPendingSetup(null); + await refreshSession(); + toast.success("Two-factor authentication disabled"); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to disable 2FA", + ); + } finally { + setIsDisabling(false); + } + } + + return ( +
+ + + + + +
+ Two-Factor Authentication + + {isEnabled ? "Enabled" : "Off"} + +
+

+ Use an authenticator app to protect your account with a 6-digit + code. +

+
+
+ +
+ {pendingSetup ? ( +
+
+
+

Set up your app

+

+ Add the setup key to your authenticator app, then enter the + current 6-digit code. +

+
+ +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+ +
void handleVerifySetup(event)}> +
+
+ + + setVerificationCode(event.target.value) + } + placeholder="123456" + required + /> +
+
+ + +
+
+
+
+ ) : isEnabled ? ( +
+ + + 2FA is enabled + + Your next password sign-in will require a code from your + authenticator app unless this device is trusted. + + + + + +
void handleGenerateBackupCodes(event)} + className="rounded-lg border p-4" + > +
+
+

Regenerate backup codes

+

+ This replaces any existing backup codes. +

+
+
+ + setBackupPassword(event.target.value)} + required + /> +
+ +
+
+ +
void handleDisable(event)} + className="rounded-lg border border-destructive/40 p-4" + > +
+
+

Disable 2FA

+

+ Password-only sign-in will be allowed again for this + account. +

+
+
+ + setDisablePassword(event.target.value)} + required + /> +
+ +
+
+
+ ) : ( +
void handleStartSetup(event)}> +
+
+

Enable authenticator app

+

+ You will enter your password, add a setup key to your + authenticator app, then verify the current code. +

+
+
+ + setSetupPassword(event.target.value)} + required + /> +
+ +
+
+ )} +
+
+ ); +} diff --git a/web/db/schema.ts b/web/db/schema.ts index 2a9137a4..86da744c 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -18,6 +18,7 @@ export const user = pgTable("user", { email: text("email").notNull().unique(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), + twoFactorEnabled: boolean("two_factor_enabled").default(false).notNull(), role: text("role", { enum: ["admin", "developer", "reader"] }) .notNull() .default("reader"), @@ -91,6 +92,27 @@ export const verification = pgTable( (table) => [index("verification_identifier_idx").on(table.identifier)], ); +export const twoFactor = pgTable( + "twoFactor", + { + id: text("id").primaryKey(), + secret: text("secret").notNull(), + backupCodes: text("backup_codes").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + verified: boolean("verified").default(true), + failedVerificationCount: integer("failed_verification_count") + .default(0) + .notNull(), + lockedUntil: timestamp("locked_until"), + }, + (table) => [ + index("twoFactor_secret_idx").on(table.secret), + index("twoFactor_userId_idx").on(table.userId), + ], +); + export const deviceCode = pgTable( "deviceCode", { @@ -187,11 +209,12 @@ export const memberInvitations = pgTable( ], ); -export const userRelations = relations(user, ({ many }) => ({ +export const userRelations = relations(user, ({ many, one }) => ({ sessions: many(session), accounts: many(account), apiKeys: many(apikey), deviceCodes: many(deviceCode), + twoFactor: one(twoFactor), sentMemberInvitations: many(memberInvitations, { relationName: "sentMemberInvitations", }), @@ -214,6 +237,13 @@ export const accountRelations = relations(account, ({ one }) => ({ }), })); +export const twoFactorRelations = relations(twoFactor, ({ one }) => ({ + user: one(user, { + fields: [twoFactor.userId], + references: [user.id], + }), +})); + export const deviceCodeRelations = relations(deviceCode, ({ one }) => ({ user: one(user, { fields: [deviceCode.userId], diff --git a/web/lib/auth-client.ts b/web/lib/auth-client.ts index 87dcaf42..9ec15831 100644 --- a/web/lib/auth-client.ts +++ b/web/lib/auth-client.ts @@ -1,9 +1,32 @@ import { apiKeyClient } from "@better-auth/api-key/client"; -import { deviceAuthorizationClient } from "better-auth/client/plugins"; +import { + deviceAuthorizationClient, + twoFactorClient, +} from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; +import { getSafeAuthRedirect } from "@/lib/two-factor"; + +function getTwoFactorRedirectUrl() { + if (typeof window === "undefined") return "/two-factor"; + + const redirectTo = getSafeAuthRedirect( + new URLSearchParams(window.location.search).get("redirect"), + ); + const target = new URL("/two-factor", window.location.origin); + target.searchParams.set("redirect", redirectTo); + return target.toString(); +} export const authClient = createAuthClient({ - plugins: [apiKeyClient(), deviceAuthorizationClient()], + plugins: [ + apiKeyClient(), + deviceAuthorizationClient(), + twoFactorClient({ + onTwoFactorRedirect: () => { + window.location.href = getTwoFactorRedirectUrl(); + }, + }), + ], }); export const { signIn, signUp, signOut, useSession } = authClient; diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 1e373fc0..73bc27fb 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,7 +1,7 @@ import { apiKey } from "@better-auth/api-key"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; -import { bearer, deviceAuthorization } from "better-auth/plugins"; +import { bearer, deviceAuthorization, twoFactor } from "better-auth/plugins"; import { admin } from "better-auth/plugins/admin"; import { userAc } from "better-auth/plugins/admin/access"; import { headers } from "next/headers"; @@ -11,8 +11,10 @@ import type { MemberRole } from "@/db/types"; import { getUserRole, hasAnyRole } from "@/lib/members"; const TECHULUS_CLI_CLIENT_ID = "techulus-cli"; +const APP_NAME = "Techulus Cloud"; export const auth = betterAuth({ + appName: APP_NAME, database: drizzleAdapter(db, { provider: "pg", schema, @@ -47,6 +49,9 @@ export const auth = betterAuth({ reader: userAc, }, }), + twoFactor({ + issuer: APP_NAME, + }), bearer(), ], }); diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts new file mode 100644 index 00000000..c45e0502 --- /dev/null +++ b/web/lib/two-factor.ts @@ -0,0 +1,39 @@ +export const DEFAULT_AUTH_REDIRECT = "/dashboard"; + +const AUTH_REDIRECT_ORIGIN = "https://auth.local"; + +function hasUnsafeAuthRedirectCharacter(value: string) { + return Array.from(value).some((character) => { + const charCode = character.charCodeAt(0); + return character === "\\" || charCode <= 31 || charCode === 127; + }); +} + +export function getSafeAuthRedirect(value: string | null | undefined) { + if (!value) return DEFAULT_AUTH_REDIRECT; + if ( + value !== value.trim() || + hasUnsafeAuthRedirectCharacter(value) || + !value.startsWith("/") || + value.startsWith("//") + ) { + return DEFAULT_AUTH_REDIRECT; + } + + try { + const url = new URL(value, AUTH_REDIRECT_ORIGIN); + if (url.origin !== AUTH_REDIRECT_ORIGIN) return DEFAULT_AUTH_REDIRECT; + + return `${url.pathname}${url.search}${url.hash}`; + } catch { + return DEFAULT_AUTH_REDIRECT; + } +} + +export function getTotpSecret(totpURI: string) { + try { + return new URL(totpURI).searchParams.get("secret") ?? ""; + } catch { + return ""; + } +} diff --git a/web/package.json b/web/package.json index d62fff4b..c758ff38 100644 --- a/web/package.json +++ b/web/package.json @@ -17,12 +17,12 @@ "dependencies": { "@aws-sdk/client-s3": "^3.968.0", "@base-ui/react": "^1.0.0", - "@better-auth/api-key": "^1.6.19", + "@better-auth/api-key": "1.6.23", "@bprogress/next": "^3.2.12", "@react-email/components": "^1.0.4", "@react-email/render": "^2.0.2", "acme-client": "^5.4.0", - "better-auth": "^1.4.9", + "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cron-parser": "^5.4.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 00a9689c..8daaa418 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: ^1.0.0 version: 1.5.0(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@better-auth/api-key': - specifier: ^1.6.19 - version: 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.6(zod@4.4.3)) + specifier: 1.6.23 + version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3)) '@bprogress/next': specifier: ^3.2.12 version: 3.2.12(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -34,8 +34,8 @@ importers: specifier: ^5.4.0 version: 5.4.0 better-auth: - specifier: ^1.4.9 - version: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + specifier: 1.6.23 + version: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -434,22 +434,22 @@ packages: '@types/react': optional: true - '@better-auth/api-key@1.6.19': - resolution: {integrity: sha512-mSZhMu6tVZ6bxnlDnYL2bdxkEpfI3CeDW7l2tPSdZC12hupTowqgWsZvrWBcgc4t4arYuS2PoXxg7lVTetrgig==} + '@better-auth/api-key@1.6.23': + resolution: {integrity: sha512-HQMTv1GkY5FzE8BlUXdNhuKPBPUvusBYlAtIQElQjfsmGiPiBie7BdzuhcbXhAqJerunU299yvEge2WlZusp+Q==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 - better-auth: ^1.6.19 - better-call: 1.3.6 + better-auth: ^1.6.23 + better-call: 1.3.7 - '@better-auth/core@1.6.19': - resolution: {integrity: sha512-ddE3Y9MoQ8t32QSO5Y8mV7pmnDAv5LdJjX1SPWUH6JUUmuOc7YEy3B5JfXZIJbRaXFdnAitN7pHPVa5u/dYAZA==} + '@better-auth/core@1.6.23': + resolution: {integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==} peerDependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@cloudflare/workers-types': '>=4' '@opentelemetry/api': ^1.9.0 - better-call: 1.3.6 + better-call: 1.3.7 jose: ^6.1.0 kysely: ^0.28.5 || ^0.29.0 nanostores: ^1.0.1 @@ -459,46 +459,46 @@ packages: '@opentelemetry/api': optional: true - '@better-auth/drizzle-adapter@1.6.19': - resolution: {integrity: sha512-57C9ePorPmIEez6dHuQMz3hCTkYim0lfVRIoRtX7PiVfiRFB2bjXseQwrCJfQmkgMFlkp1s/c9nKgAjc2EvAIg==} + '@better-auth/drizzle-adapter@1.6.23': + resolution: {integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 drizzle-orm: ^0.45.2 peerDependenciesMeta: drizzle-orm: optional: true - '@better-auth/kysely-adapter@1.6.19': - resolution: {integrity: sha512-DlmvllEd0nv8JL+plX3JB3WTmqDFnGFOmjmIiUDHo8R3PTAvC0ZaJq3Jk+LQLN5PyVQSUzXZKtvTQYaqRHzBaw==} + '@better-auth/kysely-adapter@1.6.23': + resolution: {integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 kysely: ^0.28.17 || ^0.29.0 peerDependenciesMeta: kysely: optional: true - '@better-auth/memory-adapter@1.6.19': - resolution: {integrity: sha512-cZ8iLRG/T8Oi/CqE9FTHj3z8pIOqRsINi50trWxPNwyY/Eyb7YCljrBi0PuqgIdyVs7BWfrrtEYTpO4ddfuwEw==} + '@better-auth/memory-adapter@1.6.23': + resolution: {integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.19': - resolution: {integrity: sha512-8AReXqhMGiGQIPEpGbmAhh+R4g70TsAVvzwdd6Aj4q+LTSwd3tqC89TFJ4eX8KSplxm9PFBZ6g6gsRmDd7urQg==} + '@better-auth/mongo-adapter@1.6.23': + resolution: {integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 mongodb: ^6.0.0 || ^7.0.0 peerDependenciesMeta: mongodb: optional: true - '@better-auth/prisma-adapter@1.6.19': - resolution: {integrity: sha512-pXZBhR7/bzJb48IUHlGMyz9SM9h1OCO5GIIuHEllJYt8MKgrjtsnXfUkwZh6pAUEIp3WxBEYMMK96bfkqHiWEg==} + '@better-auth/prisma-adapter@1.6.23': + resolution: {integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -508,10 +508,10 @@ packages: prisma: optional: true - '@better-auth/telemetry@1.6.19': - resolution: {integrity: sha512-bBaB6SMIsrD3WutDdm5YQ1bQyinANTimHD8RtpLWhNh/jIXvzgwVVCrDFqA256vcGC/ZRCydmtW8ZrUqNMW9Og==} + '@better-auth/telemetry@1.6.23': + resolution: {integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==} peerDependencies: - '@better-auth/core': ^1.6.19 + '@better-auth/core': ^1.6.23 '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -3008,8 +3008,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - better-auth@1.6.19: - resolution: {integrity: sha512-68eXWKj0sxa0xW4+n4tENd6Co94UCynPKe1fncmO6kIB3XhSXWgwDEpiUouJV2dmLBrHM1FPkoI6Q5597zCGpQ==} + better-auth@1.6.23: + resolution: {integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==} peerDependencies: '@lynx-js/react': '*' '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 @@ -3070,8 +3070,8 @@ packages: vue: optional: true - better-call@1.3.6: - resolution: {integrity: sha512-no1jI+h6Bkxs1NVBo4rONbVIzsPjZ8IUu7IHaJBiFwVX1XEQGN8KpHots5fSWmXe9nNyLuLIcgx6WEUcE6EDaA==} + better-call@1.3.7: + resolution: {integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==} peerDependencies: zod: ^4.0.0 peerDependenciesMeta: @@ -6394,21 +6394,21 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@better-auth/api-key@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.6(zod@4.4.3))': + '@better-auth/api-key@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))))(better-call@1.3.7(zod@4.4.3))': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - better-auth: 1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) - better-call: 1.3.6(zod@4.4.3) + better-auth: 1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))) + better-call: 1.3.7(zod@4.4.3) zod: 4.4.3 - '@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': + '@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@opentelemetry/semantic-conventions': 1.41.1 '@standard-schema/spec': 1.1.0 - better-call: 1.3.6(zod@4.4.3) + better-call: 1.3.7(zod@4.4.3) jose: 6.2.3 kysely: 0.29.2 nanostores: 1.3.0 @@ -6416,38 +6416,38 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/drizzle-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))': + '@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: drizzle-orm: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0) - '@better-auth/kysely-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': + '@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.2 - '@better-auth/memory-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': + '@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + '@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 @@ -8838,20 +8838,20 @@ snapshots: baseline-browser-mapping@2.10.37: {} - better-auth@1.6.19(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))): + better-auth@1.6.23(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(pg@8.21.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))): dependencies: - '@better-auth/core': 1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) - '@better-auth/drizzle-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0)) - '@better-auth/kysely-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) - '@better-auth/memory-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.19(@better-auth/core@1.6.19(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/drizzle-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.20.0)(kysely@0.29.2)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + '@better-auth/memory-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 '@noble/hashes': 2.2.0 - better-call: 1.3.6(zod@4.4.3) + better-call: 1.3.7(zod@4.4.3) defu: 6.1.7 jose: 6.2.3 kysely: 0.29.2 @@ -8869,7 +8869,7 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-call@1.3.6(zod@4.4.3): + better-call@1.3.7(zod@4.4.3): dependencies: '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts new file mode 100644 index 00000000..2a25755f --- /dev/null +++ b/web/tests/two-factor.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_AUTH_REDIRECT, + getSafeAuthRedirect, + getTotpSecret, +} from "@/lib/two-factor"; + +describe("two-factor helpers", () => { + it("keeps internal auth redirects", () => { + expect(getSafeAuthRedirect("/dashboard/projects/demo")).toBe( + "/dashboard/projects/demo", + ); + }); + + it("falls back for missing or external auth redirects", () => { + expect(getSafeAuthRedirect(null)).toBe(DEFAULT_AUTH_REDIRECT); + expect(getSafeAuthRedirect("https://example.com")).toBe( + DEFAULT_AUTH_REDIRECT, + ); + expect(getSafeAuthRedirect("//example.com")).toBe(DEFAULT_AUTH_REDIRECT); + expect(getSafeAuthRedirect("/\\example.com")).toBe(DEFAULT_AUTH_REDIRECT); + expect(getSafeAuthRedirect("/\\/example.com")).toBe(DEFAULT_AUTH_REDIRECT); + expect(getSafeAuthRedirect(" /dashboard")).toBe(DEFAULT_AUTH_REDIRECT); + expect(getSafeAuthRedirect("/dashboard\n")).toBe(DEFAULT_AUTH_REDIRECT); + }); + + it("extracts the secret from a TOTP URI", () => { + expect( + getTotpSecret( + "otpauth://totp/Techulus%20Cloud:agent@example.com?secret=ABC123&issuer=Techulus%20Cloud", + ), + ).toBe("ABC123"); + }); + + it("returns an empty secret for malformed TOTP URIs", () => { + expect(getTotpSecret("not a uri")).toBe(""); + }); +}); From 49bf42303b4e94b7608eb39e14b1e6fea7a771f7 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:08:53 +1000 Subject: [PATCH 02/18] Replace service request logs with metrics --- agent/internal/agent/agent.go | 2 + agent/internal/agent/drift.go | 10 +- agent/internal/agent/reporting.go | 8 + agent/internal/agent/run.go | 65 +++ agent/internal/container/stats.go | 247 +++++++++++ agent/internal/container/stats_test.go | 119 ++++++ agent/internal/metrics/victoria.go | 93 ++++- agent/internal/metrics/victoria_test.go | 41 ++ agent/internal/traefik/static.go | 113 +++++ agent/internal/traefik/static_test.go | 50 +++ .../[id]/{request-stats => metrics}/route.ts | 28 +- .../details/service-details-overview.tsx | 392 +++++++++++++----- web/lib/victoria-logs.ts | 287 ------------- web/lib/victoria-metrics.ts | 362 +++++++++++++++- web/public/setup.sh | 25 ++ web/tests/victoria-logs-request-stats.test.ts | 161 ------- .../victoria-metrics-service-metrics.test.ts | 125 ++++++ 17 files changed, 1554 insertions(+), 574 deletions(-) create mode 100644 agent/internal/container/stats.go create mode 100644 agent/internal/container/stats_test.go create mode 100644 agent/internal/metrics/victoria_test.go create mode 100644 agent/internal/traefik/static_test.go rename web/app/api/services/[id]/{request-stats => metrics}/route.ts (61%) delete mode 100644 web/tests/victoria-logs-request-stats.test.ts create mode 100644 web/tests/victoria-metrics-service-metrics.test.ts diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index 6a3b4f0b..a1a4f7fb 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -127,6 +127,8 @@ func NewAgent( type MetricsSender interface { SendSystemStats(stats *health.SystemStats, collectedAt time.Time) error + SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error + SendPrometheusMetrics(data []byte, extraLabels map[string]string) error } func (a *Agent) GetState() AgentState { diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 6d083884..18216d87 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -575,13 +575,19 @@ func (a *Agent) updateTraefik() error { } needsRestart := false + metricsRestart, err := traefik.EnsureMetricsConfig() + if err != nil { + return fmt.Errorf("failed to ensure Traefik metrics config: %w", err) + } + needsRestart = metricsRestart + if len(tcpPorts) > 0 || len(udpPorts) > 0 { log.Printf("[reconcile] ensuring L4 entry points: %d TCP, %d UDP", len(tcpPorts), len(udpPorts)) - var err error - needsRestart, err = traefik.EnsureEntryPoints(tcpPorts, udpPorts) + entryPointsRestart, err := traefik.EnsureEntryPoints(tcpPorts, udpPorts) if err != nil { return fmt.Errorf("failed to ensure entry points: %w", err) } + needsRestart = needsRestart || entryPointsRestart } log.Printf("[reconcile] updating Traefik routes (HTTP: %d, TCP: %d, UDP: %d)", len(expectedHttpRoutes), len(tcpRoutes), len(udpRoutes)) diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index d83db657..ab4c6574 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -53,6 +53,14 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport if err := a.MetricsSender.SendSystemStats(systemStats, collectedAt); err != nil { log.Printf("[metrics] failed to send system stats: %v", err) } + containerStats, err := container.CollectResourceStats() + if err != nil { + log.Printf("[metrics] failed to collect container stats: %v", err) + return + } + if err := a.MetricsSender.SendContainerStats(containerStats, collectedAt); err != nil { + log.Printf("[metrics] failed to send container stats: %v", err) + } }() } report.NetworkHealth = health.CollectNetworkHealth("wg0") diff --git a/agent/internal/agent/run.go b/agent/internal/agent/run.go index 220a2982..fbf35ca2 100644 --- a/agent/internal/agent/run.go +++ b/agent/internal/agent/run.go @@ -2,13 +2,22 @@ package agent import ( "context" + "fmt" + "io" "log" + "net/http" "time" "techulus/cloud-agent/internal/container" "techulus/cloud-agent/internal/serverless" ) +const ( + traefikMetricsURL = "http://127.0.0.1:9100/metrics" + traefikMetricsInterval = 15 * time.Second + traefikMetricsMaxBytes = 8 * 1024 * 1024 +) + func (a *Agent) Run(ctx context.Context) { if a.Config.RegistryURL != "" && a.Config.RegistryUsername != "" && a.Config.RegistryPassword != "" { if err := container.Login(a.Config.RegistryURL, a.Config.RegistryUsername, a.Config.RegistryPassword, a.Config.RegistryInsecure); err != nil { @@ -33,6 +42,10 @@ func (a *Agent) Run(ctx context.Context) { a.TraefikLogCollector.Start() } + if a.IsProxy && a.MetricsSender != nil { + go a.TraefikMetricsLoop(ctx) + } + if a.IsProxy { gateway := serverless.NewGateway(a) if err := gateway.Start(ctx); err != nil { @@ -81,6 +94,58 @@ func (a *Agent) Run(ctx context.Context) { } } +func (a *Agent) TraefikMetricsLoop(ctx context.Context) { + ticker := time.NewTicker(traefikMetricsInterval) + defer ticker.Stop() + + if err := a.ForwardTraefikMetrics(ctx); err != nil { + log.Printf("[traefik-metrics] initial scrape failed: %v", err) + } + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := a.ForwardTraefikMetrics(ctx); err != nil { + log.Printf("[traefik-metrics] scrape failed: %v", err) + } + } + } +} + +func (a *Agent) ForwardTraefikMetrics(ctx context.Context) error { + if a.MetricsSender == nil { + return nil + } + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, traefikMetricsURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", response.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(response.Body, traefikMetricsMaxBytes)) + if err != nil { + return err + } + + return a.MetricsSender.SendPrometheusMetrics(body, map[string]string{ + "job": "traefik", + "server_id": a.Config.ServerID, + }) +} + func (a *Agent) StatusReportLoop(ctx context.Context) { a.reportStatus("startup") diff --git a/agent/internal/container/stats.go b/agent/internal/container/stats.go new file mode 100644 index 00000000..960a2051 --- /dev/null +++ b/agent/internal/container/stats.go @@ -0,0 +1,247 @@ +package container + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "os/exec" + "strconv" + "strings" + "unicode" +) + +type ResourceStats struct { + ContainerID string + ServiceID string + DeploymentID string + CPUUsagePercent float64 + MemoryUsagePercent float64 + MemoryUsedBytes float64 + NetworkReceiveBytes float64 + NetworkTransmitBytes float64 +} + +func CollectResourceStats() ([]ResourceStats, error) { + containers, err := List() + if err != nil { + return nil, err + } + + running := make([]Container, 0, len(containers)) + args := []string{"stats", "--no-stream", "--format", "json"} + for _, c := range containers { + if c.State != "running" || c.ServiceID == "" || c.DeploymentID == "" { + continue + } + running = append(running, c) + args = append(args, c.ID) + } + if len(running) == 0 { + return nil, nil + } + + cmd := exec.Command("podman", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to collect container stats: %s: %w", stderr.String(), err) + } + + return parsePodmanStatsOutput(output, running) +} + +func parsePodmanStatsOutput(output []byte, containers []Container) ([]ResourceStats, error) { + rows, err := parseStatsRows(output) + if err != nil { + return nil, err + } + + stats := make([]ResourceStats, 0, len(rows)) + for _, row := range rows { + containerID := firstRowString(row, "ID", "Id", "id", "ContainerID", "Container") + container := findStatsContainerByID(containerID, containers) + if container == nil { + name := firstRowString(row, "Name", "Names", "name") + container = findStatsContainerByName(name, containers) + } + if container == nil { + continue + } + + rx, tx := parseNetIO(firstRowString(row, "NetIO", "NetIOBytes", "net_io")) + stats = append(stats, ResourceStats{ + ContainerID: container.ID, + ServiceID: container.ServiceID, + DeploymentID: container.DeploymentID, + CPUUsagePercent: parsePercent(firstRowString(row, "CPUPerc", "CPU", "cpu_percent")), + MemoryUsagePercent: parsePercent(firstRowString(row, "MemPerc", "MEMPerc", "mem_percent")), + MemoryUsedBytes: parseMemUsed(firstRowString(row, "MemUsage", "MemUse", "mem_usage")), + NetworkReceiveBytes: rx, + NetworkTransmitBytes: tx, + }) + } + + return stats, nil +} + +func parseStatsRows(output []byte) ([]map[string]interface{}, error) { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return nil, nil + } + + if strings.HasPrefix(trimmed, "[") { + var rows []map[string]interface{} + if err := json.Unmarshal([]byte(trimmed), &rows); err != nil { + return nil, fmt.Errorf("failed to parse podman stats JSON array: %w", err) + } + return rows, nil + } + + var rows []map[string]interface{} + for _, line := range strings.Split(trimmed, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var row map[string]interface{} + if err := json.Unmarshal([]byte(line), &row); err != nil { + return nil, fmt.Errorf("failed to parse podman stats JSON row: %w", err) + } + rows = append(rows, row) + } + return rows, nil +} + +func findStatsContainerByID(value string, containers []Container) *Container { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + + for i := range containers { + containerID := strings.TrimSpace(containers[i].ID) + if value == containerID { + return &containers[i] + } + if containerID != "" && (strings.HasPrefix(containerID, value) || strings.HasPrefix(value, containerID)) { + return &containers[i] + } + } + return nil +} + +func findStatsContainerByName(value string, containers []Container) *Container { + value = strings.TrimPrefix(strings.TrimSpace(value), "/") + if value == "" { + return nil + } + + for i := range containers { + containerName := strings.TrimPrefix(strings.TrimSpace(containers[i].Name), "/") + if value == containerName { + return &containers[i] + } + } + return nil +} + +func firstRowString(row map[string]interface{}, keys ...string) string { + for _, key := range keys { + value, ok := row[key] + if !ok || value == nil { + continue + } + switch v := value.(type) { + case string: + return v + case []interface{}: + if len(v) > 0 { + return fmt.Sprint(v[0]) + } + default: + return fmt.Sprint(v) + } + } + return "" +} + +func parsePercent(value string) float64 { + value = strings.TrimSpace(strings.TrimSuffix(value, "%")) + if value == "" || value == "--" { + return 0 + } + parsed, err := strconv.ParseFloat(value, 64) + if err != nil || !isFinite(parsed) { + return 0 + } + return parsed +} + +func parseMemUsed(value string) float64 { + parts := strings.Split(value, "/") + if len(parts) == 0 { + return 0 + } + return parseByteQuantity(parts[0]) +} + +func parseNetIO(value string) (float64, float64) { + parts := strings.Split(value, "/") + if len(parts) != 2 { + return 0, 0 + } + return parseByteQuantity(parts[0]), parseByteQuantity(parts[1]) +} + +func parseByteQuantity(value string) float64 { + value = strings.TrimSpace(value) + if value == "" || value == "--" { + return 0 + } + + compact := strings.ReplaceAll(value, " ", "") + splitAt := len(compact) + for i, r := range compact { + if !(unicode.IsDigit(r) || r == '.' || r == '-') { + splitAt = i + break + } + } + + numberText := compact[:splitAt] + unit := strings.ToLower(compact[splitAt:]) + parsed, err := strconv.ParseFloat(numberText, 64) + if err != nil || !isFinite(parsed) { + return 0 + } + + switch unit { + case "", "b": + return parsed + case "kb", "k", "kib", "ki": + return parsed * unitMultiplier(unit, 1) + case "mb", "m", "mib", "mi": + return parsed * unitMultiplier(unit, 2) + case "gb", "g", "gib", "gi": + return parsed * unitMultiplier(unit, 3) + case "tb", "t", "tib", "ti": + return parsed * unitMultiplier(unit, 4) + default: + return parsed + } +} + +func unitMultiplier(unit string, power float64) float64 { + base := 1000.0 + if strings.Contains(unit, "i") { + base = 1024.0 + } + return math.Pow(base, power) +} + +func isFinite(value float64) bool { + return !math.IsNaN(value) && !math.IsInf(value, 0) +} diff --git a/agent/internal/container/stats_test.go b/agent/internal/container/stats_test.go new file mode 100644 index 00000000..1edfd61b --- /dev/null +++ b/agent/internal/container/stats_test.go @@ -0,0 +1,119 @@ +package container + +import "testing" + +func TestParsePodmanStatsOutputArray(t *testing.T) { + containers := []Container{ + { + ID: "abcdef1234567890", + Name: "api", + State: "running", + ServiceID: "svc_1", + DeploymentID: "dep_1", + }, + } + + stats, err := parsePodmanStatsOutput([]byte(`[ + { + "ID": "abcdef123456", + "Name": "api", + "CPUPerc": "12.34%", + "MemUsage": "64MiB / 512MiB", + "MemPerc": "12.50%", + "NetIO": "1.5MB / 2.5MB" + } + ]`), containers) + if err != nil { + t.Fatalf("parse stats: %v", err) + } + if len(stats) != 1 { + t.Fatalf("expected 1 stat, got %d", len(stats)) + } + + stat := stats[0] + if stat.ContainerID != "abcdef1234567890" { + t.Fatalf("container id = %q", stat.ContainerID) + } + if stat.ServiceID != "svc_1" || stat.DeploymentID != "dep_1" { + t.Fatalf("unexpected service/deployment labels: %#v", stat) + } + if stat.CPUUsagePercent != 12.34 { + t.Fatalf("cpu = %f", stat.CPUUsagePercent) + } + if stat.MemoryUsagePercent != 12.5 { + t.Fatalf("memory percent = %f", stat.MemoryUsagePercent) + } + if stat.MemoryUsedBytes != 64*1024*1024 { + t.Fatalf("memory bytes = %f", stat.MemoryUsedBytes) + } + if stat.NetworkReceiveBytes != 1.5*1000*1000 { + t.Fatalf("rx bytes = %f", stat.NetworkReceiveBytes) + } + if stat.NetworkTransmitBytes != 2.5*1000*1000 { + t.Fatalf("tx bytes = %f", stat.NetworkTransmitBytes) + } +} + +func TestParsePodmanStatsOutputJSONLines(t *testing.T) { + containers := []Container{ + {ID: "1234567890abcdef", Name: "worker", State: "running", ServiceID: "svc_2", DeploymentID: "dep_2"}, + } + + stats, err := parsePodmanStatsOutput([]byte(`{"ContainerID":"1234567890","CPUPerc":"0%","MemUsage":"128MB / 1GB","MemPerc":"10%","NetIO":"0B / 32kB"}`), containers) + if err != nil { + t.Fatalf("parse stats: %v", err) + } + if len(stats) != 1 { + t.Fatalf("expected 1 stat, got %d", len(stats)) + } + if stats[0].MemoryUsedBytes != 128*1000*1000 { + t.Fatalf("memory bytes = %f", stats[0].MemoryUsedBytes) + } + if stats[0].NetworkTransmitBytes != 32*1000 { + t.Fatalf("tx bytes = %f", stats[0].NetworkTransmitBytes) + } +} + +func TestParsePodmanStatsOutputNameFallbackDoesNotUseIDPrefix(t *testing.T) { + containers := []Container{ + {ID: "api1234567890", Name: "backend", State: "running", ServiceID: "wrong", DeploymentID: "wrong_dep"}, + {ID: "fedcba987654", Name: "api", State: "running", ServiceID: "svc_3", DeploymentID: "dep_3"}, + } + + stats, err := parsePodmanStatsOutput([]byte(`[ + { + "Name": "api", + "CPUPerc": "7%", + "MemUsage": "1MiB / 128MiB", + "MemPerc": "1%", + "NetIO": "0B / 0B" + } + ]`), containers) + if err != nil { + t.Fatalf("parse stats: %v", err) + } + if len(stats) != 1 { + t.Fatalf("expected 1 stat, got %d", len(stats)) + } + if stats[0].ServiceID != "svc_3" || stats[0].DeploymentID != "dep_3" { + t.Fatalf("unexpected attribution: %#v", stats[0]) + } +} + +func TestParseByteQuantity(t *testing.T) { + tests := map[string]float64{ + "42B": 42, + "1 kB": 1000, + "1KiB": 1024, + "1.5GB": 1.5 * 1000 * 1000 * 1000, + "2 MiB": 2 * 1024 * 1024, + "--": 0, + "broken": 0, + } + + for input, expected := range tests { + if actual := parseByteQuantity(input); actual != expected { + t.Fatalf("%q = %f, want %f", input, actual, expected) + } + } +} diff --git a/agent/internal/metrics/victoria.go b/agent/internal/metrics/victoria.go index 8d90e677..d74fad37 100644 --- a/agent/internal/metrics/victoria.go +++ b/agent/internal/metrics/victoria.go @@ -5,9 +5,11 @@ import ( "fmt" "net/http" "net/url" + "sort" "strings" "time" + "techulus/cloud-agent/internal/container" "techulus/cloud-agent/internal/health" ) @@ -78,8 +80,97 @@ func (v *VictoriaMetricsSender) SendSystemStats(stats *health.SystemStats, colle return nil } +func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error { + if len(stats) == 0 { + return nil + } + + timestampMs := collectedAt.UnixMilli() + serverID := escapeLabelValue(v.serverID) + + var buf bytes.Buffer + for _, stat := range stats { + labels := map[string]string{ + "server_id": serverID, + "service_id": escapeLabelValue(stat.ServiceID), + "deployment_id": escapeLabelValue(stat.DeploymentID), + "container_id": escapeLabelValue(stat.ContainerID), + } + writeGaugeWithLabels(&buf, "techulus_service_cpu_usage_percent", labels, stat.CPUUsagePercent, timestampMs) + writeGaugeWithLabels(&buf, "techulus_service_memory_usage_percent", labels, stat.MemoryUsagePercent, timestampMs) + writeGaugeWithLabels(&buf, "techulus_service_memory_used_bytes", labels, stat.MemoryUsedBytes, timestampMs) + writeGaugeWithLabels(&buf, "techulus_service_network_receive_bytes_total", labels, stat.NetworkReceiveBytes, timestampMs) + writeGaugeWithLabels(&buf, "techulus_service_network_transmit_bytes_total", labels, stat.NetworkTransmitBytes, timestampMs) + } + + return v.postPrometheusImport(buf.Bytes(), nil) +} + +func (v *VictoriaMetricsSender) SendPrometheusMetrics(data []byte, extraLabels map[string]string) error { + if len(bytes.TrimSpace(data)) == 0 { + return nil + } + return v.postPrometheusImport(data, extraLabels) +} + +func (v *VictoriaMetricsSender) postPrometheusImport(data []byte, extraLabels map[string]string) error { + requestURL, err := url.Parse(v.endpoint + "/api/v1/import/prometheus") + if err != nil { + return fmt.Errorf("failed to parse metrics endpoint: %w", err) + } + query := requestURL.Query() + for _, key := range sortedKeys(extraLabels) { + if key == "" || extraLabels[key] == "" { + continue + } + query.Add("extra_label", fmt.Sprintf("%s=%s", key, extraLabels[key])) + } + requestURL.RawQuery = query.Encode() + + req, err := http.NewRequest("POST", requestURL.String(), bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("failed to create metrics request: %w", err) + } + req.Header.Set("Content-Type", "text/plain; version=0.0.4") + if v.username != "" { + req.SetBasicAuth(v.username, v.password) + } + + resp, err := v.client.Do(req) + if err != nil { + return fmt.Errorf("failed to send metrics: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("unexpected metrics status code: %d", resp.StatusCode) + } + + return nil +} + func writeGauge(buf *bytes.Buffer, name, serverID string, value float64, timestampMs int64) { - fmt.Fprintf(buf, "%s{server_id=\"%s\"} %f %d\n", name, serverID, value, timestampMs) + writeGaugeWithLabels(buf, name, map[string]string{"server_id": serverID}, value, timestampMs) +} + +func writeGaugeWithLabels(buf *bytes.Buffer, name string, labels map[string]string, value float64, timestampMs int64) { + fmt.Fprintf(buf, "%s{", name) + for i, key := range sortedKeys(labels) { + if i > 0 { + buf.WriteByte(',') + } + fmt.Fprintf(buf, "%s=\"%s\"", key, labels[key]) + } + fmt.Fprintf(buf, "} %f %d\n", value, timestampMs) +} + +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys } func escapeLabelValue(value string) string { diff --git a/agent/internal/metrics/victoria_test.go b/agent/internal/metrics/victoria_test.go new file mode 100644 index 00000000..5854f197 --- /dev/null +++ b/agent/internal/metrics/victoria_test.go @@ -0,0 +1,41 @@ +package metrics + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) { + var gotPath string + var gotQuery string + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotQuery = r.URL.RawQuery + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + sender := NewVictoriaMetricsSender(server.URL, "server-1") + err := sender.SendPrometheusMetrics([]byte("sample_metric 1\n"), map[string]string{ + "server_id": "server-1", + "job": "traefik", + }) + if err != nil { + t.Fatalf("send prometheus metrics: %v", err) + } + + if gotPath != "/api/v1/import/prometheus" { + t.Fatalf("path = %q", gotPath) + } + if gotQuery != "extra_label=job%3Dtraefik&extra_label=server_id%3Dserver-1" { + t.Fatalf("query = %q", gotQuery) + } + if gotBody != "sample_metric 1\n" { + t.Fatalf("body = %q", gotBody) + } +} diff --git a/agent/internal/traefik/static.go b/agent/internal/traefik/static.go index 3c7702ff..9fa007dd 100644 --- a/agent/internal/traefik/static.go +++ b/agent/internal/traefik/static.go @@ -5,11 +5,35 @@ import ( "log" "os" "os/exec" + "reflect" "time" "gopkg.in/yaml.v3" ) +const ( + metricsEntryPointName = "metrics" + metricsEntryPointAddr = "127.0.0.1:9100" +) + +var prometheusLatencyBuckets = []interface{}{ + 0.005, + 0.01, + 0.025, + 0.05, + 0.075, + 0.1, + 0.25, + 0.5, + 0.75, + 1.0, + 2.5, + 5.0, + 10.0, + 30.0, + 60.0, +} + func validateStaticConfig(data []byte) error { var config map[string]interface{} if err := yaml.Unmarshal(data, &config); err != nil { @@ -112,6 +136,95 @@ func EnsureEntryPoints(tcpPorts []int, udpPorts []int) (needsRestart bool, err e return true, nil } +func EnsureMetricsConfig() (needsRestart bool, err error) { + originalData, err := os.ReadFile(traefikStaticConfigPath) + if err != nil { + return false, fmt.Errorf("failed to read static config: %w", err) + } + + var config map[string]interface{} + if err := yaml.Unmarshal(originalData, &config); err != nil { + return false, fmt.Errorf("failed to parse static config: %w", err) + } + + if !ensurePrometheusMetricsConfig(config) { + return false, nil + } + + newData, err := yaml.Marshal(config) + if err != nil { + return false, fmt.Errorf("failed to marshal static config: %w", err) + } + + if err := validateStaticConfig(newData); err != nil { + return false, fmt.Errorf("config validation failed: %w", err) + } + + if err := atomicWrite(traefikStaticConfigPath, newData, 0644); err != nil { + return false, fmt.Errorf("failed to write static config: %w", err) + } + + log.Printf("[traefik] metrics config updated, restart required") + return true, nil +} + +func ensurePrometheusMetricsConfig(config map[string]interface{}) bool { + modified := false + + entryPoints, ok := config["entryPoints"].(map[string]interface{}) + if !ok { + entryPoints = make(map[string]interface{}) + config["entryPoints"] = entryPoints + modified = true + } + + metricsEntryPoint, ok := entryPoints[metricsEntryPointName].(map[string]interface{}) + if !ok { + metricsEntryPoint = make(map[string]interface{}) + entryPoints[metricsEntryPointName] = metricsEntryPoint + modified = true + } + if setMapValue(metricsEntryPoint, "address", metricsEntryPointAddr) { + modified = true + } + + metricsConfig, ok := config["metrics"].(map[string]interface{}) + if !ok { + metricsConfig = make(map[string]interface{}) + config["metrics"] = metricsConfig + modified = true + } + + prometheusConfig, ok := metricsConfig["prometheus"].(map[string]interface{}) + if !ok { + prometheusConfig = make(map[string]interface{}) + metricsConfig["prometheus"] = prometheusConfig + modified = true + } + + for key, value := range map[string]interface{}{ + "entryPoint": metricsEntryPointName, + "addEntryPointsLabels": false, + "addRoutersLabels": false, + "addServicesLabels": true, + "buckets": prometheusLatencyBuckets, + } { + if setMapValue(prometheusConfig, key, value) { + modified = true + } + } + + return modified +} + +func setMapValue(values map[string]interface{}, key string, value interface{}) bool { + if reflect.DeepEqual(values[key], value) { + return false + } + values[key] = value + return true +} + func ReloadTraefik() error { cmd := exec.Command("systemctl", "restart", "traefik") if err := cmd.Run(); err != nil { diff --git a/agent/internal/traefik/static_test.go b/agent/internal/traefik/static_test.go new file mode 100644 index 00000000..7f2b47af --- /dev/null +++ b/agent/internal/traefik/static_test.go @@ -0,0 +1,50 @@ +package traefik + +import "testing" + +func TestEnsurePrometheusMetricsConfigAddsPrivateMetricsEndpoint(t *testing.T) { + config := map[string]interface{}{ + "entryPoints": map[string]interface{}{ + "web": map[string]interface{}{"address": ":80"}, + }, + } + + if !ensurePrometheusMetricsConfig(config) { + t.Fatal("expected config to be modified") + } + + entryPoints := config["entryPoints"].(map[string]interface{}) + metricsEntryPoint := entryPoints["metrics"].(map[string]interface{}) + if metricsEntryPoint["address"] != "127.0.0.1:9100" { + t.Fatalf("metrics address = %#v", metricsEntryPoint["address"]) + } + + metricsConfig := config["metrics"].(map[string]interface{}) + prometheusConfig := metricsConfig["prometheus"].(map[string]interface{}) + if prometheusConfig["entryPoint"] != "metrics" { + t.Fatalf("entryPoint = %#v", prometheusConfig["entryPoint"]) + } + if prometheusConfig["addServicesLabels"] != true { + t.Fatalf("addServicesLabels = %#v", prometheusConfig["addServicesLabels"]) + } + if prometheusConfig["addRoutersLabels"] != false { + t.Fatalf("addRoutersLabels = %#v", prometheusConfig["addRoutersLabels"]) + } + if prometheusConfig["addEntryPointsLabels"] != false { + t.Fatalf("addEntryPointsLabels = %#v", prometheusConfig["addEntryPointsLabels"]) + } + if len(prometheusConfig["buckets"].([]interface{})) == 0 { + t.Fatal("expected latency buckets") + } +} + +func TestEnsurePrometheusMetricsConfigIsStable(t *testing.T) { + config := map[string]interface{}{} + + if !ensurePrometheusMetricsConfig(config) { + t.Fatal("expected first call to modify config") + } + if ensurePrometheusMetricsConfig(config) { + t.Fatal("expected second call to be stable") + } +} diff --git a/web/app/api/services/[id]/request-stats/route.ts b/web/app/api/services/[id]/metrics/route.ts similarity index 61% rename from web/app/api/services/[id]/request-stats/route.ts rename to web/app/api/services/[id]/metrics/route.ts index 3b25c5be..1d8cc791 100644 --- a/web/app/api/services/[id]/request-stats/route.ts +++ b/web/app/api/services/[id]/metrics/route.ts @@ -2,11 +2,12 @@ import { headers } from "next/headers"; import { getService } from "@/db/queries"; import { auth } from "@/lib/auth"; import { - createEmptyHttpRequestStats, - isLoggingEnabled, - parseRequestStatsRange, - queryHttpRequestStats, -} from "@/lib/victoria-logs"; + createEmptyServiceMetrics, + isMetricsEnabled, + parseMetricRange, + queryServiceMetrics, + warnMissingMetricsConfig, +} from "@/lib/victoria-metrics"; const SERVICE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; @@ -25,7 +26,7 @@ export async function GET( const { id: serviceId } = await params; const url = new URL(request.url); - const range = parseRequestStatsRange(url.searchParams.get("range")); + const range = parseMetricRange(url.searchParams.get("range")); if (!SERVICE_ID_PATTERN.test(serviceId)) { return Response.json({ message: "Invalid service id" }, { status: 400 }); @@ -36,20 +37,17 @@ export async function GET( return Response.json({ message: "Service not found" }, { status: 404 }); } - if (!isLoggingEnabled()) { - return Response.json({ - loggingEnabled: false, - ...createEmptyHttpRequestStats(range), - }); + if (!isMetricsEnabled()) { + warnMissingMetricsConfig("service"); + return Response.json(createEmptyServiceMetrics(range)); } try { - const stats = await queryHttpRequestStats({ serviceId, range }); - return Response.json({ loggingEnabled: true, ...stats }); + return Response.json(await queryServiceMetrics({ serviceId, range })); } catch (error) { - console.error("[logs:request-stats] failed to query HTTP stats:", error); + console.error("[metrics:service] failed to query service metrics:", error); return Response.json( - { message: "Request stats unavailable" }, + { message: "Service metrics unavailable" }, { status: 502 }, ); } diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index e385f341..1d1f30ce 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -28,8 +28,8 @@ import { isObservedStarting } from "@/lib/deployment-status"; import { fetcher } from "@/lib/fetcher"; import { cn } from "@/lib/utils"; -type RequestStatsResponse = { - loggingEnabled: boolean; +type ServiceMetricsResponse = { + metricsEnabled: boolean; range: string; windowStart: string; windowEnd: string; @@ -40,6 +40,15 @@ type RequestStatsResponse = { timestamp: string; totalRequests: number; statuses: Record; + p50ResponseTimeMs: number | null; + p90ResponseTimeMs: number | null; + p95ResponseTimeMs: number | null; + p99ResponseTimeMs: number | null; + ingressBytesPerSecond: number | null; + egressBytesPerSecond: number | null; + cpuUsagePercent: number | null; + memoryUsagePercent: number | null; + memoryUsedBytes: number | null; }>; }; @@ -85,20 +94,18 @@ type ServiceStatus = { type ChartRow = { timestamp: string; totalRequests: number; -} & Record; +} & Record; -type RequestChartMode = "rate" | "total"; +type ServiceChartMode = "requests" | "latency" | "traffic" | "resources"; type StatusSeries = { status: string; - rateDataKey: string; totalDataKey: string; color: string; - averageRequestsPerSecond: number; totalRequests: number; }; -type RequestTooltipPayload = { +type ServiceMetricsTooltipPayload = { name?: string; value?: unknown; color?: string; @@ -106,11 +113,18 @@ type RequestTooltipPayload = { payload?: ChartRow; }; -type RequestTooltipProps = { +type ServiceMetricsTooltipProps = { active?: boolean; label?: string | number; - mode: RequestChartMode; - payload?: readonly RequestTooltipPayload[]; + mode: ServiceChartMode; + payload?: readonly ServiceMetricsTooltipPayload[]; +}; + +type MetricSeries = { + key: string; + label: string; + color: string; + valueFormatter: (value: number) => string; }; const STATUS_TONE_CLASSES: Record< @@ -146,26 +160,27 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { () => buildOverviewData(service, proxyDomain), [service, proxyDomain], ); - const requestStatsUrl = - overview.publicHttpCount > 0 - ? `/api/services/${service.id}/request-stats?range=week` + const serviceMetricsUrl = + overview.runningDeployments > 0 + ? `/api/services/${service.id}/metrics?range=24h` : null; const { - data: requestStats, - error: requestStatsError, - isLoading: isRequestStatsLoading, - } = useSWR(requestStatsUrl, fetcher, { + data: serviceMetrics, + error: serviceMetricsError, + isLoading: isServiceMetricsLoading, + } = useSWR(serviceMetricsUrl, fetcher, { refreshInterval: 60000, }); return (
- 0} - stats={requestStats} - error={requestStatsError} - isLoading={isRequestStatsLoading} + hasRunningDeployments={overview.runningDeployments > 0} + stats={serviceMetrics} + error={serviceMetricsError} + isLoading={isServiceMetricsLoading} /> @@ -174,23 +189,33 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { ); } -function RequestStatsPanel({ +function ServiceMetricsPanel({ hasPublicHttp, + hasRunningDeployments, stats, error, isLoading, }: { hasPublicHttp: boolean; - stats?: RequestStatsResponse; + hasRunningDeployments: boolean; + stats?: ServiceMetricsResponse; error?: unknown; isLoading: boolean; }) { - const [chartMode, setChartMode] = useState("total"); + const [chartMode, setChartMode] = useState("requests"); const chartRows = useMemo(() => buildChartRows(stats), [stats]); const statusSeries = useMemo(() => buildStatusSeries(stats), [stats]); - const hasChartData = chartRows.some((row) => row.totalRequests > 0); - const isUnavailable = Boolean(error) || stats?.loggingEnabled === false; - const hasMetricData = hasPublicHttp && stats && !isUnavailable; + const activeSeries = useMemo( + () => buildMetricSeries(chartMode, statusSeries), + [chartMode, statusSeries], + ); + const hasChartData = hasMetricDataForMode(chartRows, chartMode, activeSeries); + const isUnavailable = Boolean(error) || stats?.metricsEnabled === false; + const hasMetricData = stats && !isUnavailable; + const p95 = getLatestValue(chartRows, "p95ResponseTimeMs"); + const egress = getLatestValue(chartRows, "egressBytesPerSecond"); + const cpu = getLatestValue(chartRows, "cpuUsagePercent"); + const memory = getLatestValue(chartRows, "memoryUsedBytes"); return (
@@ -201,7 +226,7 @@ function RequestStatsPanel({
- ) : hasPublicHttp ? ( + ) : hasRunningDeployments ? (

@@ -209,9 +234,7 @@ function RequestStatsPanel({ ? formatCompactNumber(stats.totalRequests) : "-"}

-

- requests this week -

+

requests in 24h

@@ -219,9 +242,33 @@ function RequestStatsPanel({ ? formatRate(getAverageRequestsPerSecond(stats)) : "-"}

-

- avg RPS this week +

avg RPS

+
+
+

+ {hasMetricData && p95 != null ? formatDurationMs(p95) : "-"} +

+

p95 latency

+
+
+

+ {hasMetricData && cpu != null ? `${formatRate(cpu)}%` : "-"}

+

CPU

+
+
+

+ {hasMetricData && memory != null ? formatBytes(memory) : "-"} +

+

memory

+
+
+

+ {hasMetricData && egress != null + ? `${formatBytes(egress)}/s` + : "-"} +

+

egress

) : ( @@ -229,33 +276,39 @@ function RequestStatsPanel({

-

-

- no public HTTP ingress -

+

not deployed

)}

{formatToday()}

-
- {!hasPublicHttp ? ( - + {!hasRunningDeployments ? ( + ) : isLoading ? ( ) : isUnavailable ? ( - + ) : !hasChartData ? ( - + ) : ( @@ -289,30 +342,26 @@ function RequestStatsPanel({ + formatAxisTick(Number(value), chartMode) } className="text-xs" /> ( - )} /> - {statusSeries.map((series) => ( + {activeSeries.map((series) => ( - {statusSeries.length > 0 && ( + {activeSeries.length > 0 && (
- {statusSeries.map((series) => ( + {activeSeries.map((series) => ( ))}
@@ -349,18 +390,20 @@ function RequestStatsPanel({ ); } -function RequestChartModeToggle({ +function ServiceChartModeToggle({ value, onChange, disabled, }: { - value: RequestChartMode; - onChange: (value: RequestChartMode) => void; + value: ServiceChartMode; + onChange: (value: ServiceChartMode) => void; disabled: boolean; }) { - const options: Array<{ value: RequestChartMode; label: string }> = [ - { value: "total", label: "Total" }, - { value: "rate", label: "RPS" }, + const options: Array<{ value: ServiceChartMode; label: string }> = [ + { value: "requests", label: "Requests" }, + { value: "latency", label: "Latency" }, + { value: "traffic", label: "Traffic" }, + { value: "resources", label: "Resources" }, ]; return ( @@ -616,7 +659,7 @@ function LegendMetric({ ); } -function RequestStatsState({ message }: { message: string }) { +function ServiceMetricsState({ message }: { message: string }) { return (
{message} @@ -624,16 +667,18 @@ function RequestStatsState({ message }: { message: string }) { ); } -function RequestStatsTooltip({ +function ServiceMetricsTooltip({ active, payload, label, mode, -}: RequestTooltipProps) { +}: ServiceMetricsTooltipProps) { if (!active || !payload?.length) return null; const row = payload[0]?.payload; - const visiblePayload = payload.filter((item) => Number(item.value) > 0); + const visiblePayload = payload.filter( + (item) => item.value != null && Number.isFinite(Number(item.value)), + ); const items = visiblePayload.length > 0 ? visiblePayload : payload; return ( @@ -824,28 +869,34 @@ function getSourceInfo(service: Service): SourceInfo { }; } -function buildChartRows(stats?: RequestStatsResponse): ChartRow[] { +function buildChartRows(stats?: ServiceMetricsResponse): ChartRow[] { if (!stats) return []; return stats.buckets.map((bucket) => { const row: ChartRow = { timestamp: bucket.timestamp, totalRequests: bucket.totalRequests, + p50ResponseTimeMs: bucket.p50ResponseTimeMs, + p90ResponseTimeMs: bucket.p90ResponseTimeMs, + p95ResponseTimeMs: bucket.p95ResponseTimeMs, + p99ResponseTimeMs: bucket.p99ResponseTimeMs, + ingressBytesPerSecond: bucket.ingressBytesPerSecond, + egressBytesPerSecond: bucket.egressBytesPerSecond, + cpuUsagePercent: bucket.cpuUsagePercent, + memoryUsagePercent: bucket.memoryUsagePercent, + memoryUsedBytes: bucket.memoryUsedBytes, }; for (const status of stats.statusCodes) { const requests = bucket.statuses[status] ?? 0; - row[getStatusRateDataKey(status)] = requests / stats.stepSeconds; - row[getStatusTotalDataKey(status)] = requests; + row[getStatusDataKey(status)] = requests; } return row; }); } -function buildStatusSeries(stats?: RequestStatsResponse): StatusSeries[] { +function buildStatusSeries(stats?: ServiceMetricsResponse): StatusSeries[] { if (!stats) return []; - const totalSeconds = getStatsDurationSeconds(stats); - return stats.statusCodes.map((status, index) => { const totalRequests = stats.buckets.reduce( (total, bucket) => total + (bucket.statuses[status] ?? 0), @@ -854,21 +905,14 @@ function buildStatusSeries(stats?: RequestStatsResponse): StatusSeries[] { return { status, - rateDataKey: getStatusRateDataKey(status), - totalDataKey: getStatusTotalDataKey(status), + totalDataKey: getStatusDataKey(status), color: getStatusColor(status, index), - averageRequestsPerSecond: - totalSeconds > 0 ? totalRequests / totalSeconds : 0, totalRequests, }; }); } -function getStatusRateDataKey(status: string): string { - return `${getStatusDataKeyBase(status)}_rate`; -} - -function getStatusTotalDataKey(status: string): string { +function getStatusDataKey(status: string): string { return `${getStatusDataKeyBase(status)}_total`; } @@ -977,7 +1021,116 @@ function formatRate(value: number): string { return value.toFixed(2).replace(/\.?0+$/, ""); } -function getAverageRequestsPerSecond(stats: RequestStatsResponse): number { +function buildMetricSeries( + mode: ServiceChartMode, + statusSeries: StatusSeries[], +): MetricSeries[] { + if (mode === "requests") { + return statusSeries.map((series) => ({ + key: series.totalDataKey, + label: series.status, + color: series.color, + valueFormatter: formatRequestCount, + })); + } + + if (mode === "latency") { + return [ + { + key: "p99ResponseTimeMs", + label: "p99", + color: "#ef4444", + valueFormatter: formatDurationMs, + }, + { + key: "p95ResponseTimeMs", + label: "p95", + color: "#ec4899", + valueFormatter: formatDurationMs, + }, + { + key: "p90ResponseTimeMs", + label: "p90", + color: "#f59e0b", + valueFormatter: formatDurationMs, + }, + { + key: "p50ResponseTimeMs", + label: "p50", + color: "#3b82f6", + valueFormatter: formatDurationMs, + }, + ]; + } + + if (mode === "traffic") { + return [ + { + key: "ingressBytesPerSecond", + label: "Ingress", + color: "#0ea5e9", + valueFormatter: (value) => `${formatBytes(value)}/s`, + }, + { + key: "egressBytesPerSecond", + label: "Egress", + color: "#10b981", + valueFormatter: (value) => `${formatBytes(value)}/s`, + }, + ]; + } + + return [ + { + key: "cpuUsagePercent", + label: "CPU", + color: "#8b5cf6", + valueFormatter: (value) => `${formatRate(value)}%`, + }, + { + key: "memoryUsagePercent", + label: "Memory", + color: "#14b8a6", + valueFormatter: (value) => `${formatRate(value)}%`, + }, + ]; +} + +function hasMetricDataForMode( + rows: ChartRow[], + mode: ServiceChartMode, + series: MetricSeries[], +): boolean { + if (mode === "requests") { + return rows.some((row) => row.totalRequests > 0); + } + return rows.some((row) => + series.some((item) => { + const value = row[item.key]; + return typeof value === "number" && Number.isFinite(value); + }), + ); +} + +function getLatestValue(rows: ChartRow[], key: string): number | null { + for (let index = rows.length - 1; index >= 0; index--) { + const value = rows[index][key]; + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + } + return null; +} + +function formatLatestSeriesValue( + rows: ChartRow[], + series: MetricSeries, +): string { + const value = getLatestValue(rows, series.key); + return value == null ? "-" : series.valueFormatter(value); +} + +function getAverageRequestsPerSecond(stats: ServiceMetricsResponse): number { const totalSeconds = getStatsDurationSeconds(stats); if (totalSeconds <= 0) return 0; @@ -985,7 +1138,7 @@ function getAverageRequestsPerSecond(stats: RequestStatsResponse): number { return stats.totalRequests / totalSeconds; } -function getStatsDurationSeconds(stats: RequestStatsResponse): number { +function getStatsDurationSeconds(stats: ServiceMetricsResponse): number { const windowStart = new Date(stats.windowStart).getTime(); const windowEnd = new Date(stats.windowEnd).getTime(); @@ -1000,18 +1153,34 @@ function getStatsDurationSeconds(stats: RequestStatsResponse): number { return stats.buckets.length * stats.stepSeconds; } -function formatChartValue(value: number, mode: RequestChartMode): string { - if (mode === "rate") return `${formatRate(value)}/s`; - +function formatChartValue(value: number, mode: ServiceChartMode): string { + if (!Number.isFinite(value)) return "-"; + if (mode === "latency") return formatDurationMs(value); + if (mode === "traffic") return `${formatBytes(value)}/s`; + if (mode === "resources") return `${formatRate(value)}%`; return formatRequestCount(value); } +function formatAxisTick(value: number, mode: ServiceChartMode): string { + if (mode === "latency") return formatDurationMs(value); + if (mode === "traffic") return formatBytes(value); + if (mode === "resources") return `${formatRateTick(value)}%`; + return formatRequestTick(value); +} + function formatRateTick(value: number): string { if (value >= 100) return value.toFixed(0); if (value >= 10) return value.toFixed(0); return value.toFixed(1); } +function getYAxisMargin(mode: ServiceChartMode): number { + if (mode === "traffic") return -4; + if (mode === "latency") return -12; + if (mode === "resources") return -24; + return -20; +} + function formatRequestTick(value: number): string { return formatCompactNumber(value); } @@ -1024,6 +1193,29 @@ function formatRequestCount(value: number): string { }).format(value); } +function formatDurationMs(value: number): string { + if (!Number.isFinite(value)) return "-"; + if (value >= 1000) { + return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}s`; + } + if (value >= 100) return `${value.toFixed(0)}ms`; + if (value >= 10) return `${value.toFixed(1)}ms`; + return `${value.toFixed(2).replace(/\.?0+$/, "")}ms`; +} + +function formatBytes(value: number): string { + if (!Number.isFinite(value)) return "-"; + const units = ["B", "KB", "MB", "GB", "TB"]; + let unitIndex = 0; + let scaled = Math.max(0, value); + while (scaled >= 1000 && unitIndex < units.length - 1) { + scaled /= 1000; + unitIndex++; + } + const maximumFractionDigits = scaled >= 100 || unitIndex === 0 ? 0 : 1; + return `${scaled.toFixed(maximumFractionDigits)} ${units[unitIndex]}`; +} + function formatShortDate(value: string): string { return new Intl.DateTimeFormat(undefined, { month: "short", diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 32484641..9540568d 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -1,35 +1,6 @@ const VICTORIA_LOGS_URL = process.env.VICTORIA_LOGS_URL; const VICTORIA_LOGS_PRIVATE_URL = process.env.VICTORIA_LOGS_PRIVATE_URL; -export const REQUEST_STATS_RANGE_OPTIONS = { - "1h": { durationMs: 60 * 60 * 1000, stepSeconds: 60, stepLogSql: "1m" }, - "6h": { - durationMs: 6 * 60 * 60 * 1000, - stepSeconds: 5 * 60, - stepLogSql: "5m", - }, - "24h": { - durationMs: 24 * 60 * 60 * 1000, - stepSeconds: 5 * 60, - stepLogSql: "5m", - }, - "7d": { - durationMs: 7 * 24 * 60 * 60 * 1000, - stepSeconds: 30 * 60, - stepLogSql: "30m", - }, - week: { - durationMs: 0, - stepSeconds: 30 * 60, - stepLogSql: "30m", - weekToDate: true, - }, -} as const; - -export type RequestStatsRange = keyof typeof REQUEST_STATS_RANGE_OPTIONS; - -export const DEFAULT_REQUEST_STATS_RANGE: RequestStatsRange = "7d"; - type EndpointConfig = { url: string; username?: string; @@ -84,260 +55,6 @@ export function isLoggingEnabled(): boolean { return !!(VICTORIA_LOGS_PRIVATE_URL || VICTORIA_LOGS_URL); } -export type HttpRequestStatsBucket = { - timestamp: string; - totalRequests: number; - statuses: Record; -}; - -export type HttpRequestStats = { - range: RequestStatsRange; - windowStart: string; - windowEnd: string; - stepSeconds: number; - totalRequests: number; - statusCodes: string[]; - buckets: HttpRequestStatsBucket[]; -}; - -type RequestStatsRow = Record; - -export function parseRequestStatsRange( - value: string | null | undefined, -): RequestStatsRange { - if (value && value in REQUEST_STATS_RANGE_OPTIONS) { - return value as RequestStatsRange; - } - return DEFAULT_REQUEST_STATS_RANGE; -} - -export function createEmptyHttpRequestStats( - range: RequestStatsRange = DEFAULT_REQUEST_STATS_RANGE, - now = new Date(), -): HttpRequestStats { - const window = getRequestStatsWindow(range, now); - return { - range, - windowStart: window.start.toISOString(), - windowEnd: window.end.toISOString(), - stepSeconds: window.stepSeconds, - totalRequests: 0, - statusCodes: [], - buckets: [], - }; -} - -export function getRequestStatsWindow( - range: RequestStatsRange, - now = new Date(), -): { - start: Date; - end: Date; - durationMs: number; - stepSeconds: number; - stepLogSql: string; -} { - const config = REQUEST_STATS_RANGE_OPTIONS[range]; - const start = - "weekToDate" in config && config.weekToDate - ? getStartOfUtcWeek(now) - : new Date(now.getTime() - config.durationMs); - - return { - start, - end: now, - durationMs: Math.max(0, now.getTime() - start.getTime()), - stepSeconds: config.stepSeconds, - stepLogSql: config.stepLogSql, - }; -} - -export function parseRequestStatsRows(text: string): RequestStatsRow[] { - const lines = text.trim().split("\n").filter(Boolean); - const rows: RequestStatsRow[] = []; - - for (const line of lines) { - try { - const parsed = JSON.parse(line) as unknown; - if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - rows.push(parsed as RequestStatsRow); - } - } catch { - // VictoriaLogs should return JSON lines, but a single malformed row should - // not break the service overview card. - } - } - - return rows; -} - -export function buildRequestStatsBuckets( - rows: RequestStatsRow[], - options: { start: Date; end: Date; stepSeconds: number }, -): HttpRequestStatsBucket[] { - const stepMs = options.stepSeconds * 1000; - const startMs = floorToStep(options.start.getTime(), stepMs); - const endMs = floorToStep(options.end.getTime(), stepMs); - - if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || startMs > endMs) { - return []; - } - - const byBucket = new Map< - number, - { totalRequests: number; statuses: Record } - >(); - for (const row of rows) { - const timestamp = parseTimestamp(row._time ?? row.time ?? row.timestamp); - if (!timestamp) continue; - - const bucketMs = floorToStep(timestamp.getTime(), stepMs); - const bucket = byBucket.get(bucketMs) ?? { - totalRequests: 0, - statuses: {}, - }; - const requests = parseStatNumber(row.requests); - const status = normalizeStatus(row.status); - bucket.totalRequests += requests; - bucket.statuses[status] = (bucket.statuses[status] ?? 0) + requests; - byBucket.set(bucketMs, bucket); - } - - const buckets: HttpRequestStatsBucket[] = []; - for (let timestampMs = startMs; timestampMs <= endMs; timestampMs += stepMs) { - const bucket = byBucket.get(timestampMs) ?? { - totalRequests: 0, - statuses: {}, - }; - buckets.push({ - timestamp: new Date(timestampMs).toISOString(), - totalRequests: bucket.totalRequests, - statuses: bucket.statuses, - }); - } - - return buckets; -} - -export async function queryHttpRequestStats(options: { - serviceId: string; - range: RequestStatsRange; - now?: Date; -}): Promise { - const now = options.now ?? new Date(); - const window = getRequestStatsWindow(options.range, now); - const statusCodeLimit = 64; - const bucketLimit = - (Math.ceil(window.durationMs / (window.stepSeconds * 1000)) + 5) * - statusCodeLimit; - const baseFilter = [ - formatLogSqlExactFilter("service_id", options.serviceId), - "log_type:http", - formatLogSqlTimeRange(window.start, window.end), - ].join(" "); - const bucketQuery = `${baseFilter} | stats by (_time:${window.stepLogSql}, status) count() as requests | sort by (_time)`; - - const bucketText = await queryLogSql(bucketQuery, bucketLimit); - - const buckets = buildRequestStatsBuckets(parseRequestStatsRows(bucketText), { - start: window.start, - end: window.end, - stepSeconds: window.stepSeconds, - }); - const statusCodes = sortStatusCodes([ - ...buckets.flatMap((bucket) => Object.keys(bucket.statuses)), - ]); - - return { - range: options.range, - windowStart: window.start.toISOString(), - windowEnd: window.end.toISOString(), - stepSeconds: window.stepSeconds, - totalRequests: buckets.reduce( - (sum, bucket) => sum + bucket.totalRequests, - 0, - ), - statusCodes, - buckets, - }; -} - -async function queryLogSql(query: string, limit: number): Promise { - const endpoint = getQueryEndpoint(); - if (!endpoint) { - throw new Error("VICTORIA_LOGS_URL is not configured"); - } - - const url = new URL(`${endpoint.url}/select/logsql/query`); - url.searchParams.set("query", query); - url.searchParams.set("limit", String(limit)); - - const response = await fetch(url.toString(), buildFetchOptions(endpoint)); - - if (!response.ok) { - throw new Error( - `Failed to query logs: ${response.status} ${response.statusText}`, - ); - } - - return response.text(); -} - -function parseStatNumber(value: unknown): number { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string" && value.trim() !== "") { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : 0; - } - return 0; -} - -function normalizeStatus(value: unknown): string { - if (typeof value === "number" && Number.isFinite(value)) { - return String(value); - } - if (typeof value === "string" && value.trim() !== "") { - return value.trim(); - } - return "unknown"; -} - -function sortStatusCodes(statuses: string[]): string[] { - return Array.from(new Set(statuses)).sort((a, b) => { - const statusA = Number(a); - const statusB = Number(b); - if (Number.isFinite(statusA) && Number.isFinite(statusB)) { - return statusA - statusB; - } - return a.localeCompare(b); - }); -} - -function parseTimestamp(value: unknown): Date | null { - if (typeof value !== "string" && typeof value !== "number") { - return null; - } - - const timestamp = new Date(value); - return Number.isFinite(timestamp.getTime()) ? timestamp : null; -} - -function floorToStep(timestampMs: number, stepMs: number): number { - return Math.floor(timestampMs / stepMs) * stepMs; -} - -function getStartOfUtcWeek(value: Date): Date { - const start = new Date( - Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), value.getUTCDate()), - ); - const day = start.getUTCDay(); - const daysSinceMonday = day === 0 ? 6 : day - 1; - start.setUTCDate(start.getUTCDate() - daysSinceMonday); - return start; -} - function formatLogSqlExactFilter(field: string, value: string): string { const trimmed = value.trim(); if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { @@ -347,10 +64,6 @@ function formatLogSqlExactFilter(field: string, value: string): string { return `${field}:${trimmed}`; } -function formatLogSqlTimeRange(start: Date, end: Date): string { - return `_time:[${start.toISOString()}, ${end.toISOString()})`; -} - type QueryLogsByServiceOptions = { serviceId: string; limit: number; diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index 23984e7f..0fb62f7d 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -6,8 +6,6 @@ import { export { METRIC_RANGE_OPTIONS, type MetricRange, parseMetricRange }; -const VICTORIA_METRICS_URL = process.env.VICTORIA_METRICS_URL; -const VICTORIA_METRICS_PRIVATE_URL = process.env.VICTORIA_METRICS_PRIVATE_URL; let hasWarnedMissingMetricsConfig = false; type EndpointConfig = { @@ -27,13 +25,15 @@ type VictoriaInstantResponse = { error?: string; }; +type VictoriaMatrixResult = { + metric: Record; + values: Array<[number, string]>; +}; + type VictoriaMatrixResponse = { status: string; data?: { - result?: Array<{ - metric: Record; - values: Array<[number, string]>; - }>; + result?: VictoriaMatrixResult[]; }; error?: string; }; @@ -51,6 +51,32 @@ export type NodeMetricPoint = { value: number; }; +export type ServiceMetricsBucket = { + timestamp: string; + totalRequests: number; + statuses: Record; + p50ResponseTimeMs: number | null; + p90ResponseTimeMs: number | null; + p95ResponseTimeMs: number | null; + p99ResponseTimeMs: number | null; + ingressBytesPerSecond: number | null; + egressBytesPerSecond: number | null; + cpuUsagePercent: number | null; + memoryUsagePercent: number | null; + memoryUsedBytes: number | null; +}; + +export type ServiceMetrics = { + metricsEnabled: boolean; + range: MetricRange; + windowStart: string; + windowEnd: string; + stepSeconds: number; + totalRequests: number; + statusCodes: string[]; + buckets: ServiceMetricsBucket[]; +}; + export type NodeMetricsHistory = { cpuUsagePercent: NodeMetricPoint[]; memoryUsagePercent: NodeMetricPoint[]; @@ -85,7 +111,9 @@ function parseEndpoint(endpoint: string): EndpointConfig { } function getQueryEndpoint(): EndpointConfig | undefined { - const endpoint = VICTORIA_METRICS_PRIVATE_URL || VICTORIA_METRICS_URL; + const endpoint = + process.env.VICTORIA_METRICS_PRIVATE_URL || + process.env.VICTORIA_METRICS_URL; if (!endpoint) return undefined; return parseEndpoint(endpoint); } @@ -101,7 +129,9 @@ function buildFetchOptions(config: EndpointConfig): RequestInit { } export function isMetricsEnabled(): boolean { - return !!(VICTORIA_METRICS_PRIVATE_URL || VICTORIA_METRICS_URL); + return !!( + process.env.VICTORIA_METRICS_PRIVATE_URL || process.env.VICTORIA_METRICS_URL + ); } export function warnMissingMetricsConfig(context: string) { @@ -262,6 +292,180 @@ export async function queryServersMetricsHistory(options: { })); } +export function createEmptyServiceMetrics( + range: MetricRange, + now = new Date(), +): ServiceMetrics { + const window = getMetricWindow(range, now); + return { + metricsEnabled: false, + range, + windowStart: window.start.toISOString(), + windowEnd: window.end.toISOString(), + stepSeconds: window.stepSeconds, + totalRequests: 0, + statusCodes: [], + buckets: [], + }; +} + +export async function queryServiceMetrics(options: { + serviceId: string; + range: MetricRange; + now?: Date; +}): Promise { + const endpoint = getQueryEndpoint(); + const now = options.now ?? new Date(); + const window = getMetricWindow(options.range, now); + if (!endpoint) return createEmptyServiceMetrics(options.range, now); + + const serviceMatcher = buildTraefikServiceMatcher(options.serviceId); + const serviceId = escapePromQL(options.serviceId); + const rangeWindow = formatPromDuration(window.stepSeconds); + const traefikFilter = `service=~"${serviceMatcher}"`; + const queryStart = new Date( + window.start.getTime() + window.stepSeconds * 1000, + ); + + const [ + requestResults, + p50Results, + p90Results, + p95Results, + p99Results, + ingressResults, + egressResults, + cpuResults, + memoryPercentResults, + memoryBytesResults, + ] = await Promise.all([ + queryRangePromQL(endpoint, { + query: `sum by (code) (increase(traefik_service_requests_total{${traefikFilter}}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.5, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.9, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.95, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: responseTimeQuery(0.99, traefikFilter, rangeWindow), + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: `sum(rate(traefik_service_requests_bytes_total{${traefikFilter}}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: `sum(rate(traefik_service_responses_bytes_total{${traefikFilter}}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: `avg(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + queryRangePromQL(endpoint, { + query: `sum(avg_over_time(techulus_service_memory_used_bytes{service_id="${serviceId}"}[${rangeWindow}]))`, + start: queryStart, + end: window.end, + stepSeconds: window.stepSeconds, + }).catch(() => []), + ]); + + const buckets = createServiceMetricBuckets(window); + const bucketsByTimestamp = new Map( + buckets.map((bucket) => [bucket.timestamp, bucket]), + ); + const statusCodes = new Set(); + + for (const result of requestResults) { + const status = result.metric.code || "unknown"; + statusCodes.add(status); + for (const point of matrixResultToPoints(result)) { + const bucket = bucketsByTimestamp.get(point.timestamp); + if (!bucket) continue; + const requests = Math.max(0, Math.round(point.value)); + bucket.statuses[status] = (bucket.statuses[status] ?? 0) + requests; + bucket.totalRequests += requests; + } + } + + applySingleSeries(bucketsByTimestamp, p50Results, (bucket, value) => { + bucket.p50ResponseTimeMs = value * 1000; + }); + applySingleSeries(bucketsByTimestamp, p90Results, (bucket, value) => { + bucket.p90ResponseTimeMs = value * 1000; + }); + applySingleSeries(bucketsByTimestamp, p95Results, (bucket, value) => { + bucket.p95ResponseTimeMs = value * 1000; + }); + applySingleSeries(bucketsByTimestamp, p99Results, (bucket, value) => { + bucket.p99ResponseTimeMs = value * 1000; + }); + applySingleSeries(bucketsByTimestamp, ingressResults, (bucket, value) => { + bucket.ingressBytesPerSecond = value; + }); + applySingleSeries(bucketsByTimestamp, egressResults, (bucket, value) => { + bucket.egressBytesPerSecond = value; + }); + applySingleSeries(bucketsByTimestamp, cpuResults, (bucket, value) => { + bucket.cpuUsagePercent = value; + }); + applySingleSeries( + bucketsByTimestamp, + memoryPercentResults, + (bucket, value) => { + bucket.memoryUsagePercent = value; + }, + ); + applySingleSeries(bucketsByTimestamp, memoryBytesResults, (bucket, value) => { + bucket.memoryUsedBytes = value; + }); + + return { + metricsEnabled: true, + range: options.range, + windowStart: window.start.toISOString(), + windowEnd: window.end.toISOString(), + stepSeconds: window.stepSeconds, + totalRequests: buckets.reduce( + (total, bucket) => total + bucket.totalRequests, + 0, + ), + statusCodes: sortStatusCodes([...statusCodes]), + buckets, + }; +} + async function queryInstantMetric( endpoint: EndpointConfig, metricName: string, @@ -414,6 +618,39 @@ async function queryRangeMetricGroup( return byServer; } +async function queryRangePromQL( + endpoint: EndpointConfig, + options: { + query: string; + start: Date; + end: Date; + stepSeconds: number; + }, +): Promise { + const url = new URL(`${endpoint.url}/api/v1/query_range`); + url.searchParams.set("query", options.query); + url.searchParams.set( + "start", + String(Math.floor(options.start.getTime() / 1000)), + ); + url.searchParams.set("end", String(Math.floor(options.end.getTime() / 1000))); + url.searchParams.set("step", String(options.stepSeconds)); + + const response = await fetch(url.toString(), buildFetchOptions(endpoint)); + if (!response.ok) { + throw new Error( + `Failed to query metrics range: ${response.status} ${response.statusText}`, + ); + } + + const data = (await response.json()) as VictoriaMatrixResponse; + if (data.status !== "success") { + throw new Error(data.error || "Failed to query metrics range"); + } + + return data.data?.result ?? []; +} + export function emptyHistory(): NodeMetricsHistory { return { cpuUsagePercent: [], @@ -424,9 +661,118 @@ export function emptyHistory(): NodeMetricsHistory { }; } +export function getMetricWindow( + range: MetricRange, + now = new Date(), +): { + start: Date; + end: Date; + durationMs: number; + stepSeconds: number; +} { + const option = METRIC_RANGE_OPTIONS[range]; + const end = new Date(Math.floor(now.getTime() / 1000) * 1000); + const start = new Date(end.getTime() - option.durationMs); + return { + start, + end, + durationMs: option.durationMs, + stepSeconds: option.stepSeconds, + }; +} + +export function buildTraefikServiceMatcher(serviceId: string): string { + return `^${escapePromRegex(serviceId)}(@file)?$`; +} + +export function formatPromDuration(seconds: number): string { + if (seconds % 86400 === 0) return `${seconds / 86400}d`; + if (seconds % 3600 === 0) return `${seconds / 3600}h`; + if (seconds % 60 === 0) return `${seconds / 60}m`; + return `${seconds}s`; +} + +function responseTimeQuery( + quantile: number, + traefikFilter: string, + rangeWindow: string, +) { + return `histogram_quantile(${quantile}, sum by (le) (rate(traefik_service_request_duration_seconds_bucket{${traefikFilter}}[${rangeWindow}])))`; +} + +function createServiceMetricBuckets(window: { + start: Date; + end: Date; + stepSeconds: number; +}): ServiceMetricsBucket[] { + const buckets: ServiceMetricsBucket[] = []; + const stepMs = window.stepSeconds * 1000; + for ( + let timestampMs = window.start.getTime() + stepMs; + timestampMs <= window.end.getTime(); + timestampMs += stepMs + ) { + buckets.push({ + timestamp: new Date(timestampMs).toISOString(), + totalRequests: 0, + statuses: {}, + p50ResponseTimeMs: null, + p90ResponseTimeMs: null, + p95ResponseTimeMs: null, + p99ResponseTimeMs: null, + ingressBytesPerSecond: null, + egressBytesPerSecond: null, + cpuUsagePercent: null, + memoryUsagePercent: null, + memoryUsedBytes: null, + }); + } + return buckets; +} + +function applySingleSeries( + bucketsByTimestamp: Map, + results: VictoriaMatrixResult[], + apply: (bucket: ServiceMetricsBucket, value: number) => void, +) { + const result = results[0]; + if (!result) return; + for (const point of matrixResultToPoints(result)) { + const bucket = bucketsByTimestamp.get(point.timestamp); + if (!bucket) continue; + apply(bucket, point.value); + } +} + +function matrixResultToPoints(result: { + values: Array<[number, string]>; +}): NodeMetricPoint[] { + return result.values + .map(([timestamp, rawValue]) => ({ + timestamp: new Date(timestamp * 1000).toISOString(), + value: Number.parseFloat(rawValue), + })) + .filter((point) => Number.isFinite(point.value)); +} + +function sortStatusCodes(statuses: string[]): string[] { + return Array.from(new Set(statuses)).sort((a, b) => { + const statusA = Number(a); + const statusB = Number(b); + if (Number.isFinite(statusA) && Number.isFinite(statusB)) { + return statusA - statusB; + } + return a.localeCompare(b); + }); +} + function escapePromQL(value: string) { return value .replace(/\\/g, "\\\\") .replace(/"/g, '\\"') .replace(/\n/g, "\\n"); } + +function escapePromRegex(value: string) { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} diff --git a/web/public/setup.sh b/web/public/setup.sh index 30725e66..1509fe3a 100644 --- a/web/public/setup.sh +++ b/web/public/setup.sh @@ -407,6 +407,29 @@ accessLog: names: User-Agent: keep +metrics: + prometheus: + entryPoint: metrics + addEntryPointsLabels: false + addRoutersLabels: false + addServicesLabels: true + buckets: + - 0.005 + - 0.01 + - 0.025 + - 0.05 + - 0.075 + - 0.1 + - 0.25 + - 0.5 + - 0.75 + - 1 + - 2.5 + - 5 + - 10 + - 30 + - 60 + api: dashboard: false insecure: false @@ -418,6 +441,8 @@ experimental: version: v1.5.0 entryPoints: + metrics: + address: "127.0.0.1:9100" web: address: ":80" websecure: diff --git a/web/tests/victoria-logs-request-stats.test.ts b/web/tests/victoria-logs-request-stats.test.ts deleted file mode 100644 index ca732e25..00000000 --- a/web/tests/victoria-logs-request-stats.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - buildRequestStatsBuckets, - createEmptyHttpRequestStats, - getRequestStatsWindow, - parseRequestStatsRange, - parseRequestStatsRows, -} from "@/lib/victoria-logs"; - -const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000"; - -describe("VictoriaLogs request stats", () => { - afterEach(() => { - vi.restoreAllMocks(); - vi.unstubAllEnvs(); - vi.unstubAllGlobals(); - vi.resetModules(); - }); - - it("parses JSON lines and ignores malformed rows", () => { - const rows = parseRequestStatsRows( - [ - '{"_time":"2026-07-02T00:00:00Z","status":200,"requests":"10"}', - "not-json", - '{"_time":"2026-07-02T00:01:00Z","status":"500","requests":5}', - ].join("\n"), - ); - - expect(rows).toEqual([ - { - _time: "2026-07-02T00:00:00Z", - status: 200, - requests: "10", - }, - { - _time: "2026-07-02T00:01:00Z", - status: "500", - requests: 5, - }, - ]); - }); - - it("builds complete buckets and fills gaps with zeroes", () => { - const buckets = buildRequestStatsBuckets( - [ - { _time: "2026-07-02T00:00:10Z", status: "200", requests: "3" }, - { _time: "2026-07-02T00:02:05Z", status: "404", requests: 7 }, - { _time: "2026-07-02T00:02:15Z", status: "500", requests: "2" }, - ], - { - start: new Date("2026-07-02T00:00:00Z"), - end: new Date("2026-07-02T00:02:30Z"), - stepSeconds: 60, - }, - ); - - expect(buckets).toEqual([ - { - timestamp: "2026-07-02T00:00:00.000Z", - totalRequests: 3, - statuses: { "200": 3 }, - }, - { - timestamp: "2026-07-02T00:01:00.000Z", - totalRequests: 0, - statuses: {}, - }, - { - timestamp: "2026-07-02T00:02:00.000Z", - totalRequests: 9, - statuses: { "404": 7, "500": 2 }, - }, - ]); - }); - - it("falls back to the default range for unsupported values", () => { - expect(parseRequestStatsRange("7d")).toBe("7d"); - expect(parseRequestStatsRange("week")).toBe("week"); - expect(parseRequestStatsRange("90d")).toBe("7d"); - expect(parseRequestStatsRange(null)).toBe("7d"); - }); - - it("uses a Monday UTC start for the week-to-date range", () => { - const window = getRequestStatsWindow( - "week", - new Date("2026-07-02T12:34:00Z"), - ); - - expect(window.start.toISOString()).toBe("2026-06-29T00:00:00.000Z"); - expect(window.end.toISOString()).toBe("2026-07-02T12:34:00.000Z"); - expect(window.stepSeconds).toBe(1800); - }); - - it("handles Sunday and Monday UTC week boundaries", () => { - expect( - getRequestStatsWindow( - "week", - new Date("2026-07-05T23:59:59Z"), - ).start.toISOString(), - ).toBe("2026-06-29T00:00:00.000Z"); - expect( - getRequestStatsWindow( - "week", - new Date("2026-07-06T00:00:00Z"), - ).start.toISOString(), - ).toBe("2026-07-06T00:00:00.000Z"); - }); - - it("queries a week-to-date stats window with one LogSQL range filter", async () => { - vi.stubEnv("VICTORIA_LOGS_URL", "http://victoria.test"); - vi.stubEnv("VICTORIA_LOGS_PRIVATE_URL", ""); - vi.resetModules(); - - const fetchMock = vi.fn(async (input: string | URL | Request) => { - const url = new URL(String(input)); - const query = url.searchParams.get("query"); - - expect(url.pathname).toBe("/select/logsql/query"); - expect(url.searchParams.get("limit")).toBe("11200"); - expect(query).toBe( - `service_id:${SERVICE_ID} log_type:http _time:[2026-06-29T00:00:00.000Z, 2026-07-02T12:34:00.000Z) | stats by (_time:30m, status) count() as requests | sort by (_time)`, - ); - - return new Response( - '{"_time":"2026-07-02T12:30:00Z","status":"200","requests":"3"}\n', - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const { queryHttpRequestStats } = await import("@/lib/victoria-logs"); - const stats = await queryHttpRequestStats({ - serviceId: SERVICE_ID, - range: "week", - now: new Date("2026-07-02T12:34:00Z"), - }); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(stats).toMatchObject({ - range: "week", - windowStart: "2026-06-29T00:00:00.000Z", - windowEnd: "2026-07-02T12:34:00.000Z", - stepSeconds: 1800, - totalRequests: 3, - statusCodes: ["200"], - }); - }); - - it("creates an empty non-breaking stats payload", () => { - expect( - createEmptyHttpRequestStats("1h", new Date("2026-07-02T01:00:00Z")), - ).toMatchObject({ - range: "1h", - windowStart: "2026-07-02T00:00:00.000Z", - windowEnd: "2026-07-02T01:00:00.000Z", - stepSeconds: 60, - totalRequests: 0, - statusCodes: [], - buckets: [], - }); - }); -}); diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts new file mode 100644 index 00000000..9e0f1fd1 --- /dev/null +++ b/web/tests/victoria-metrics-service-metrics.test.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildTraefikServiceMatcher, + createEmptyServiceMetrics, + formatPromDuration, + queryServiceMetrics, +} from "@/lib/victoria-metrics"; + +const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000"; +const END_TS = Date.parse("2026-07-02T12:34:00Z") / 1000; + +describe("VictoriaMetrics service metrics", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("formats Prometheus durations", () => { + expect(formatPromDuration(60)).toBe("1m"); + expect(formatPromDuration(300)).toBe("5m"); + expect(formatPromDuration(7200)).toBe("2h"); + expect(formatPromDuration(45)).toBe("45s"); + }); + + it("matches Traefik service labels with optional provider suffix", () => { + expect(buildTraefikServiceMatcher(SERVICE_ID)).toBe( + `^${SERVICE_ID}(@file)?$`, + ); + expect(buildTraefikServiceMatcher("svc.1")).toBe("^svc\\.1(@file)?$"); + }); + + it("creates an empty metrics payload", () => { + expect( + createEmptyServiceMetrics("1h", new Date("2026-07-02T01:00:00Z")), + ).toMatchObject({ + metricsEnabled: false, + range: "1h", + windowStart: "2026-07-02T00:00:00.000Z", + windowEnd: "2026-07-02T01:00:00.000Z", + stepSeconds: 60, + totalRequests: 0, + statusCodes: [], + buckets: [], + }); + }); + + it("queries service metrics from VictoriaMetrics only", async () => { + vi.stubEnv("VICTORIA_METRICS_URL", "http://victoria.test"); + vi.stubEnv("VICTORIA_METRICS_PRIVATE_URL", ""); + + const queries: string[] = []; + const starts: string[] = []; + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = new URL(String(input)); + const query = url.searchParams.get("query") || ""; + queries.push(query); + starts.push(url.searchParams.get("start") || ""); + + expect(url.pathname).toBe("/api/v1/query_range"); + + if (query.includes("traefik_service_requests_total")) { + return jsonResponse([ + { + metric: { code: "200" }, + values: [[END_TS, "3"]], + }, + ]); + } + if (query.includes("histogram_quantile(0.95")) { + return jsonResponse([{ metric: {}, values: [[END_TS, "0.123"]] }]); + } + if (query.includes("traefik_service_responses_bytes_total")) { + return jsonResponse([{ metric: {}, values: [[END_TS, "2048"]] }]); + } + if (query.includes("techulus_service_cpu_usage_percent")) { + return jsonResponse([{ metric: {}, values: [[END_TS, "42"]] }]); + } + + return jsonResponse([]); + }); + vi.stubGlobal("fetch", fetchMock); + + const stats = await queryServiceMetrics({ + serviceId: SERVICE_ID, + range: "24h", + now: new Date("2026-07-02T12:34:00Z"), + }); + + expect(fetchMock).toHaveBeenCalledTimes(10); + expect(queries.some((query) => query.includes("LogSQL"))).toBe(false); + expect(queries).toContain( + `sum by (code) (increase(traefik_service_requests_total{service=~"^${SERVICE_ID}(@file)?$"}[5m]))`, + ); + expect(queries).toContain( + `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${SERVICE_ID}"}[5m]))`, + ); + expect( + starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)), + ).toBe(true); + expect(stats.totalRequests).toBe(3); + expect(stats.statusCodes).toEqual(["200"]); + expect(stats.buckets).toHaveLength(288); + expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:39:00.000Z"); + + const lastBucket = stats.buckets.at(-1); + expect(lastBucket).toMatchObject({ + totalRequests: 3, + statuses: { "200": 3 }, + p95ResponseTimeMs: 123, + egressBytesPerSecond: 2048, + cpuUsagePercent: 42, + }); + }); +}); + +function jsonResponse(result: unknown[]) { + return new Response( + JSON.stringify({ + status: "success", + data: { result }, + }), + ); +} From 76b442bd672418868ba451dba124ae9712667fac Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:45:26 +1000 Subject: [PATCH 03/18] Add QR code to 2FA setup --- .../settings/two-factor-settings.tsx | 61 +++++++- web/package.json | 2 + web/pnpm-lock.yaml | 138 ++++++++++++++++++ 3 files changed, 198 insertions(+), 3 deletions(-) diff --git a/web/components/settings/two-factor-settings.tsx b/web/components/settings/two-factor-settings.tsx index 1e693839..eed12b1a 100644 --- a/web/components/settings/two-factor-settings.tsx +++ b/web/components/settings/two-factor-settings.tsx @@ -7,7 +7,9 @@ import { ShieldCheck, ShieldOff, } from "lucide-react"; -import { useMemo, useState } from "react"; +import Image from "next/image"; +import QRCode from "qrcode"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; @@ -99,6 +101,7 @@ export function TwoFactorSettings() { const [setupPassword, setSetupPassword] = useState(""); const [verificationCode, setVerificationCode] = useState(""); const [pendingSetup, setPendingSetup] = useState(null); + const [setupQrCode, setSetupQrCode] = useState(""); const [backupPassword, setBackupPassword] = useState(""); const [disablePassword, setDisablePassword] = useState(""); const [recoveryCodes, setRecoveryCodes] = useState([]); @@ -115,6 +118,35 @@ export function TwoFactorSettings() { [verificationCode], ); + useEffect(() => { + if (!pendingSetup?.totpURI) { + setSetupQrCode(""); + return; + } + + let shouldUseResult = true; + + QRCode.toDataURL(pendingSetup.totpURI, { + errorCorrectionLevel: "M", + margin: 2, + width: 192, + color: { + dark: "#000000", + light: "#ffffff", + }, + }) + .then((dataUrl) => { + if (shouldUseResult) setSetupQrCode(dataUrl); + }) + .catch(() => { + if (shouldUseResult) setSetupQrCode(""); + }); + + return () => { + shouldUseResult = false; + }; + }, [pendingSetup?.totpURI]); + function refreshSession() { return refetch({ query: { disableCookieCache: true } }); } @@ -275,12 +307,35 @@ export function TwoFactorSettings() {

Set up your app

- Add the setup key to your authenticator app, then enter the - current 6-digit code. + Scan the QR code or add the setup key to your authenticator + app, then enter the current 6-digit code.

+
+
+ {setupQrCode ? ( + Authenticator setup QR code + ) : ( + + )} +
+
+

Scan QR code

+

+ Use Google Authenticator, 1Password, Authy, or another + TOTP-compatible app. +

+
+
diff --git a/web/package.json b/web/package.json index c758ff38..69ff99b6 100644 --- a/web/package.json +++ b/web/package.json @@ -37,6 +37,7 @@ "nodemailer": "^7.0.12", "nuqs": "^2.8.6", "pg": "^8.16.3", + "qrcode": "^1.5.4", "react": "19.2.7", "react-dom": "19.2.7", "recharts": "^3.8.1", @@ -56,6 +57,7 @@ "@types/node": "^24", "@types/nodemailer": "^7.0.5", "@types/pg": "^8.16.0", + "@types/qrcode": "^1.5.6", "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "@types/validator": "^13.15.10", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8daaa418..37609859 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -78,6 +78,9 @@ importers: pg: specifier: ^8.16.3 version: 8.21.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: 19.2.7 version: 19.2.7 @@ -130,6 +133,9 @@ importers: '@types/pg': specifier: ^8.16.0 version: 8.20.0 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: 19.2.17 version: 19.2.17 @@ -2602,6 +2608,9 @@ packages: '@types/pg@8.20.0': resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -3134,6 +3143,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} @@ -3176,6 +3189,9 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -3360,6 +3376,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} @@ -3425,6 +3445,9 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3903,6 +3926,10 @@ packages: resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} engines: {node: '>=6'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -4600,6 +4627,10 @@ packages: resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} engines: {node: '>=6'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -4937,6 +4968,10 @@ packages: resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} engines: {node: '>=6'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} @@ -5065,6 +5100,10 @@ packages: resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} engines: {node: '>=8'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5148,6 +5187,11 @@ packages: resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} engines: {node: '>=16.0.0'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -5234,6 +5278,9 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -5333,6 +5380,9 @@ packages: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-cookie-parser@3.1.0: resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} @@ -5847,6 +5897,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.22: resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==} engines: {node: '>= 0.4'} @@ -5870,6 +5923,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -5893,6 +5950,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5905,10 +5965,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -8425,6 +8493,10 @@ snapshots: pg-protocol: 1.14.0 pg-types: 2.2.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.13.2 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -8948,6 +9020,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + caniuse-lite@1.0.30001799: {} canonicalize@1.0.8: {} @@ -8979,6 +9053,12 @@ snapshots: client-only@0.0.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -9140,6 +9220,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js-light@2.5.1: {} dedent@1.7.2: {} @@ -9183,6 +9265,8 @@ snapshots: diff@8.0.4: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -9849,6 +9933,11 @@ snapshots: dependencies: locate-path: 3.0.0 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -10493,6 +10582,10 @@ snapshots: p-locate: 3.0.0 path-exists: 3.0.0 + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -10811,6 +10904,10 @@ snapshots: dependencies: p-limit: 2.3.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 @@ -10915,6 +11012,8 @@ snapshots: dependencies: find-up: 3.0.0 + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.4: @@ -10996,6 +11095,12 @@ snapshots: pvutils@1.1.5: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.1 @@ -11104,6 +11209,8 @@ snapshots: transitivePeerDependencies: - supports-color + require-main-filename@2.0.0: {} + reselect@5.1.1: {} reselect@5.2.0: {} @@ -11238,6 +11345,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-cookie-parser@3.1.0: {} set-function-length@1.2.2: @@ -11846,6 +11955,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.22: dependencies: available-typed-arrays: 1.0.7 @@ -11871,6 +11982,12 @@ snapshots: word-wrap@1.2.5: {} + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -11894,14 +12011,35 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 From f88b2c4a6e153b07f736cefe93c2ca97fd2fd4ce Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:53:22 +1000 Subject: [PATCH 04/18] Reduce service metric label cardinality --- agent/internal/metrics/victoria.go | 53 ++++++++++++++++-- agent/internal/metrics/victoria_test.go | 54 +++++++++++++++++++ web/lib/victoria-metrics.ts | 2 +- .../victoria-metrics-service-metrics.test.ts | 3 ++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/agent/internal/metrics/victoria.go b/agent/internal/metrics/victoria.go index d74fad37..c7c5c721 100644 --- a/agent/internal/metrics/victoria.go +++ b/agent/internal/metrics/victoria.go @@ -21,6 +21,15 @@ type VictoriaMetricsSender struct { client *http.Client } +type serviceResourceStats struct { + ServiceID string + CPUUsagePercent float64 + MemoryUsagePercent float64 + MemoryUsedBytes float64 + NetworkReceiveBytes float64 + NetworkTransmitBytes float64 +} + func NewVictoriaMetricsSender(endpoint, serverID string) *VictoriaMetricsSender { var username, password string cleanEndpoint := strings.TrimRight(endpoint, "/") @@ -85,16 +94,19 @@ func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceSta return nil } + aggregates := aggregateContainerStats(stats) + if len(aggregates) == 0 { + return nil + } + timestampMs := collectedAt.UnixMilli() serverID := escapeLabelValue(v.serverID) var buf bytes.Buffer - for _, stat := range stats { + for _, stat := range aggregates { labels := map[string]string{ - "server_id": serverID, - "service_id": escapeLabelValue(stat.ServiceID), - "deployment_id": escapeLabelValue(stat.DeploymentID), - "container_id": escapeLabelValue(stat.ContainerID), + "server_id": serverID, + "service_id": escapeLabelValue(stat.ServiceID), } writeGaugeWithLabels(&buf, "techulus_service_cpu_usage_percent", labels, stat.CPUUsagePercent, timestampMs) writeGaugeWithLabels(&buf, "techulus_service_memory_usage_percent", labels, stat.MemoryUsagePercent, timestampMs) @@ -106,6 +118,37 @@ func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceSta return v.postPrometheusImport(buf.Bytes(), nil) } +func aggregateContainerStats(stats []container.ResourceStats) []serviceResourceStats { + byService := make(map[string]*serviceResourceStats) + for _, stat := range stats { + if stat.ServiceID == "" { + continue + } + aggregate := byService[stat.ServiceID] + if aggregate == nil { + aggregate = &serviceResourceStats{ServiceID: stat.ServiceID} + byService[stat.ServiceID] = aggregate + } + aggregate.CPUUsagePercent += stat.CPUUsagePercent + aggregate.MemoryUsagePercent += stat.MemoryUsagePercent + aggregate.MemoryUsedBytes += stat.MemoryUsedBytes + aggregate.NetworkReceiveBytes += stat.NetworkReceiveBytes + aggregate.NetworkTransmitBytes += stat.NetworkTransmitBytes + } + + serviceIDs := make([]string, 0, len(byService)) + for serviceID := range byService { + serviceIDs = append(serviceIDs, serviceID) + } + sort.Strings(serviceIDs) + + aggregates := make([]serviceResourceStats, 0, len(serviceIDs)) + for _, serviceID := range serviceIDs { + aggregates = append(aggregates, *byService[serviceID]) + } + return aggregates +} + func (v *VictoriaMetricsSender) SendPrometheusMetrics(data []byte, extraLabels map[string]string) error { if len(bytes.TrimSpace(data)) == 0 { return nil diff --git a/agent/internal/metrics/victoria_test.go b/agent/internal/metrics/victoria_test.go index 5854f197..9e8792f7 100644 --- a/agent/internal/metrics/victoria_test.go +++ b/agent/internal/metrics/victoria_test.go @@ -4,7 +4,11 @@ import ( "io" "net/http" "net/http/httptest" + "strings" "testing" + "time" + + "techulus/cloud-agent/internal/container" ) func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) { @@ -39,3 +43,53 @@ func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) { t.Fatalf("body = %q", gotBody) } } + +func TestSendContainerStatsAggregatesStableServiceLabels(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + sender := NewVictoriaMetricsSender(server.URL, "server-1") + err := sender.SendContainerStats([]container.ResourceStats{ + { + ContainerID: "container-a", + ServiceID: "svc-a", + DeploymentID: "dep-a", + CPUUsagePercent: 10, + MemoryUsagePercent: 1.5, + MemoryUsedBytes: 1024, + NetworkReceiveBytes: 100, + NetworkTransmitBytes: 200, + }, + { + ContainerID: "container-b", + ServiceID: "svc-a", + DeploymentID: "dep-b", + CPUUsagePercent: 20, + MemoryUsagePercent: 2.5, + MemoryUsedBytes: 2048, + NetworkReceiveBytes: 300, + NetworkTransmitBytes: 400, + }, + }, time.UnixMilli(1_700_000_000_000)) + if err != nil { + t.Fatalf("send container stats: %v", err) + } + + if strings.Contains(gotBody, "container_id") || strings.Contains(gotBody, "deployment_id") { + t.Fatalf("unexpected churn labels in body:\n%s", gotBody) + } + if !strings.Contains(gotBody, `techulus_service_cpu_usage_percent{server_id="server-1",service_id="svc-a"} 30.000000 1700000000000`) { + t.Fatalf("missing aggregated CPU metric:\n%s", gotBody) + } + if !strings.Contains(gotBody, `techulus_service_memory_usage_percent{server_id="server-1",service_id="svc-a"} 4.000000 1700000000000`) { + t.Fatalf("missing aggregated memory percent metric:\n%s", gotBody) + } + if !strings.Contains(gotBody, `techulus_service_memory_used_bytes{server_id="server-1",service_id="svc-a"} 3072.000000 1700000000000`) { + t.Fatalf("missing aggregated memory bytes metric:\n%s", gotBody) + } +} diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index 0fb62f7d..2e68ad3d 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -388,7 +388,7 @@ export async function queryServiceMetrics(options: { stepSeconds: window.stepSeconds, }).catch(() => []), queryRangePromQL(endpoint, { - query: `avg(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, + query: `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${serviceId}"}[${rangeWindow}]))`, start: queryStart, end: window.end, stepSeconds: window.stepSeconds, diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts index 9e0f1fd1..5f971156 100644 --- a/web/tests/victoria-metrics-service-metrics.test.ts +++ b/web/tests/victoria-metrics-service-metrics.test.ts @@ -96,6 +96,9 @@ describe("VictoriaMetrics service metrics", () => { expect(queries).toContain( `sum(avg_over_time(techulus_service_cpu_usage_percent{service_id="${SERVICE_ID}"}[5m]))`, ); + expect(queries).toContain( + `sum(avg_over_time(techulus_service_memory_usage_percent{service_id="${SERVICE_ID}"}[5m]))`, + ); expect( starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)), ).toBe(true); From 1ee14b0c24dd9fdc3237c131c5977fe6cfe82553 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:21:45 +1000 Subject: [PATCH 05/18] Add agent process resource metrics --- agent/internal/agent/agent.go | 1 + agent/internal/agent/reporting.go | 6 ++ agent/internal/health/health.go | 100 ++++++++++++++++++++++++ agent/internal/health/health_test.go | 67 ++++++++++++++++ agent/internal/metrics/victoria.go | 16 ++++ agent/internal/metrics/victoria_test.go | 31 ++++++++ 6 files changed, 221 insertions(+) create mode 100644 agent/internal/health/health_test.go diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index a1a4f7fb..fa85f72d 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -127,6 +127,7 @@ func NewAgent( type MetricsSender interface { SendSystemStats(stats *health.SystemStats, collectedAt time.Time) error + SendAgentStats(stats *health.AgentProcessStats, collectedAt time.Time) error SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error SendPrometheusMetrics(data []byte, extraLabels map[string]string) error } diff --git a/agent/internal/agent/reporting.go b/agent/internal/agent/reporting.go index ab4c6574..70f75cc3 100644 --- a/agent/internal/agent/reporting.go +++ b/agent/internal/agent/reporting.go @@ -53,6 +53,12 @@ func (a *Agent) BuildStatusReport(includeResources bool) *agenthttp.StatusReport if err := a.MetricsSender.SendSystemStats(systemStats, collectedAt); err != nil { log.Printf("[metrics] failed to send system stats: %v", err) } + agentStats, err := health.CollectAgentProcessStats() + if err != nil { + log.Printf("[metrics] failed to collect agent stats: %v", err) + } else if err := a.MetricsSender.SendAgentStats(agentStats, collectedAt); err != nil { + log.Printf("[metrics] failed to send agent stats: %v", err) + } containerStats, err := container.CollectResourceStats() if err != nil { log.Printf("[metrics] failed to collect container stats: %v", err) diff --git a/agent/internal/health/health.go b/agent/internal/health/health.go index 3d140cdc..f23ce9d7 100644 --- a/agent/internal/health/health.go +++ b/agent/internal/health/health.go @@ -1,14 +1,19 @@ package health import ( + "math" + "os" "os/exec" + "runtime" "strconv" "strings" + "sync" "time" "github.com/shirou/gopsutil/v3/cpu" "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/mem" + "github.com/shirou/gopsutil/v3/process" ) type SystemStats struct { @@ -19,6 +24,12 @@ type SystemStats struct { DiskUsedGb int `json:"diskUsedGb"` } +type AgentProcessStats struct { + CPUUsagePercent float64 + MemoryUsagePercent float64 + MemoryUsedBytes uint64 +} + type NetworkPeerHealth struct { ID string `json:"id"` LastSeenSecs int `json:"lastSeenSecs"` @@ -45,6 +56,12 @@ type AgentHealthInfo struct { LastSyncAt string `json:"lastSyncAt"` } +var ( + agentProcessCPUMu sync.Mutex + agentProcessLastCPUTimes *cpu.TimesStat + agentProcessLastCPUTime time.Time +) + func CollectSystemStats() *SystemStats { stats := &SystemStats{} @@ -68,6 +85,89 @@ func CollectSystemStats() *SystemStats { return stats } +func CollectAgentProcessStats() (*AgentProcessStats, error) { + proc, err := process.NewProcess(int32(os.Getpid())) + if err != nil { + return nil, err + } + + stats := &AgentProcessStats{} + + cpuPercent, err := collectAgentCPUPercent(proc) + if err != nil { + return nil, err + } + stats.CPUUsagePercent = cpuPercent + + memInfo, err := proc.MemoryInfo() + if err != nil { + return nil, err + } + stats.MemoryUsedBytes = memInfo.RSS + + memPercent, err := proc.MemoryPercent() + if err != nil { + return nil, err + } + stats.MemoryUsagePercent = float64(memPercent) + + return stats, nil +} + +func collectAgentCPUPercent(proc *process.Process) (float64, error) { + cpuTimes, err := proc.Times() + if err != nil { + return 0, err + } + now := time.Now() + + agentProcessCPUMu.Lock() + defer agentProcessCPUMu.Unlock() + + if agentProcessLastCPUTimes == nil || agentProcessLastCPUTime.IsZero() { + // Delta-based CPU metrics need one sample to establish the baseline. + agentProcessLastCPUTimes = cpuTimes + agentProcessLastCPUTime = now + return 0, nil + } + + elapsedSeconds := now.Sub(agentProcessLastCPUTime).Seconds() + percent := calculateAgentCPUUsagePercent( + agentProcessLastCPUTimes, + cpuTimes, + elapsedSeconds, + runtime.NumCPU(), + ) + agentProcessLastCPUTimes = cpuTimes + agentProcessLastCPUTime = now + + return percent, nil +} + +func calculateAgentCPUUsagePercent(previous, current *cpu.TimesStat, elapsedSeconds float64, cpuCount int) float64 { + if previous == nil || current == nil { + return 0 + } + + cpuDeltaSeconds := processCPUTotal(current) - processCPUTotal(previous) + if elapsedSeconds <= 0 || cpuDeltaSeconds < 0 { + return 0 + } + if cpuCount <= 0 { + cpuCount = 1 + } + + percent := (cpuDeltaSeconds / elapsedSeconds) * 100 / float64(cpuCount) + if math.IsNaN(percent) || math.IsInf(percent, 0) { + return 0 + } + return percent +} + +func processCPUTotal(times *cpu.TimesStat) float64 { + return times.User + times.System +} + func CollectNetworkHealth(interfaceName string) *NetworkHealth { health := &NetworkHealth{ TunnelUp: false, diff --git a/agent/internal/health/health_test.go b/agent/internal/health/health_test.go new file mode 100644 index 00000000..1e169e9a --- /dev/null +++ b/agent/internal/health/health_test.go @@ -0,0 +1,67 @@ +package health + +import ( + "testing" + + "github.com/shirou/gopsutil/v3/cpu" +) + +func TestCalculateAgentCPUUsagePercentNormalizesByCPUCount(t *testing.T) { + previous := &cpu.TimesStat{User: 10, System: 5} + current := &cpu.TimesStat{User: 12, System: 7} + + got := calculateAgentCPUUsagePercent(previous, current, 1, 4) + if got != 100 { + t.Fatalf("cpu percent = %f, want 100", got) + } +} + +func TestCalculateAgentCPUUsagePercentWarmupOrInvalidInputs(t *testing.T) { + current := &cpu.TimesStat{User: 12, System: 7} + + tests := map[string]struct { + previous *cpu.TimesStat + current *cpu.TimesStat + elapsedSeconds float64 + cpuCount int + }{ + "missing previous sample": { + previous: nil, + current: current, + elapsedSeconds: 1, + cpuCount: 4, + }, + "missing current sample": { + previous: &cpu.TimesStat{User: 10, System: 5}, + current: nil, + elapsedSeconds: 1, + cpuCount: 4, + }, + "zero elapsed": { + previous: &cpu.TimesStat{User: 10, System: 5}, + current: current, + elapsedSeconds: 0, + cpuCount: 4, + }, + "negative counter delta": { + previous: current, + current: &cpu.TimesStat{User: 10, System: 5}, + elapsedSeconds: 1, + cpuCount: 4, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got := calculateAgentCPUUsagePercent( + test.previous, + test.current, + test.elapsedSeconds, + test.cpuCount, + ) + if got != 0 { + t.Fatalf("cpu percent = %f, want 0", got) + } + }) + } +} diff --git a/agent/internal/metrics/victoria.go b/agent/internal/metrics/victoria.go index c7c5c721..1bedd144 100644 --- a/agent/internal/metrics/victoria.go +++ b/agent/internal/metrics/victoria.go @@ -89,6 +89,22 @@ func (v *VictoriaMetricsSender) SendSystemStats(stats *health.SystemStats, colle return nil } +func (v *VictoriaMetricsSender) SendAgentStats(stats *health.AgentProcessStats, collectedAt time.Time) error { + if stats == nil { + return nil + } + + timestampMs := collectedAt.UnixMilli() + serverID := escapeLabelValue(v.serverID) + + var buf bytes.Buffer + writeGauge(&buf, "techulus_agent_cpu_usage_percent", serverID, stats.CPUUsagePercent, timestampMs) + writeGauge(&buf, "techulus_agent_memory_usage_percent", serverID, stats.MemoryUsagePercent, timestampMs) + writeGauge(&buf, "techulus_agent_memory_used_bytes", serverID, float64(stats.MemoryUsedBytes), timestampMs) + + return v.postPrometheusImport(buf.Bytes(), nil) +} + func (v *VictoriaMetricsSender) SendContainerStats(stats []container.ResourceStats, collectedAt time.Time) error { if len(stats) == 0 { return nil diff --git a/agent/internal/metrics/victoria_test.go b/agent/internal/metrics/victoria_test.go index 9e8792f7..ff691eb2 100644 --- a/agent/internal/metrics/victoria_test.go +++ b/agent/internal/metrics/victoria_test.go @@ -9,6 +9,7 @@ import ( "time" "techulus/cloud-agent/internal/container" + "techulus/cloud-agent/internal/health" ) func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) { @@ -44,6 +45,36 @@ func TestSendPrometheusMetricsAddsExtraLabels(t *testing.T) { } } +func TestSendAgentStatsWritesStableServerLabels(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + sender := NewVictoriaMetricsSender(server.URL, "server-1") + err := sender.SendAgentStats(&health.AgentProcessStats{ + CPUUsagePercent: 1.25, + MemoryUsagePercent: 0.5, + MemoryUsedBytes: 64 * 1024 * 1024, + }, time.UnixMilli(1_700_000_000_000)) + if err != nil { + t.Fatalf("send agent stats: %v", err) + } + + if !strings.Contains(gotBody, `techulus_agent_cpu_usage_percent{server_id="server-1"} 1.250000 1700000000000`) { + t.Fatalf("missing agent CPU metric:\n%s", gotBody) + } + if !strings.Contains(gotBody, `techulus_agent_memory_usage_percent{server_id="server-1"} 0.500000 1700000000000`) { + t.Fatalf("missing agent memory percent metric:\n%s", gotBody) + } + if !strings.Contains(gotBody, `techulus_agent_memory_used_bytes{server_id="server-1"} 67108864.000000 1700000000000`) { + t.Fatalf("missing agent memory bytes metric:\n%s", gotBody) + } +} + func TestSendContainerStatsAggregatesStableServiceLabels(t *testing.T) { var gotBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { From 779abb289c24be480abd964994f23484d3528431 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 9 Jul 2026 18:29:43 +1000 Subject: [PATCH 06/18] Fix service metrics window alignment --- web/lib/victoria-metrics.ts | 3 ++- web/tests/victoria-metrics-service-metrics.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index 2e68ad3d..9fcafbb0 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -671,7 +671,8 @@ export function getMetricWindow( stepSeconds: number; } { const option = METRIC_RANGE_OPTIONS[range]; - const end = new Date(Math.floor(now.getTime() / 1000) * 1000); + const stepMs = option.stepSeconds * 1000; + const end = new Date(Math.floor(now.getTime() / stepMs) * stepMs); const start = new Date(end.getTime() - option.durationMs); return { start, diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts index 5f971156..fc6464ea 100644 --- a/web/tests/victoria-metrics-service-metrics.test.ts +++ b/web/tests/victoria-metrics-service-metrics.test.ts @@ -7,7 +7,7 @@ import { } from "@/lib/victoria-metrics"; const SERVICE_ID = "123e4567-e89b-42d3-a456-426614174000"; -const END_TS = Date.parse("2026-07-02T12:34:00Z") / 1000; +const END_TS = Date.parse("2026-07-02T12:30:00Z") / 1000; describe("VictoriaMetrics service metrics", () => { afterEach(() => { @@ -105,7 +105,8 @@ describe("VictoriaMetrics service metrics", () => { expect(stats.totalRequests).toBe(3); expect(stats.statusCodes).toEqual(["200"]); expect(stats.buckets).toHaveLength(288); - expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:39:00.000Z"); + expect(stats.windowEnd).toBe("2026-07-02T12:30:00.000Z"); + expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:35:00.000Z"); const lastBucket = stats.buckets.at(-1); expect(lastBucket).toMatchObject({ From c2721b48a051e3fb505da796dac226dfddeceaa6 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 9 Jul 2026 18:36:00 +1000 Subject: [PATCH 07/18] Always show service metrics history --- .../details/service-details-overview.tsx | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 1d1f30ce..7b445074 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -160,10 +160,7 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { () => buildOverviewData(service, proxyDomain), [service, proxyDomain], ); - const serviceMetricsUrl = - overview.runningDeployments > 0 - ? `/api/services/${service.id}/metrics?range=24h` - : null; + const serviceMetricsUrl = `/api/services/${service.id}/metrics?range=24h`; const { data: serviceMetrics, error: serviceMetricsError, @@ -177,7 +174,6 @@ export function ServiceDetailsOverview({ service }: { service: Service }) {
0} - hasRunningDeployments={overview.runningDeployments > 0} stats={serviceMetrics} error={serviceMetricsError} isLoading={isServiceMetricsLoading} @@ -191,13 +187,11 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { function ServiceMetricsPanel({ hasPublicHttp, - hasRunningDeployments, stats, error, isLoading, }: { hasPublicHttp: boolean; - hasRunningDeployments: boolean; stats?: ServiceMetricsResponse; error?: unknown; isLoading: boolean; @@ -226,7 +220,7 @@ function ServiceMetricsPanel({
- ) : hasRunningDeployments ? ( + ) : (

@@ -271,13 +265,6 @@ function ServiceMetricsPanel({

egress

- ) : ( - <> -

- - -

-

not deployed

- )}
@@ -285,15 +272,13 @@ function ServiceMetricsPanel({
- {!hasRunningDeployments ? ( - - ) : isLoading ? ( + {isLoading ? ( ) : isUnavailable ? ( From 16ad330bddc23599342ec9a862b9b496558d3e7e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 9 Jul 2026 18:38:27 +1000 Subject: [PATCH 08/18] Scope service metric summary to selected tab --- .../details/service-details-overview.tsx | 160 +++++++++++++----- 1 file changed, 113 insertions(+), 47 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 7b445074..484fcf4c 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -127,6 +127,11 @@ type MetricSeries = { valueFormatter: (value: number) => string; }; +type ServiceMetricSummaryItem = { + label: string; + value: string; +}; + const STATUS_TONE_CLASSES: Record< ServiceStatus["tone"], { dot: string; text: string } @@ -205,11 +210,11 @@ function ServiceMetricsPanel({ ); const hasChartData = hasMetricDataForMode(chartRows, chartMode, activeSeries); const isUnavailable = Boolean(error) || stats?.metricsEnabled === false; - const hasMetricData = stats && !isUnavailable; - const p95 = getLatestValue(chartRows, "p95ResponseTimeMs"); - const egress = getLatestValue(chartRows, "egressBytesPerSecond"); - const cpu = getLatestValue(chartRows, "cpuUsagePercent"); - const memory = getLatestValue(chartRows, "memoryUsedBytes"); + const hasMetricData = Boolean(stats && !isUnavailable); + const summaryItems = useMemo( + () => buildServiceMetricSummaryItems(chartMode, stats, chartRows, hasMetricData), + [chartMode, stats, chartRows, hasMetricData], + ); return (
@@ -222,48 +227,14 @@ function ServiceMetricsPanel({
) : (
-
-

- {hasMetricData - ? formatCompactNumber(stats.totalRequests) - : "-"} -

-

requests in 24h

-
-
-

- {hasMetricData - ? formatRate(getAverageRequestsPerSecond(stats)) - : "-"} -

-

avg RPS

-
-
-

- {hasMetricData && p95 != null ? formatDurationMs(p95) : "-"} -

-

p95 latency

-
-
-

- {hasMetricData && cpu != null ? `${formatRate(cpu)}%` : "-"} -

-

CPU

-
-
-

- {hasMetricData && memory != null ? formatBytes(memory) : "-"} -

-

memory

-
-
-

- {hasMetricData && egress != null - ? `${formatBytes(egress)}/s` - : "-"} -

-

egress

-
+ {summaryItems.map((item) => ( +
+

+ {item.value} +

+

{item.label}

+
+ ))}
)}
@@ -1006,6 +977,101 @@ function formatRate(value: number): string { return value.toFixed(2).replace(/\.?0+$/, ""); } +function buildServiceMetricSummaryItems( + mode: ServiceChartMode, + stats: ServiceMetricsResponse | undefined, + rows: ChartRow[], + hasMetricData: boolean, +): ServiceMetricSummaryItem[] { + if (mode === "requests") { + return [ + { + label: "requests in 24h", + value: + hasMetricData && stats + ? formatCompactNumber(stats.totalRequests) + : "-", + }, + { + label: "avg RPS", + value: + hasMetricData && stats + ? formatRate(getAverageRequestsPerSecond(stats)) + : "-", + }, + ]; + } + + if (mode === "latency") { + return [ + { + label: "p50 latency", + value: formatNullableMetric( + getLatestValue(rows, "p50ResponseTimeMs"), + formatDurationMs, + ), + }, + { + label: "p95 latency", + value: formatNullableMetric( + getLatestValue(rows, "p95ResponseTimeMs"), + formatDurationMs, + ), + }, + { + label: "p99 latency", + value: formatNullableMetric( + getLatestValue(rows, "p99ResponseTimeMs"), + formatDurationMs, + ), + }, + ]; + } + + if (mode === "traffic") { + return [ + { + label: "ingress", + value: formatNullableMetric( + getLatestValue(rows, "ingressBytesPerSecond"), + (value) => `${formatBytes(value)}/s`, + ), + }, + { + label: "egress", + value: formatNullableMetric( + getLatestValue(rows, "egressBytesPerSecond"), + (value) => `${formatBytes(value)}/s`, + ), + }, + ]; + } + + return [ + { + label: "CPU", + value: formatNullableMetric( + getLatestValue(rows, "cpuUsagePercent"), + (value) => `${formatRate(value)}%`, + ), + }, + { + label: "memory", + value: formatNullableMetric( + getLatestValue(rows, "memoryUsagePercent"), + (value) => `${formatRate(value)}%`, + ), + }, + ]; +} + +function formatNullableMetric( + value: number | null, + formatter: (value: number) => string, +): string { + return value == null ? "-" : formatter(value); +} + function buildMetricSeries( mode: ServiceChartMode, statusSeries: StatusSeries[], From 67f82f5a3543a7f51f5cfcd479761320b391ae78 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Thu, 9 Jul 2026 19:21:35 +1000 Subject: [PATCH 09/18] Group service request metrics by status family --- .../details/service-details-overview.tsx | 11 ++++++++ web/lib/victoria-metrics.ts | 23 ++++++++++++++- .../victoria-metrics-service-metrics.test.ts | 28 ++++++++++++++++--- 3 files changed, 57 insertions(+), 5 deletions(-) diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 484fcf4c..3931e2da 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -159,6 +159,14 @@ const STATUS_CODE_COLOR_PALETTES: Record = { default: ["#64748b", "#0ea5e9", "#a855f7", "#71717a"], }; +const STATUS_FAMILY_COLORS: Record = { + "2xx": "#10b981", + "3xx": "#6366f1", + "4xx": "#f59e0b", + "5xx": "#ef4444", + unknown: "#64748b", +}; + export function ServiceDetailsOverview({ service }: { service: Service }) { const { proxyDomain } = useService(); const overview = useMemo( @@ -878,6 +886,9 @@ function getStatusDataKeyBase(status: string): string { } function getStatusColor(status: string, index: number): string { + const familyColor = STATUS_FAMILY_COLORS[status]; + if (familyColor) return familyColor; + const palette = STATUS_CODE_COLOR_PALETTES[status.charAt(0)] ?? STATUS_CODE_COLOR_PALETTES.default; diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index 9fcafbb0..806a8717 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -408,7 +408,7 @@ export async function queryServiceMetrics(options: { const statusCodes = new Set(); for (const result of requestResults) { - const status = result.metric.code || "unknown"; + const status = normalizeHTTPStatusFamily(result.metric.code); statusCodes.add(status); for (const point of matrixResultToPoints(result)) { const bucket = bucketsByTimestamp.get(point.timestamp); @@ -757,7 +757,23 @@ function matrixResultToPoints(result: { } function sortStatusCodes(statuses: string[]): string[] { + const statusOrder = new Map([ + ["2xx", 0], + ["3xx", 1], + ["4xx", 2], + ["5xx", 3], + ["unknown", 4], + ]); + return Array.from(new Set(statuses)).sort((a, b) => { + const orderA = statusOrder.get(a); + const orderB = statusOrder.get(b); + if (orderA !== undefined && orderB !== undefined) { + return orderA - orderB; + } + if (orderA !== undefined) return -1; + if (orderB !== undefined) return 1; + const statusA = Number(a); const statusB = Number(b); if (Number.isFinite(statusA) && Number.isFinite(statusB)) { @@ -767,6 +783,11 @@ function sortStatusCodes(statuses: string[]): string[] { }); } +function normalizeHTTPStatusFamily(code: string | undefined): string { + if (!code || !/^[2-5]\d\d$/.test(code)) return "unknown"; + return `${code.charAt(0)}xx`; +} + function escapePromQL(value: string) { return value .replace(/\\/g, "\\\\") diff --git a/web/tests/victoria-metrics-service-metrics.test.ts b/web/tests/victoria-metrics-service-metrics.test.ts index fc6464ea..40010cc2 100644 --- a/web/tests/victoria-metrics-service-metrics.test.ts +++ b/web/tests/victoria-metrics-service-metrics.test.ts @@ -66,6 +66,26 @@ describe("VictoriaMetrics service metrics", () => { metric: { code: "200" }, values: [[END_TS, "3"]], }, + { + metric: { code: "204" }, + values: [[END_TS, "2"]], + }, + { + metric: { code: "301" }, + values: [[END_TS, "1"]], + }, + { + metric: { code: "404" }, + values: [[END_TS, "4"]], + }, + { + metric: { code: "500" }, + values: [[END_TS, "5"]], + }, + { + metric: { code: "502" }, + values: [[END_TS, "6"]], + }, ]); } if (query.includes("histogram_quantile(0.95")) { @@ -102,16 +122,16 @@ describe("VictoriaMetrics service metrics", () => { expect( starts.every((start) => start === String(END_TS - 24 * 60 * 60 + 5 * 60)), ).toBe(true); - expect(stats.totalRequests).toBe(3); - expect(stats.statusCodes).toEqual(["200"]); + expect(stats.totalRequests).toBe(21); + expect(stats.statusCodes).toEqual(["2xx", "3xx", "4xx", "5xx"]); expect(stats.buckets).toHaveLength(288); expect(stats.windowEnd).toBe("2026-07-02T12:30:00.000Z"); expect(stats.buckets[0]?.timestamp).toBe("2026-07-01T12:35:00.000Z"); const lastBucket = stats.buckets.at(-1); expect(lastBucket).toMatchObject({ - totalRequests: 3, - statuses: { "200": 3 }, + totalRequests: 21, + statuses: { "2xx": 5, "3xx": 1, "4xx": 4, "5xx": 11 }, p95ResponseTimeMs: 123, egressBytesPerSecond: 2048, cpuUsagePercent: 42, From 7a0934eaf91a47c304e4a0ed3357ecc51a8d191f Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:48:27 +1000 Subject: [PATCH 10/18] Require 2FA before deleting services --- web/actions/projects.ts | 64 ++++++++++- .../[serviceId]/configuration/page.tsx | 106 +++++++++++++++++- web/lib/service-delete-confirmation.ts | 29 +++++ web/lib/two-factor.ts | 4 + web/tests/service-delete-confirmation.test.ts | 36 ++++++ web/tests/two-factor.test.ts | 6 + 6 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 web/lib/service-delete-confirmation.ts create mode 100644 web/tests/service-delete-confirmation.test.ts diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 828a2575..6c73ba39 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -4,6 +4,7 @@ import { randomUUID } from "node:crypto"; import cronstrue from "cronstrue"; import { and, desc, eq, inArray, isNotNull, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; +import { headers } from "next/headers"; import { ZodError, z } from "zod"; import { db } from "@/db"; import { @@ -28,7 +29,7 @@ import { volumeBackups, workQueue, } from "@/db/schema"; -import { requireDeveloperRole } from "@/lib/auth"; +import { auth, requireDeveloperRole } from "@/lib/auth"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; import { deployServiceInternal } from "@/lib/deploy-service"; import { @@ -47,15 +48,27 @@ import { nameSchema, volumeNameSchema, } from "@/lib/schemas"; -import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; import type { PortConfig, HealthCheckConfig as ServiceHealthCheckConfig, } from "@/lib/service-config"; +import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; +import { + getServiceDeleteConfirmation, + type ServiceDeleteConfirmation, +} from "@/lib/service-delete-confirmation"; import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; +type AuthenticatedDeveloperSession = Awaited< + ReturnType +>; + +type TwoFactorSessionUser = { + twoFactorEnabled?: boolean | null; +}; + function isValidImageReferencePart(reference: string): boolean { const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; @@ -574,8 +587,51 @@ async function hardDeleteService(serviceId: string) { return { success: true }; } -export async function deleteService(serviceId: string) { - await requireDeveloperRole(); +function hasTwoFactorEnabled(session: AuthenticatedDeveloperSession) { + return Boolean( + (session?.user as TwoFactorSessionUser | undefined)?.twoFactorEnabled, + ); +} + +async function verifyServiceDeleteConfirmation( + session: AuthenticatedDeveloperSession, + confirmation?: ServiceDeleteConfirmation, +) { + const normalizedConfirmation = getServiceDeleteConfirmation( + hasTwoFactorEnabled(session), + confirmation, + ); + + if (!normalizedConfirmation) return; + + const requestHeaders = await headers(); + + try { + const passwordVerification = await auth.api.verifyPassword({ + body: { password: normalizedConfirmation.password }, + headers: requestHeaders, + }); + + if (passwordVerification.status !== true) { + throw new Error("Invalid password"); + } + + await auth.api.verifyTOTP({ + body: { code: normalizedConfirmation.totpCode }, + headers: requestHeaders, + }); + } catch { + throw new Error("Invalid password or authenticator code"); + } +} + +export async function deleteService( + serviceId: string, + confirmation?: ServiceDeleteConfirmation, +) { + const session = await requireDeveloperRole(); + await verifyServiceDeleteConfirmation(session, confirmation); + const service = await getService(serviceId); if (!service) { throw new Error("Service not found"); diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index 77700a12..ad73c4ba 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -30,10 +30,19 @@ import { AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/lib/auth-client"; +import { normalizeTwoFactorCode } from "@/lib/two-factor"; const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const; +type TwoFactorSessionUser = { + twoFactorEnabled?: boolean | null; +}; + function formatBackupDate(value: Date | string | null | undefined) { if (!value) return "an unknown time"; return new Date(value).toLocaleString(); @@ -42,8 +51,19 @@ function formatBackupDate(value: Date | string | null | undefined) { export default function ConfigurationPage() { const router = useRouter(); const { mutate: globalMutate } = useSWRConfig(); + const { data: session, isPending: isSessionLoading } = useSession(); + const sessionUser = session?.user as TwoFactorSessionUser | undefined; const { service, projectSlug, envName, proxyDomain, onUpdate } = useService(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletePassword, setDeletePassword] = useState(""); + const [deleteTotpCode, setDeleteTotpCode] = useState(""); const [isDeleting, setIsDeleting] = useState(false); + const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled); + const normalizedDeleteTotpCode = normalizeTwoFactorCode(deleteTotpCode); + const isDeleteConfirmationIncomplete = + requiresDeleteConfirmation && + (deletePassword.trim().length === 0 || + normalizedDeleteTotpCode.length === 0); const hasActiveDeploymentForBackup = service.deployments.some( (deployment) => ACTIVE_DELETE_BACKUP_STATUSES.includes( @@ -63,15 +83,39 @@ export default function ConfigurationPage() { toast.info("Changes saved. Deploy to apply them."); }, [onUpdate]); + const resetDeleteConfirmation = useCallback(() => { + setDeletePassword(""); + setDeleteTotpCode(""); + }, []); + const handleDelete = async () => { + if (isDeleteConfirmationIncomplete) { + toast.error("Enter your current password and authenticator code"); + return; + } + setIsDeleting(true); try { - await deleteService(service.id); + await deleteService( + service.id, + requiresDeleteConfirmation + ? { + password: deletePassword, + totpCode: normalizedDeleteTotpCode, + } + : undefined, + ); await globalMutate(`/api/projects/${service.projectId}/services`); toast.success( service.stateful ? "Delete workflow started" : "Service deleted", ); + setDeleteDialogOpen(false); + resetDeleteConfirmation(); router.push(`/dashboard/projects/${projectSlug}/${envName}`); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete service", + ); } finally { setIsDeleting(false); } @@ -120,11 +164,17 @@ export default function ConfigurationPage() { : "Once deleted, this service and all its deployments will be permanently removed."}

- + { + setDeleteDialogOpen(open); + if (!open && !isDeleting) resetDeleteConfirmation(); + }} + > }> Delete Service - + Delete {service.name}? @@ -167,15 +217,63 @@ export default function ConfigurationPage() { ) : ( "This action cannot be undone. This will permanently delete the service and all its deployments." )} + {requiresDeleteConfirmation && ( + <> +
+
+ Enter your current password and authenticator code to + confirm this deletion. + + )}
+ {requiresDeleteConfirmation && ( +
+
+ + + setDeletePassword(event.target.value) + } + disabled={isDeleting} + /> +
+
+ + + setDeleteTotpCode(event.target.value) + } + placeholder="123456" + disabled={isDeleting} + /> +
+
+ )} Cancel + {isDeleting ? : null} {isDeleting ? "Deleting..." : "Delete"} diff --git a/web/lib/service-delete-confirmation.ts b/web/lib/service-delete-confirmation.ts new file mode 100644 index 00000000..d71d0ff3 --- /dev/null +++ b/web/lib/service-delete-confirmation.ts @@ -0,0 +1,29 @@ +import { normalizeTwoFactorCode } from "@/lib/two-factor"; + +export type ServiceDeleteConfirmation = { + password?: string; + totpCode?: string; +}; + +export type NormalizedServiceDeleteConfirmation = { + password: string; + totpCode: string; +}; + +export function getServiceDeleteConfirmation( + twoFactorEnabled: boolean, + confirmation?: ServiceDeleteConfirmation, +): NormalizedServiceDeleteConfirmation | null { + if (!twoFactorEnabled) return null; + + const password = confirmation?.password?.trim() ?? ""; + const totpCode = normalizeTwoFactorCode(confirmation?.totpCode); + + if (!password || !totpCode) { + throw new Error( + "Password and authenticator code are required to delete this service", + ); + } + + return { password, totpCode }; +} diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts index c45e0502..11253b4e 100644 --- a/web/lib/two-factor.ts +++ b/web/lib/two-factor.ts @@ -37,3 +37,7 @@ export function getTotpSecret(totpURI: string) { return ""; } } + +export function normalizeTwoFactorCode(value: string | null | undefined) { + return (value ?? "").replace(/\s/g, ""); +} diff --git a/web/tests/service-delete-confirmation.test.ts b/web/tests/service-delete-confirmation.test.ts new file mode 100644 index 00000000..47be1f00 --- /dev/null +++ b/web/tests/service-delete-confirmation.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { getServiceDeleteConfirmation } from "@/lib/service-delete-confirmation"; + +describe("service delete confirmation", () => { + it("does not require confirmation when 2FA is disabled", () => { + expect(getServiceDeleteConfirmation(false)).toBeNull(); + }); + + it("requires password and authenticator code when 2FA is enabled", () => { + expect(() => getServiceDeleteConfirmation(true)).toThrow( + "Password and authenticator code are required to delete this service", + ); + expect(() => + getServiceDeleteConfirmation(true, { password: "secret" }), + ).toThrow( + "Password and authenticator code are required to delete this service", + ); + expect(() => + getServiceDeleteConfirmation(true, { totpCode: "123456" }), + ).toThrow( + "Password and authenticator code are required to delete this service", + ); + }); + + it("trims password and normalizes authenticator code", () => { + expect( + getServiceDeleteConfirmation(true, { + password: " secret ", + totpCode: "123 456", + }), + ).toEqual({ + password: "secret", + totpCode: "123456", + }); + }); +}); diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts index 2a25755f..c8b24107 100644 --- a/web/tests/two-factor.test.ts +++ b/web/tests/two-factor.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_AUTH_REDIRECT, getSafeAuthRedirect, getTotpSecret, + normalizeTwoFactorCode, } from "@/lib/two-factor"; describe("two-factor helpers", () => { @@ -35,4 +36,9 @@ describe("two-factor helpers", () => { it("returns an empty secret for malformed TOTP URIs", () => { expect(getTotpSecret("not a uri")).toBe(""); }); + + it("normalizes two-factor codes by removing whitespace", () => { + expect(normalizeTwoFactorCode(" 123 456\n")).toBe("123456"); + expect(normalizeTwoFactorCode(null)).toBe(""); + }); }); From 7066f158c0e54241ca0851b126e5741cdd91925f Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:55:19 +1000 Subject: [PATCH 11/18] Inline service delete 2FA check --- web/actions/projects.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 6c73ba39..39d1a0f9 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -65,10 +65,6 @@ type AuthenticatedDeveloperSession = Awaited< ReturnType >; -type TwoFactorSessionUser = { - twoFactorEnabled?: boolean | null; -}; - function isValidImageReferencePart(reference: string): boolean { const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; @@ -587,18 +583,15 @@ async function hardDeleteService(serviceId: string) { return { success: true }; } -function hasTwoFactorEnabled(session: AuthenticatedDeveloperSession) { - return Boolean( - (session?.user as TwoFactorSessionUser | undefined)?.twoFactorEnabled, - ); -} - async function verifyServiceDeleteConfirmation( session: AuthenticatedDeveloperSession, confirmation?: ServiceDeleteConfirmation, ) { const normalizedConfirmation = getServiceDeleteConfirmation( - hasTwoFactorEnabled(session), + Boolean( + (session?.user as { twoFactorEnabled?: boolean | null } | undefined) + ?.twoFactorEnabled, + ), confirmation, ); From 5bd3fcffe72046a1b4fc59908b74c997ed028fb6 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:34:35 +1000 Subject: [PATCH 12/18] Simplify service delete confirmation --- web/actions/projects.ts | 32 ++++++++++------- .../[serviceId]/configuration/page.tsx | 3 +- web/lib/service-delete-confirmation.ts | 29 --------------- web/lib/two-factor.ts | 4 --- web/tests/service-delete-confirmation.test.ts | 36 ------------------- web/tests/two-factor.test.ts | 6 ---- 6 files changed, 20 insertions(+), 90 deletions(-) delete mode 100644 web/lib/service-delete-confirmation.ts delete mode 100644 web/tests/service-delete-confirmation.test.ts diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 39d1a0f9..552903d2 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -53,10 +53,6 @@ import type { HealthCheckConfig as ServiceHealthCheckConfig, } from "@/lib/service-config"; import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; -import { - getServiceDeleteConfirmation, - type ServiceDeleteConfirmation, -} from "@/lib/service-delete-confirmation"; import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; @@ -65,6 +61,11 @@ type AuthenticatedDeveloperSession = Awaited< ReturnType >; +type ServiceDeleteConfirmation = { + password?: string; + totpCode?: string; +}; + function isValidImageReferencePart(reference: string): boolean { const tagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/; const digestPattern = /^[A-Za-z0-9_+.-]+:[0-9a-fA-F]{32,256}$/; @@ -587,21 +588,26 @@ async function verifyServiceDeleteConfirmation( session: AuthenticatedDeveloperSession, confirmation?: ServiceDeleteConfirmation, ) { - const normalizedConfirmation = getServiceDeleteConfirmation( - Boolean( - (session?.user as { twoFactorEnabled?: boolean | null } | undefined) - ?.twoFactorEnabled, - ), - confirmation, + const twoFactorEnabled = Boolean( + (session?.user as { twoFactorEnabled?: boolean | null } | undefined) + ?.twoFactorEnabled, ); + if (!twoFactorEnabled) return; + + const password = confirmation?.password?.trim() ?? ""; + const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, ""); - if (!normalizedConfirmation) return; + if (!password || !totpCode) { + throw new Error( + "Password and authenticator code are required to delete this service", + ); + } const requestHeaders = await headers(); try { const passwordVerification = await auth.api.verifyPassword({ - body: { password: normalizedConfirmation.password }, + body: { password }, headers: requestHeaders, }); @@ -610,7 +616,7 @@ async function verifyServiceDeleteConfirmation( } await auth.api.verifyTOTP({ - body: { code: normalizedConfirmation.totpCode }, + body: { code: totpCode }, headers: requestHeaders, }); } catch { diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index ad73c4ba..917883be 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -35,7 +35,6 @@ import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { useSession } from "@/lib/auth-client"; -import { normalizeTwoFactorCode } from "@/lib/two-factor"; const ACTIVE_DELETE_BACKUP_STATUSES = ["running", "healthy"] as const; @@ -59,7 +58,7 @@ export default function ConfigurationPage() { const [deleteTotpCode, setDeleteTotpCode] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled); - const normalizedDeleteTotpCode = normalizeTwoFactorCode(deleteTotpCode); + const normalizedDeleteTotpCode = deleteTotpCode.replace(/\s/g, ""); const isDeleteConfirmationIncomplete = requiresDeleteConfirmation && (deletePassword.trim().length === 0 || diff --git a/web/lib/service-delete-confirmation.ts b/web/lib/service-delete-confirmation.ts deleted file mode 100644 index d71d0ff3..00000000 --- a/web/lib/service-delete-confirmation.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { normalizeTwoFactorCode } from "@/lib/two-factor"; - -export type ServiceDeleteConfirmation = { - password?: string; - totpCode?: string; -}; - -export type NormalizedServiceDeleteConfirmation = { - password: string; - totpCode: string; -}; - -export function getServiceDeleteConfirmation( - twoFactorEnabled: boolean, - confirmation?: ServiceDeleteConfirmation, -): NormalizedServiceDeleteConfirmation | null { - if (!twoFactorEnabled) return null; - - const password = confirmation?.password?.trim() ?? ""; - const totpCode = normalizeTwoFactorCode(confirmation?.totpCode); - - if (!password || !totpCode) { - throw new Error( - "Password and authenticator code are required to delete this service", - ); - } - - return { password, totpCode }; -} diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts index 11253b4e..c45e0502 100644 --- a/web/lib/two-factor.ts +++ b/web/lib/two-factor.ts @@ -37,7 +37,3 @@ export function getTotpSecret(totpURI: string) { return ""; } } - -export function normalizeTwoFactorCode(value: string | null | undefined) { - return (value ?? "").replace(/\s/g, ""); -} diff --git a/web/tests/service-delete-confirmation.test.ts b/web/tests/service-delete-confirmation.test.ts deleted file mode 100644 index 47be1f00..00000000 --- a/web/tests/service-delete-confirmation.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { getServiceDeleteConfirmation } from "@/lib/service-delete-confirmation"; - -describe("service delete confirmation", () => { - it("does not require confirmation when 2FA is disabled", () => { - expect(getServiceDeleteConfirmation(false)).toBeNull(); - }); - - it("requires password and authenticator code when 2FA is enabled", () => { - expect(() => getServiceDeleteConfirmation(true)).toThrow( - "Password and authenticator code are required to delete this service", - ); - expect(() => - getServiceDeleteConfirmation(true, { password: "secret" }), - ).toThrow( - "Password and authenticator code are required to delete this service", - ); - expect(() => - getServiceDeleteConfirmation(true, { totpCode: "123456" }), - ).toThrow( - "Password and authenticator code are required to delete this service", - ); - }); - - it("trims password and normalizes authenticator code", () => { - expect( - getServiceDeleteConfirmation(true, { - password: " secret ", - totpCode: "123 456", - }), - ).toEqual({ - password: "secret", - totpCode: "123456", - }); - }); -}); diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts index c8b24107..2a25755f 100644 --- a/web/tests/two-factor.test.ts +++ b/web/tests/two-factor.test.ts @@ -3,7 +3,6 @@ import { DEFAULT_AUTH_REDIRECT, getSafeAuthRedirect, getTotpSecret, - normalizeTwoFactorCode, } from "@/lib/two-factor"; describe("two-factor helpers", () => { @@ -36,9 +35,4 @@ describe("two-factor helpers", () => { it("returns an empty secret for malformed TOTP URIs", () => { expect(getTotpSecret("not a uri")).toBe(""); }); - - it("normalizes two-factor codes by removing whitespace", () => { - expect(normalizeTwoFactorCode(" 123 456\n")).toBe("123456"); - expect(normalizeTwoFactorCode(null)).toBe(""); - }); }); From 69ae8deffd45d07c4e2e502a2156a53205a26826 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:50:25 +1000 Subject: [PATCH 13/18] Use auth code only for service delete confirmation --- web/actions/projects.ts | 55 +++++-------------- .../[serviceId]/configuration/page.tsx | 27 +-------- 2 files changed, 18 insertions(+), 64 deletions(-) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 552903d2..1f598302 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -57,12 +57,7 @@ import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; -type AuthenticatedDeveloperSession = Awaited< - ReturnType ->; - type ServiceDeleteConfirmation = { - password?: string; totpCode?: string; }; @@ -584,52 +579,32 @@ async function hardDeleteService(serviceId: string) { return { success: true }; } -async function verifyServiceDeleteConfirmation( - session: AuthenticatedDeveloperSession, +export async function deleteService( + serviceId: string, confirmation?: ServiceDeleteConfirmation, ) { + const session = await requireDeveloperRole(); const twoFactorEnabled = Boolean( (session?.user as { twoFactorEnabled?: boolean | null } | undefined) ?.twoFactorEnabled, ); - if (!twoFactorEnabled) return; - - const password = confirmation?.password?.trim() ?? ""; - const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, ""); - - if (!password || !totpCode) { - throw new Error( - "Password and authenticator code are required to delete this service", - ); - } - const requestHeaders = await headers(); - - try { - const passwordVerification = await auth.api.verifyPassword({ - body: { password }, - headers: requestHeaders, - }); + if (twoFactorEnabled) { + const totpCode = (confirmation?.totpCode ?? "").replace(/\s/g, ""); - if (passwordVerification.status !== true) { - throw new Error("Invalid password"); + if (!totpCode) { + throw new Error("Authenticator code is required to delete this service"); } - await auth.api.verifyTOTP({ - body: { code: totpCode }, - headers: requestHeaders, - }); - } catch { - throw new Error("Invalid password or authenticator code"); + try { + await auth.api.verifyTOTP({ + body: { code: totpCode }, + headers: await headers(), + }); + } catch { + throw new Error("Invalid authenticator code"); + } } -} - -export async function deleteService( - serviceId: string, - confirmation?: ServiceDeleteConfirmation, -) { - const session = await requireDeveloperRole(); - await verifyServiceDeleteConfirmation(session, confirmation); const service = await getService(serviceId); if (!service) { diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx index 917883be..8b334d79 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/configuration/page.tsx @@ -54,15 +54,12 @@ export default function ConfigurationPage() { const sessionUser = session?.user as TwoFactorSessionUser | undefined; const { service, projectSlug, envName, proxyDomain, onUpdate } = useService(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [deletePassword, setDeletePassword] = useState(""); const [deleteTotpCode, setDeleteTotpCode] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled); const normalizedDeleteTotpCode = deleteTotpCode.replace(/\s/g, ""); const isDeleteConfirmationIncomplete = - requiresDeleteConfirmation && - (deletePassword.trim().length === 0 || - normalizedDeleteTotpCode.length === 0); + requiresDeleteConfirmation && normalizedDeleteTotpCode.length === 0; const hasActiveDeploymentForBackup = service.deployments.some( (deployment) => ACTIVE_DELETE_BACKUP_STATUSES.includes( @@ -83,13 +80,12 @@ export default function ConfigurationPage() { }, [onUpdate]); const resetDeleteConfirmation = useCallback(() => { - setDeletePassword(""); setDeleteTotpCode(""); }, []); const handleDelete = async () => { if (isDeleteConfirmationIncomplete) { - toast.error("Enter your current password and authenticator code"); + toast.error("Enter your authenticator code"); return; } @@ -99,7 +95,6 @@ export default function ConfigurationPage() { service.id, requiresDeleteConfirmation ? { - password: deletePassword, totpCode: normalizedDeleteTotpCode, } : undefined, @@ -220,29 +215,13 @@ export default function ConfigurationPage() { <>

- Enter your current password and authenticator code to - confirm this deletion. + Enter your authenticator code to confirm this deletion. )} {requiresDeleteConfirmation && (
-
- - - setDeletePassword(event.target.value) - } - disabled={isDeleting} - /> -
)} diff --git a/web/app/globals.css b/web/app/globals.css index 24cec57e..33dea139 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -8,8 +8,8 @@ --font-sans: var(--font-inter), ui-sans-serif, system-ui, sans-serif; --font-sans--font-feature-settings: "cv11", "ss03"; --font-mono: - var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, "SF Mono", - Menlo, Consolas, monospace; + var(--font-ioskeley-mono), ui-monospace, SFMono-Regular, "SF Mono", Menlo, + Consolas, monospace; --color-background: var(--background); --color-foreground: var(--foreground); --color-sidebar-ring: var(--sidebar-ring); @@ -76,6 +76,7 @@ @theme { --animate-shimmer: shimmer 1.4s ease-in-out infinite; + --animate-caret-blink: caret-blink 1.2s ease-out infinite; @keyframes shimmer { 0% { @@ -85,6 +86,18 @@ transform: translateX(300%); } } + + @keyframes caret-blink { + 0%, + 70%, + 100% { + opacity: 1; + } + 20%, + 50% { + opacity: 0; + } + } } @layer theme { diff --git a/web/components/ui/input-otp.tsx b/web/components/ui/input-otp.tsx new file mode 100644 index 00000000..c2258750 --- /dev/null +++ b/web/components/ui/input-otp.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { OTPInput, OTPInputContext } from "input-otp"; +import { MinusIcon } from "lucide-react"; +import * as React from "react"; +import { cn } from "@/lib/utils"; + +function InputOTP({ + className, + containerClassName, + ...props +}: React.ComponentProps & { + containerClassName?: string; +}) { + return ( + + ); +} + +function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function InputOTPSlot({ + index, + className, + ...props +}: React.ComponentProps<"div"> & { + index: number; +}) { + const inputOTPContext = React.useContext(OTPInputContext); + const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}; + + return ( +
+ {char} + {hasFakeCaret && ( +
+
+
+ )} +
+ ); +} + +function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { + return ( + + ); +} + +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }; diff --git a/web/package.json b/web/package.json index 69ff99b6..8bae61e0 100644 --- a/web/package.json +++ b/web/package.json @@ -29,6 +29,7 @@ "cronstrue": "^3.9.0", "drizzle-orm": "^0.45.1", "inngest": "^4.3.0", + "input-otp": "^1.4.2", "ip-address": "^10.1.0", "jose": "^6.1.3", "lucide-react": "^0.562.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 37609859..83d1aea3 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -54,6 +54,9 @@ importers: inngest: specifier: ^4.3.0 version: 4.6.0(@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1))(express@5.2.1)(hono@4.12.25)(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(typescript@5.9.3)(zod@4.4.3) + input-otp: + specifier: ^1.4.2 + version: 1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7) ip-address: specifier: ^10.1.0 version: 10.2.0 @@ -4239,6 +4242,12 @@ packages: typescript: optional: true + input-otp@1.4.2: + resolution: {integrity: sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} @@ -10259,6 +10268,11 @@ snapshots: - encoding - supports-color + input-otp@1.4.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + internal-slot@1.1.0: dependencies: es-errors: 1.3.0 From fd6ddd877b3d0e9531b3e26f4b5a71fa09a96d91 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:03:53 +1000 Subject: [PATCH 15/18] Use OTP input for auth codes --- .../auth/two-factor-challenge-page.tsx | 56 +++++++++++++++---- .../settings/two-factor-settings.tsx | 28 ++++++++-- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/web/components/auth/two-factor-challenge-page.tsx b/web/components/auth/two-factor-challenge-page.tsx index f9eb22f0..e23ee920 100644 --- a/web/components/auth/two-factor-challenge-page.tsx +++ b/web/components/auth/two-factor-challenge-page.tsx @@ -1,5 +1,6 @@ "use client"; +import { REGEXP_ONLY_DIGITS } from "input-otp"; import Image from "next/image"; import Link from "next/link"; import { useRouter, useSearchParams } from "next/navigation"; @@ -14,6 +15,11 @@ import { CardTitle, } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; @@ -145,16 +151,39 @@ export function TwoFactorChallengePage() { - setCode(event.target.value)} - placeholder={mode === "totp" ? "123456" : "XXXX-XXXX"} - required - autoFocus - /> + {mode === "totp" ? ( + setCode(value.replace(/\D/g, ""))} + required + autoFocus + > + + + + + + + + + + ) : ( + setCode(event.target.value)} + placeholder="XXXX-XXXX" + required + autoFocus + /> + )}
@@ -174,7 +203,12 @@ export function TwoFactorChallengePage() {
)} - Cancel + + Cancel +