diff --git a/compose.dev.yml b/compose.dev.yml index 070d6302..a67bb479 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -1,6 +1,6 @@ services: postgres: - image: postgres:16-alpine + image: postgres:18-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -8,7 +8,7 @@ services: ports: - "5432:5432" volumes: - - postgres_data:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 10s diff --git a/deployment/compose.postgres.yml b/deployment/compose.postgres.yml index c6e1d3a0..2ca6c88e 100644 --- a/deployment/compose.postgres.yml +++ b/deployment/compose.postgres.yml @@ -52,7 +52,7 @@ services: restart: unless-stopped postgres: - image: postgres:16 + image: postgres:18 env_file: - ./.env environment: @@ -60,7 +60,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - POSTGRES_DB=${POSTGRES_DB} volumes: - - postgres-data:/var/lib/postgresql/data + - postgres-data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] interval: 30s diff --git a/docs/infrastructure/logging.mdx b/docs/infrastructure/logging.mdx index 911752f5..0f060c3c 100644 --- a/docs/infrastructure/logging.mdx +++ b/docs/infrastructure/logging.mdx @@ -40,3 +40,5 @@ The control plane exposes logs at `https://logs.` with basic auth. ## Accessing Logs Logs are accessible from the web UI for each service, deployment, build, and server. The control plane queries Victoria Logs using LogSQL with filters for `service_id`, `deployment_id`, `server_id`, and time ranges. + +Searches run against Victoria Logs rather than only the entries currently loaded in the browser. Continuous service, request, and server log views default to the last 24 hours and support 1-hour, 6-hour, 24-hour, and 7-day ranges. These query ranges do not change the separate `VL_RETENTION` storage setting. diff --git a/web/actions/members.ts b/web/actions/members.ts index c2374375..12f250c5 100644 --- a/web/actions/members.ts +++ b/web/actions/members.ts @@ -11,6 +11,7 @@ import { db } from "@/db"; import { account, memberInvitations, user } from "@/db/schema"; import type { InvitableMemberRole } from "@/db/types"; import { auth, requireAdminRole } from "@/lib/auth"; +import { addMilliseconds, DAY_IN_MILLISECONDS, isExpired } from "@/lib/date"; import { sendMemberInviteEmail } from "@/lib/email"; import { createInviteToken, @@ -18,7 +19,7 @@ import { isInvitableMemberRole, } from "@/lib/members"; -const INVITE_EXPIRY_MS = 1000 * 60 * 60 * 24 * 7; +const INVITE_EXPIRY_MS = 7 * DAY_IN_MILLISECONDS; const inviteMemberSchema = z.object({ email: z.string().trim().email(), @@ -50,10 +51,6 @@ const createAuthUser = auth.api.createUser as ( data: CreateUserInput, ) => Promise; -function isExpired(expiresAt: Date) { - return expiresAt.getTime() <= Date.now(); -} - async function requireAdminSession() { const session = await requireAdminRole(); if (!session) { @@ -191,7 +188,7 @@ export async function inviteMember(input: { } const inviteUrl = `${baseUrl}/invite/${encodeURIComponent(token)}`; - const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS); + const expiresAt = addMilliseconds(new Date(), INVITE_EXPIRY_MS); await db.insert(memberInvitations).values({ id: randomUUID(), diff --git a/web/actions/migrations.ts b/web/actions/migrations.ts index 226839a9..56ec46d0 100644 --- a/web/actions/migrations.ts +++ b/web/actions/migrations.ts @@ -7,17 +7,6 @@ import { services } from "@/db/schema"; import { requireDeveloperRole } from "@/lib/auth"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; -import { startMigrationInternal } from "@/lib/migrations"; - -export async function startMigration( - serviceId: string, - targetServerId: string, -) { - await requireDeveloperRole(); - await startMigrationInternal(serviceId, targetServerId); - revalidatePath(`/dashboard/projects`); - return { success: true }; -} export async function cancelMigration(serviceId: string) { await requireDeveloperRole(); diff --git a/web/actions/projects.ts b/web/actions/projects.ts index a384a25f..3d8385aa 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -1,11 +1,9 @@ "use server"; import { randomUUID } from "node:crypto"; -import { isAPIError } from "better-auth/api"; 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 { @@ -30,7 +28,7 @@ import { volumeBackups, workQueue, } from "@/db/schema"; -import { auth, requireDeveloperRole } from "@/lib/auth"; +import { requireDeveloperRole, verifyDeleteConfirmation } from "@/lib/auth"; import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; import { deployServiceInternal } from "@/lib/deploy-service"; import { @@ -54,14 +52,11 @@ import type { HealthCheckConfig as ServiceHealthCheckConfig, } from "@/lib/service-config"; import { MIN_SERVERLESS_SLEEP_AFTER_SECONDS } from "@/lib/service-config"; +import type { DeleteConfirmation } from "@/lib/two-factor"; import { getZodErrorMessage, slugify } from "@/lib/utils"; import { enqueueWork } from "@/lib/work-queue"; import { deleteBackup } from "./backups"; -type ServiceDeleteConfirmation = { - 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}$/; @@ -268,8 +263,12 @@ export async function createProject(name: string) { } } -export async function deleteProject(id: string) { - await requireDeveloperRole(); +export async function deleteProject( + id: string, + confirmation?: DeleteConfirmation, +) { + const session = await requireDeveloperRole(); + await verifyDeleteConfirmation(session, confirmation, "project"); const projectServices = await db .select() .from(services) @@ -582,36 +581,10 @@ async function hardDeleteService(serviceId: string) { export async function deleteService( serviceId: string, - confirmation?: ServiceDeleteConfirmation, + confirmation?: DeleteConfirmation, ) { const session = await requireDeveloperRole(); - if (!session) { - throw new Error("Unauthorized"); - } - - const twoFactorEnabled = Boolean( - (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled, - ); - - if (twoFactorEnabled) { - const totpCode = confirmation?.totpCode ?? ""; - - if (!/^\d{6}$/.test(totpCode)) { - throw new Error("Authenticator code is required to delete this service"); - } - - try { - await auth.api.verifyTOTP({ - body: { code: totpCode }, - headers: await headers(), - }); - } catch (error) { - if (isAPIError(error)) { - throw new Error("Invalid authenticator code"); - } - throw error; - } - } + await verifyDeleteConfirmation(session, confirmation, "service"); const service = await getService(serviceId); if (!service) { diff --git a/web/actions/servers.ts b/web/actions/servers.ts index 1868e47e..f0014f54 100644 --- a/web/actions/servers.ts +++ b/web/actions/servers.ts @@ -7,8 +7,9 @@ import { ZodError } from "zod"; import { db } from "@/db"; import { servers } from "@/db/schema"; import { enqueueAgentUpgrade } from "@/lib/agent-upgrades"; -import { requireDeveloperRole } from "@/lib/auth"; +import { requireDeveloperRole, verifyDeleteConfirmation } from "@/lib/auth"; import { nameSchema } from "@/lib/schemas"; +import type { DeleteConfirmation } from "@/lib/two-factor"; import { getZodErrorMessage } from "@/lib/utils"; function generateId(): string { @@ -48,8 +49,12 @@ export async function createServer(name: string) { } } -export async function deleteServer(id: string) { - await requireDeveloperRole(); +export async function deleteServer( + id: string, + confirmation?: DeleteConfirmation, +) { + const session = await requireDeveloperRole(); + await verifyDeleteConfirmation(session, confirmation, "server"); await db.delete(servers).where(eq(servers.id, id)); } 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 138c0c28..35d98e0e 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 @@ -1,12 +1,13 @@ "use client"; -import { REGEXP_ONLY_DIGITS } from "input-otp"; import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useState } from "react"; +import { useCallback } from "react"; import { toast } from "sonner"; import { useSWRConfig } from "swr"; import { deleteService } from "@/actions/projects"; +import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-dialog"; +import { LocalDate } from "@/components/core/local-date"; import { HealthCheckSection } from "@/components/service/details/health-check-section"; import { PortsSection } from "@/components/service/details/ports-section"; import { ReplicasSection } from "@/components/service/details/replicas-section"; @@ -19,51 +20,15 @@ import { StartCommandSection } from "@/components/service/details/start-command- import { TCPProxySection } from "@/components/service/details/tcp-proxy-section"; import { VolumesSection } from "@/components/service/details/volumes-section"; import { useService } from "@/components/service/service-layout-client"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { - InputOTP, - InputOTPGroup, - InputOTPSlot, -} from "@/components/ui/input-otp"; 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 type { DeleteConfirmation } 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(); -} - 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 [deleteTotpCode, setDeleteTotpCode] = useState(""); - const [isDeleting, setIsDeleting] = useState(false); - const requiresDeleteConfirmation = Boolean(sessionUser?.twoFactorEnabled); - const isDeleteConfirmationIncomplete = - requiresDeleteConfirmation && deleteTotpCode.length !== 6; const hasActiveDeploymentForBackup = service.deployments.some( (deployment) => ACTIVE_DELETE_BACKUP_STATUSES.includes( @@ -83,41 +48,13 @@ export default function ConfigurationPage() { toast.info("Changes saved. Deploy to apply them."); }, [onUpdate]); - const resetDeleteConfirmation = useCallback(() => { - setDeleteTotpCode(""); - }, []); - - const handleDelete = async () => { - if (isDeleteConfirmationIncomplete) { - toast.error("Enter your 6-digit authenticator code"); - return; - } - - setIsDeleting(true); - try { - await deleteService( - service.id, - requiresDeleteConfirmation - ? { - totpCode: deleteTotpCode, - } - : 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", - ); - resetDeleteConfirmation(); - } finally { - setIsDeleting(false); - } + const handleDelete = async (confirmation?: DeleteConfirmation) => { + await deleteService(service.id, confirmation); + await globalMutate(`/api/projects/${service.projectId}/services`); + toast.success( + service.stateful ? "Delete workflow started" : "Service deleted", + ); + router.push(`/dashboard/projects/${projectSlug}/${envName}`); }; return ( @@ -163,118 +100,55 @@ export default function ConfigurationPage() { : "Once deleted, this service and all its deployments will be permanently removed."}

- { - if (isDeleting) return; - setDeleteDialogOpen(open); - if (!open) resetDeleteConfirmation(); - }} - > - }> - Delete Service - - - - Delete {service.name}? - - {service.stateful ? ( - <> - This starts a backup-first delete workflow. The service - will be restorable from Deleted services until its - retention window expires. - {willReuseCompletedBackups && - hasCompletedBackupForEveryVolume && ( - <> -
-
- - This service is not currently running. - {" "} - Restore will use the latest completed backups for - its volumes. The oldest selected backup is from{" "} - {formatBackupDate( - service.deletionBackupFallback - ?.oldestLatestBackupAt, - )} - ; changes after that backup will not be restored. - - )} - {willReuseCompletedBackups && - !hasCompletedBackupForEveryVolume && ( - <> -
-
- - No completed backup is available for every - volume. - {" "} - Delete will fail unless the service is running so - a fresh deletion backup can be created. - - )} - - ) : ( - "This action cannot be undone. This will permanently delete the service and all its deployments." - )} - {requiresDeleteConfirmation && ( - <> -
-
- Enter your authenticator code to confirm this deletion. - - )} -
-
- {requiresDeleteConfirmation && ( -
-
- - - setDeleteTotpCode(value.replace(/\D/g, "")) - } - disabled={isDeleting} - > - - - - - - - - - -
-
- )} - - - Cancel - - - {isDeleting ? : null} - {isDeleting ? "Deleting..." : "Delete"} - - -
-
+ + This starts a backup-first delete workflow. The service will + be restorable from Deleted services until its retention + window expires. + {willReuseCompletedBackups && + hasCompletedBackupForEveryVolume && ( + <> +
+
+ + This service is not currently running. + {" "} + Restore will use the latest completed backups for its + volumes. The oldest selected backup is from{" "} + + ; changes after that backup will not be restored. + + )} + {willReuseCompletedBackups && + !hasCompletedBackupForEveryVolume && ( + <> +
+
+ + No completed backup is available for every volume. + {" "} + Delete will fail unless the service is running so a + fresh deletion backup can be created. + + )} + + ) : ( + "This action cannot be undone. This will permanently delete the service and all its deployments." + ) + } + fallbackError="Failed to delete service" + onDelete={handleDelete} + /> diff --git a/web/app/api/builds/[buildId]/logs/route.ts b/web/app/api/builds/[buildId]/logs/route.ts index a35ceeaf..eb68ca32 100644 --- a/web/app/api/builds/[buildId]/logs/route.ts +++ b/web/app/api/builds/[buildId]/logs/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; +import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByBuild } from "@/lib/victoria-logs"; export async function GET( @@ -6,13 +7,19 @@ export async function GET( { params }: { params: Promise<{ buildId: string }> }, ) { const { buildId } = await params; + let search: string | undefined; + try { + search = normalizeLogSearch(request.nextUrl.searchParams.get("q")); + } catch (error) { + return invalidLogQueryResponse(error); + } if (!isLoggingEnabled()) { return NextResponse.json({ logs: [] }); } try { - const { logs: rawLogs } = await queryLogsByBuild(buildId); + const { logs: rawLogs } = await queryLogsByBuild(buildId, { search }); const logs = rawLogs.map((log) => ({ timestamp: log._time, @@ -22,6 +29,9 @@ export async function GET( return NextResponse.json({ logs }); } catch (error) { console.error("Failed to fetch build logs:", error); - return NextResponse.json({ logs: [] }); + return NextResponse.json( + { message: "Failed to query build logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/cluster-metrics/route.ts b/web/app/api/cluster-metrics/route.ts index 57404906..6c355c27 100644 --- a/web/app/api/cluster-metrics/route.ts +++ b/web/app/api/cluster-metrics/route.ts @@ -1,6 +1,7 @@ import { headers } from "next/headers"; import { listServers } from "@/db/queries"; import { auth } from "@/lib/auth"; +import { subtractMilliseconds } from "@/lib/date"; import { isMetricsEnabled, METRIC_RANGE_OPTIONS, @@ -33,7 +34,7 @@ export async function GET(request: Request) { const end = new Date(); const option = METRIC_RANGE_OPTIONS[range]; - const start = new Date(end.getTime() - option.durationMs); + const start = subtractMilliseconds(end, option.durationMs); const servers = await listServers(); const selectedServers = serverId && serverId !== "all" diff --git a/web/app/api/deployments/[id]/logs/route.ts b/web/app/api/deployments/[id]/logs/route.ts index cafcfc55..f7c7c328 100644 --- a/web/app/api/deployments/[id]/logs/route.ts +++ b/web/app/api/deployments/[id]/logs/route.ts @@ -1,5 +1,10 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { + invalidLogQueryResponse, + normalizeLogCursor, + parseLogLimit, +} from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByDeployment } from "@/lib/victoria-logs"; export async function GET( @@ -20,11 +25,14 @@ export async function GET( const { id: deploymentId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "100", 10), - 1000, - ); - const after = url.searchParams.get("after") || undefined; + let limit: number; + let after: string | undefined; + try { + limit = parseLogLimit(url.searchParams.get("limit"), 100); + after = normalizeLogCursor(url.searchParams.get("after")); + } catch (error) { + return invalidLogQueryResponse(error); + } try { const result = await queryLogsByDeployment(deploymentId, limit, after); @@ -43,6 +51,9 @@ export async function GET( }); } catch (error) { console.error("[logs:deployment] failed to query logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query deployment logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts index 0aef17b1..105ad5d4 100644 --- a/web/app/api/projects/[id]/services/route.ts +++ b/web/app/api/projects/[id]/services/route.ts @@ -17,10 +17,7 @@ import { volumeBackups, } from "@/db/schema"; import { auth } from "@/lib/auth"; - -function getBackupTime(value: Date | string | null) { - return value ? new Date(value).getTime() : 0; -} +import { getTimestamp } from "@/lib/date"; export async function GET( request: Request, @@ -181,13 +178,17 @@ export async function GET( oldestLatestBackupAt: latestBackupTimes.length > 0 ? latestBackupTimes.reduce((oldest, value) => - getBackupTime(value) < getBackupTime(oldest) ? value : oldest, + getTimestamp(value, 0) < getTimestamp(oldest, 0) + ? value + : oldest, ) : null, newestLatestBackupAt: latestBackupTimes.length > 0 ? latestBackupTimes.reduce((newest, value) => - getBackupTime(value) > getBackupTime(newest) ? value : newest, + getTimestamp(value, 0) > getTimestamp(newest, 0) + ? value + : newest, ) : null, }; diff --git a/web/app/api/rollouts/[rolloutId]/logs/route.ts b/web/app/api/rollouts/[rolloutId]/logs/route.ts index 68a4050a..664c12bf 100644 --- a/web/app/api/rollouts/[rolloutId]/logs/route.ts +++ b/web/app/api/rollouts/[rolloutId]/logs/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; +import { invalidLogQueryResponse, normalizeLogSearch } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByRollout } from "@/lib/victoria-logs"; export async function GET( @@ -6,13 +7,19 @@ export async function GET( { params }: { params: Promise<{ rolloutId: string }> }, ) { const { rolloutId } = await params; + let search: string | undefined; + try { + search = normalizeLogSearch(request.nextUrl.searchParams.get("q")); + } catch (error) { + return invalidLogQueryResponse(error); + } if (!isLoggingEnabled()) { return NextResponse.json({ logs: [] }); } try { - const { logs: rawLogs } = await queryLogsByRollout(rolloutId); + const { logs: rawLogs } = await queryLogsByRollout(rolloutId, { search }); const logs = rawLogs.map((log) => ({ timestamp: log._time, @@ -23,6 +30,9 @@ export async function GET( return NextResponse.json({ logs }); } catch (error) { console.error("Failed to fetch rollout logs:", error); - return NextResponse.json({ logs: [] }); + return NextResponse.json( + { message: "Failed to query rollout logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/servers/[id]/logs/route.ts b/web/app/api/servers/[id]/logs/route.ts index 4bbe8b97..a3f59d16 100644 --- a/web/app/api/servers/[id]/logs/route.ts +++ b/web/app/api/servers/[id]/logs/route.ts @@ -1,5 +1,6 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { invalidLogQueryResponse, parseLogListParams } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByServer } from "@/lib/victoria-logs"; export async function GET( @@ -20,14 +21,18 @@ export async function GET( const { id: serverId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "500", 10), - 1000, - ); - const before = url.searchParams.get("before") || undefined; + let queryParams: ReturnType; + try { + queryParams = parseLogListParams(url.searchParams, 500); + } catch (error) { + return invalidLogQueryResponse(error); + } try { - const result = await queryLogsByServer(serverId, limit, before); + const result = await queryLogsByServer({ + serverId, + ...queryParams, + }); const logs = result.logs.map((log, index) => ({ id: `${log.server_id}-${log._time}-${index}`, @@ -42,6 +47,9 @@ export async function GET( }); } catch (error) { console.error("[logs:server] failed to query logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query server logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/servers/[id]/metrics/route.ts b/web/app/api/servers/[id]/metrics/route.ts index 4c5dd4f0..c1955753 100644 --- a/web/app/api/servers/[id]/metrics/route.ts +++ b/web/app/api/servers/[id]/metrics/route.ts @@ -1,5 +1,6 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth"; +import { subtractMilliseconds } from "@/lib/date"; import { emptyHistory, isMetricsEnabled, @@ -38,7 +39,7 @@ export async function GET( const end = new Date(); const option = METRIC_RANGE_OPTIONS[range]; - const start = new Date(end.getTime() - option.durationMs); + const start = subtractMilliseconds(end, option.durationMs); try { const [current, history] = await Promise.all([ diff --git a/web/app/api/services/[id]/logs/route.ts b/web/app/api/services/[id]/logs/route.ts index 3aa37f43..49bcd40b 100644 --- a/web/app/api/services/[id]/logs/route.ts +++ b/web/app/api/services/[id]/logs/route.ts @@ -1,9 +1,10 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { invalidLogQueryResponse, parseLogListParams } from "@/lib/log-query"; import { isLoggingEnabled, - queryLogsByService, type LogType, + queryLogsByService, } from "@/lib/victoria-logs"; export async function GET( @@ -24,11 +25,12 @@ export async function GET( const { id: serviceId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "100", 10), - 1000, - ); - const before = url.searchParams.get("before") || undefined; + let queryParams: ReturnType; + try { + queryParams = parseLogListParams(url.searchParams, 100); + } catch (error) { + return invalidLogQueryResponse(error); + } const serverId = url.searchParams.get("serverId") || undefined; const logTypeParam = url.searchParams.get("type"); const logType = @@ -39,8 +41,7 @@ export async function GET( try { const result = await queryLogsByService({ serviceId, - limit, - before, + ...queryParams, logType, serverId, }); @@ -65,6 +66,9 @@ export async function GET( }); } catch (error) { console.error("[logs:service] failed to query logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query service logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/services/[id]/requests/route.ts b/web/app/api/services/[id]/requests/route.ts index 08387349..bd03415c 100644 --- a/web/app/api/services/[id]/requests/route.ts +++ b/web/app/api/services/[id]/requests/route.ts @@ -1,5 +1,6 @@ -import { auth } from "@/lib/auth"; import { headers } from "next/headers"; +import { auth } from "@/lib/auth"; +import { invalidLogQueryResponse, parseLogListParams } from "@/lib/log-query"; import { isLoggingEnabled, queryLogsByService } from "@/lib/victoria-logs"; export async function GET( @@ -20,17 +21,17 @@ export async function GET( const { id: serviceId } = await params; const url = new URL(request.url); - const limit = Math.min( - Number.parseInt(url.searchParams.get("limit") || "100", 10), - 1000, - ); - const before = url.searchParams.get("before") || undefined; + let queryParams: ReturnType; + try { + queryParams = parseLogListParams(url.searchParams, 100); + } catch (error) { + return invalidLogQueryResponse(error); + } try { const result = await queryLogsByService({ serviceId, - limit, - before, + ...queryParams, logType: "http", }); @@ -50,6 +51,9 @@ export async function GET( }); } catch (error) { console.error("[logs:requests] failed to query HTTP logs:", error); - return Response.json({ logs: [], hasMore: false }); + return Response.json( + { message: "Failed to query request logs" }, + { status: 502 }, + ); } } diff --git a/web/app/api/v1/agent/register/route.ts b/web/app/api/v1/agent/register/route.ts index 5233a424..cf4186cc 100644 --- a/web/app/api/v1/agent/register/route.ts +++ b/web/app/api/v1/agent/register/route.ts @@ -2,6 +2,7 @@ import { and, eq, gt, isNull } from "drizzle-orm"; import { type NextRequest, NextResponse } from "next/server"; import { db } from "@/db"; import { servers } from "@/db/schema"; +import { HOUR_IN_MILLISECONDS, subtractMilliseconds } from "@/lib/date"; import { agentRegisterSchema } from "@/lib/schemas"; import { formatZodErrors } from "@/lib/utils"; import { assignSubnet } from "@/lib/wireguard"; @@ -30,8 +31,9 @@ export async function POST(request: NextRequest) { } = parseResult.data; const now = new Date(); - const expiryThreshold = new Date( - now.getTime() - TOKEN_EXPIRY_HOURS * 60 * 60 * 1000, + const expiryThreshold = subtractMilliseconds( + now, + TOKEN_EXPIRY_HOURS * HOUR_IN_MILLISECONDS, ); const serverResults = await db diff --git a/web/app/api/v1/manifest/logs/route.ts b/web/app/api/v1/manifest/logs/route.ts index 8cfc7592..0404405c 100644 --- a/web/app/api/v1/manifest/logs/route.ts +++ b/web/app/api/v1/manifest/logs/route.ts @@ -3,6 +3,7 @@ export const dynamic = "force-dynamic"; import { z } from "zod"; import { requireRequestRole } from "@/lib/api-auth"; import { getManifestStatus } from "@/lib/cli-service"; +import { isLogCursor } from "@/lib/log-query"; import { slugify } from "@/lib/utils"; import { isLoggingEnabled, queryLogsByService } from "@/lib/victoria-logs"; @@ -10,7 +11,12 @@ const querySchema = z.object({ project: z.string().trim().min(1), environment: z.string().trim().min(1), service: z.string().trim().min(1), - after: z.string().trim().min(1).optional(), + after: z + .string() + .trim() + .min(1) + .refine(isLogCursor, { message: "Invalid log cursor" }) + .optional(), tail: z.coerce.number().int().min(1).max(1000).default(100), }); diff --git a/web/components/auth/device-authorization-page.tsx b/web/components/auth/device-authorization-page.tsx index ee7f1a9b..552cc614 100644 --- a/web/components/auth/device-authorization-page.tsx +++ b/web/components/auth/device-authorization-page.tsx @@ -15,10 +15,6 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { authClient } from "@/lib/auth-client"; -function normalizeUserCode(value: string) { - return value.trim().replace(/-/g, "").toUpperCase(); -} - export function DeviceAuthorizationPage() { const router = useRouter(); const searchParams = useSearchParams(); @@ -36,7 +32,7 @@ export function DeviceAuthorizationPage() { const verifyCode = useCallback( async (code: string) => { - const formatted = normalizeUserCode(code); + const formatted = code.trim().replace(/-/g, "").toUpperCase(); if (!formatted) { setError("Enter the device code to continue"); return; diff --git a/web/components/builds/build-details.tsx b/web/components/builds/build-details.tsx index f48b343e..dfeef859 100644 --- a/web/components/builds/build-details.tsx +++ b/web/components/builds/build-details.tsx @@ -28,7 +28,7 @@ import { ItemTitle, } from "@/components/ui/item"; import type { Build, BuildStatus, GithubRepo, Service } from "@/db/types"; -import { formatRelativeTime } from "@/lib/date"; +import { formatElapsedDurationBetween, formatRelativeTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; type BuildWithDates = Omit< @@ -100,21 +100,6 @@ const STATUS_CONFIG: Record< }, }; -function formatDuration( - start: string | Date, - end: string | Date | null, -): string { - const startDate = new Date(start); - const endDate = end ? new Date(end) : new Date(); - const diff = endDate.getTime() - startDate.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; -} - export function BuildDetails({ projectSlug, envName, @@ -296,7 +281,10 @@ export function BuildDetails({
Duration - {formatDuration(build.startedAt, build.completedAt)} + {formatElapsedDurationBetween( + build.startedAt, + build.completedAt, + )}
)} diff --git a/web/components/builds/builds-viewer.tsx b/web/components/builds/builds-viewer.tsx index d8741efd..c98fa1a6 100644 --- a/web/components/builds/builds-viewer.tsx +++ b/web/components/builds/builds-viewer.tsx @@ -35,7 +35,7 @@ import { } from "@/components/ui/item"; import { Spinner } from "@/components/ui/spinner"; import type { Build, BuildStatus } from "@/db/types"; -import { formatRelativeTime } from "@/lib/date"; +import { formatElapsedDurationBetween, formatRelativeTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; type BuildListItem = Pick< @@ -118,18 +118,6 @@ function StatusBadge({ status }: { status: BuildStatus }) { ); } -function formatDuration(start: string, end: string | null): string { - const startDate = new Date(start); - const endDate = end ? new Date(end) : new Date(); - const diff = endDate.getTime() - startDate.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; -} - export function BuildsViewer({ serviceId, projectSlug, @@ -280,7 +268,10 @@ export function BuildsViewer({ {build.startedAt && ( Duration:{" "} - {formatDuration(build.startedAt, build.completedAt)} + {formatElapsedDurationBetween( + build.startedAt, + build.completedAt, + )} )} diff --git a/web/components/core/delete-confirmation-dialog.tsx b/web/components/core/delete-confirmation-dialog.tsx new file mode 100644 index 00000000..ffc095d3 --- /dev/null +++ b/web/components/core/delete-confirmation-dialog.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { REGEXP_ONLY_DIGITS } from "input-otp"; +import type { ReactNode } from "react"; +import { useId, useState } from "react"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + InputOTP, + InputOTPGroup, + InputOTPSlot, +} from "@/components/ui/input-otp"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/lib/auth-client"; +import type { DeleteConfirmation } from "@/lib/two-factor"; + +type TwoFactorSessionUser = { + twoFactorEnabled?: boolean | null; +}; + +export function DeleteConfirmationDialog({ + resourceName, + triggerLabel, + description, + fallbackError, + onDelete, +}: { + resourceName: string; + triggerLabel: string; + description: ReactNode; + fallbackError: string; + onDelete: (confirmation?: DeleteConfirmation) => Promise; +}) { + const { + data: session, + isPending: isSessionLoading, + refetch: refetchSession, + } = useSession(); + const sessionUser = session?.user as TwoFactorSessionUser | undefined; + const totpInputId = useId(); + const [open, setOpen] = useState(false); + const [totpCode, setTotpCode] = useState(""); + const [isDeleting, setIsDeleting] = useState(false); + const requiresConfirmation = Boolean(sessionUser?.twoFactorEnabled); + const isConfirmationIncomplete = + requiresConfirmation && totpCode.length !== 6; + + const resetConfirmation = () => setTotpCode(""); + + const handleDelete = async () => { + if (isConfirmationIncomplete) { + toast.error("Enter your 6-digit authenticator code"); + return; + } + + setIsDeleting(true); + try { + await onDelete( + requiresConfirmation + ? { + totpCode, + } + : undefined, + ); + setOpen(false); + resetConfirmation(); + } catch (error) { + if (!requiresConfirmation) { + await refetchSession(); + } + toast.error(error instanceof Error ? error.message : fallbackError); + resetConfirmation(); + } finally { + setIsDeleting(false); + } + }; + + return ( + { + if (isDeleting) return; + setOpen(nextOpen); + if (!nextOpen) resetConfirmation(); + }} + > + }> + {triggerLabel} + + + + Delete {resourceName}? + + {description} + {requiresConfirmation && ( + <> +
+
+ Enter your authenticator code to confirm this deletion. + + )} +
+
+ {requiresConfirmation && ( +
+ + setTotpCode(value.replace(/\D/g, ""))} + disabled={isDeleting} + > + + + + + + + + + +
+ )} + + Cancel + { + void handleDelete(); + }} + disabled={ + isDeleting || isSessionLoading || isConfirmationIncomplete + } + > + {isDeleting ? : null} + {isDeleting ? "Deleting..." : "Delete"} + + +
+
+ ); +} diff --git a/web/components/core/local-date.tsx b/web/components/core/local-date.tsx new file mode 100644 index 00000000..ef933f0f --- /dev/null +++ b/web/components/core/local-date.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + type DateFormatOptions, + type DateInput, + formatCompactDate, + formatCompactDateTime, + formatDate, + formatDateTime, + formatPreciseDateTime, + formatTime, + toDate, +} from "@/lib/date"; + +type LocalDateFormat = + | "date" + | "dateTime" + | "preciseDateTime" + | "time" + | "compactDate" + | "compactDateTime"; + +type DateFormatter = ( + value: DateInput | null | undefined, + options?: DateFormatOptions, +) => string; + +const FORMATTERS: Record = { + date: formatDate, + dateTime: formatDateTime, + preciseDateTime: formatPreciseDateTime, + time: formatTime, + compactDate: formatCompactDate, + compactDateTime: formatCompactDateTime, +}; + +const subscribe = () => () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +export function LocalDate({ + value, + format = "dateTime", + fallback = "—", +}: { + value: DateInput | null | undefined; + format?: LocalDateFormat; + fallback?: string; +}) { + const isHydrated = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + const date = toDate(value); + + if (!date) return <>{fallback}; + + const label = FORMATTERS[format](date, { + fallback, + timeZone: isHydrated ? undefined : "UTC", + }); + + return ( + + ); +} diff --git a/web/components/github/github-repo-selector.tsx b/web/components/github/github-repo-selector.tsx index 04179728..5ad14e4d 100644 --- a/web/components/github/github-repo-selector.tsx +++ b/web/components/github/github-repo-selector.tsx @@ -26,11 +26,6 @@ const fetcher = (url: string) => fetch(url).then((res) => res.json()); const EMPTY_REPOS: GitHubRepo[] = []; -function getValidGitHubRepoName(url: string) { - const match = url.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/?$/); - return match ? match[1] : null; -} - export function GitHubRepoSelector({ value, onChange, @@ -61,7 +56,8 @@ export function GitHubRepoSelector({ }, [repos, search]); const publicRepoFromSearch = useMemo(() => { - const repoName = getValidGitHubRepoName(search); + const match = search.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/?$/); + const repoName = match ? match[1] : null; if (!repoName) return null; const alreadyInList = repos.some( (r) => r.fullName.toLowerCase() === repoName.toLowerCase(), diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index e4a881ac..0721337d 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -13,7 +13,8 @@ import { parseAsStringLiteral, useQueryState, } from "nuqs"; -import { useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; import useSWR from "swr"; import { Button } from "@/components/ui/button"; import { @@ -22,14 +23,23 @@ import { DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Empty, EmptyTitle } from "@/components/ui/empty"; +import { Empty, EmptyContent, EmptyTitle } from "@/components/ui/empty"; import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; -import { formatDateTime, formatTime } from "@/lib/date"; +import { formatPreciseDateTime, formatTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; +import { + DEFAULT_LOG_TIME_RANGE, + LOG_TIME_RANGES, + type LogTimeRange, + MAX_LOG_SEARCH_LENGTH, + splitLogSearchMatches, +} from "@/lib/log-query"; type LogLevel = "error" | "warn" | "info" | "debug"; type StatusCategory = "2xx" | "3xx" | "4xx" | "5xx"; @@ -85,6 +95,13 @@ const SERVER_LOG_LEVEL_COLORS: Record = { const EMPTY_LOGS: unknown[] = []; +const LOG_TIME_RANGE_LABELS: Record = { + "1h": "Last hour", + "6h": "Last 6 hours", + "24h": "Last 24 hours", + "7d": "Last 7 days", +}; + type LogViewerProps = | { variant: "service-logs"; serviceId: string; servers?: Server[] } | { variant: "requests"; serviceId: string } @@ -124,20 +141,14 @@ function getStatusCategory(status: number): StatusCategory { } function highlightMatches(text: string, search: string): React.ReactNode { - if (!search) return text; - - const regex = new RegExp( - `(${search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, - "gi", - ); - const parts = text.split(regex); + const parts = splitLogSearchMatches(text, search); let offset = 0; - return parts.map((part) => { + return parts.map(({ text: part, isMatch }) => { const start = offset; offset += part.length; - return regex.test(part) ? ( + return isMatch ? ( { - const serverParam = filterServerId ? `&serverId=${filterServerId}` : ""; - switch (props.variant) { - case "service-logs": - return `/api/services/${props.serviceId}/logs?limit=500&type=container${serverParam}`; - case "requests": - return `/api/services/${props.serviceId}/requests?limit=500`; - case "build-logs": - return `/api/builds/${props.buildId}/logs`; - case "server-logs": - return `/api/servers/${props.serverId}/logs?limit=500`; - case "rollout-logs": - return `/api/rollouts/${props.rolloutId}/logs`; - } - }, [props, filterServerId]); +type LogDataResponse = { logs: unknown[]; hasMore?: boolean }; - const pollingInterval = useMemo(() => { - if (props.variant === "build-logs") { - return props.isLive ? 2000 : 0; - } - if (props.variant === "rollout-logs") { - return props.isLive ? 2000 : 0; - } - if (props.variant === "server-logs") { - return 5000; +type LogEndpointOptions = { + search: string; + range: LogTimeRange; + filterServerId?: string; + before?: string; +}; + +function supportsTimeRange(variant: LogViewerProps["variant"]): boolean { + return ( + variant === "service-logs" || + variant === "requests" || + variant === "server-logs" + ); +} + +function buildLogEndpoint( + props: LogViewerProps, + { search, range, filterServerId, before }: LogEndpointOptions, +): string { + const params = new URLSearchParams(); + if (search) params.set("q", search); + if (supportsTimeRange(props.variant)) params.set("range", range); + if (before) params.set("before", before); + + let path: string; + switch (props.variant) { + case "service-logs": + path = `/api/services/${props.serviceId}/logs`; + params.set("limit", "500"); + params.set("type", "container"); + if (filterServerId) params.set("serverId", filterServerId); + break; + case "requests": + path = `/api/services/${props.serviceId}/requests`; + params.set("limit", "500"); + break; + case "build-logs": + path = `/api/builds/${props.buildId}/logs`; + break; + case "server-logs": + path = `/api/servers/${props.serverId}/logs`; + params.set("limit", "500"); + break; + case "rollout-logs": + path = `/api/rollouts/${props.rolloutId}/logs`; + break; + } + + const query = params.toString(); + return query ? `${path}?${query}` : path; +} + +function useDebouncedValue(value: string, delay: number): string { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (!value) { + setDebouncedValue(""); + return; } - return 2000; - }, [props]); - return useSWR<{ logs: unknown[]; hasMore?: boolean }>(endpoint, fetcher, { - refreshInterval: pollingInterval, - }); + const timeout = window.setTimeout(() => setDebouncedValue(value), delay); + return () => window.clearTimeout(timeout); + }, [value, delay]); + + return debouncedValue; +} + +function TimeRangeFilter({ + range, + onRangeChange, +}: { + range: LogTimeRange; + onRangeChange: (range: LogTimeRange) => void; +}) { + return ( + + + {LOG_TIME_RANGE_LABELS[range]} + + + + + Time range + + + onRangeChange(value as LogTimeRange)} + > + {LOG_TIME_RANGES.map((option) => ( + + {LOG_TIME_RANGE_LABELS[option]} + + ))} + + + + ); } function ServiceLogsFilters({ @@ -478,7 +558,7 @@ function ServiceLogRow({
{formatTime(entry.timestamp)} @@ -524,7 +604,7 @@ function RequestRow({
{formatTime(entry.timestamp)} @@ -560,7 +640,7 @@ function BuildLogRow({
{formatTime(entry.timestamp)} @@ -582,7 +662,7 @@ function ServerLogRow({
{formatTime(entry.timestamp)} @@ -604,6 +684,7 @@ const logLevelParser = parseAsArrayOf( const statusParser = parseAsArrayOf( parseAsStringLiteral(["2xx", "3xx", "4xx", "5xx"] as const), ); +const rangeParser = parseAsStringLiteral(LOG_TIME_RANGES); export function LogViewer(props: LogViewerProps) { const containerRef = useRef(null); @@ -611,7 +692,13 @@ export function LogViewer(props: LogViewerProps) { scrollTop: number; scrollHeight: number; } | null>(null); + const paginationAbortRef = useRef(null); const [search, setSearch] = useQueryState("q", { defaultValue: "" }); + const debouncedSearch = useDebouncedValue(search, 300).trim(); + const [range, setRange] = useQueryState( + "range", + rangeParser.withDefault(DEFAULT_LOG_TIME_RANGE), + ); const [autoScroll, setAutoScroll] = useState(true); const [selectedServerId, setSelectedServerId] = useQueryState("server"); @@ -651,20 +738,59 @@ export function LogViewer(props: LogViewerProps) { const setStatusFilter = (newStatus: Set) => setStatusParam(Array.from(newStatus) as typeof statusParam); - const [olderLogs, setOlderLogs] = useState([]); - const [isLoadingOlder, setIsLoadingOlder] = useState(false); - const [olderHasMore, setOlderHasMore] = useState(true); - const prevSelectedServerIdRef = useRef(selectedServerId); - - if (selectedServerId !== prevSelectedServerIdRef.current) { - prevSelectedServerIdRef.current = selectedServerId; - setOlderLogs([]); - setOlderHasMore(true); - } - const servers = props.variant === "service-logs" ? props.servers : undefined; + const logEndpointOptions: LogEndpointOptions = { + search: debouncedSearch, + range, + filterServerId: selectedServerId ?? undefined, + }; + const paginationKey = buildLogEndpoint(props, logEndpointOptions); + let pollingInterval = 2000; + if (props.variant === "build-logs" || props.variant === "rollout-logs") { + pollingInterval = props.isLive ? 2000 : 0; + } else if (props.variant === "server-logs") { + pollingInterval = 5000; + } + const [olderState, setOlderState] = useState<{ + key: string; + logs: unknown[]; + hasMore: boolean; + }>({ key: "", logs: [], hasMore: true }); + const [loadingOlderKey, setLoadingOlderKey] = useState(null); + const olderLogs = + olderState.key === paginationKey ? olderState.logs : EMPTY_LOGS; + const olderHasMore = + olderState.key === paginationKey ? olderState.hasMore : true; + const isLoadingOlder = loadingOlderKey === paginationKey; + + useEffect(() => { + void paginationKey; + paginationAbortRef.current?.abort(); + paginationAbortRef.current = null; + scrollRestorationRef.current = null; + setLoadingOlderKey(null); + + return () => paginationAbortRef.current?.abort(); + }, [paginationKey]); + + const { data, error, isLoading, mutate } = useSWR( + paginationKey, + fetcher, + { + keepPreviousData: true, + refreshInterval: pollingInterval, + }, + ); + const hasResolvedData = data !== undefined; + useEffect(() => { + if (error && hasResolvedData) { + toast.error( + error instanceof Error ? error.message : "Failed to refresh logs", + { id: `logs-refresh-${paginationKey}` }, + ); + } + }, [error, hasResolvedData, paginationKey]); - const { data, isLoading } = useLogData(props, selectedServerId ?? undefined); const recentLogs = (data?.logs as unknown[] | undefined) || EMPTY_LOGS; const logs = useMemo(() => { const seenIds = new Set(); @@ -702,7 +828,7 @@ export function LogViewer(props: LogViewerProps) { olderLogs.length === 0 ? data?.hasMore || false : olderHasMore; const loadOlderLogs = async () => { - if (isLoadingOlder || !hasMore) return; + if (isLoadingOlder || isLoading || !hasMore) return; const oldestLog = logs[0] as { timestamp?: string } | undefined; if (!oldestLog?.timestamp) return; @@ -715,44 +841,60 @@ export function LogViewer(props: LogViewerProps) { }; } - setIsLoadingOlder(true); + const requestKey = paginationKey; + const controller = new AbortController(); + paginationAbortRef.current?.abort(); + paginationAbortRef.current = controller; + setLoadingOlderKey(requestKey); try { const cursor = oldestLog.timestamp; - let endpoint: string; - const beforeParam = `&before=${encodeURIComponent(cursor)}`; - const serverParam = selectedServerId - ? `&serverId=${selectedServerId}` - : ""; - - switch (props.variant) { - case "service-logs": - endpoint = `/api/services/${props.serviceId}/logs?limit=500&type=container${beforeParam}${serverParam}`; - break; - case "requests": - endpoint = `/api/services/${props.serviceId}/requests?limit=500${beforeParam}`; - break; - case "server-logs": - endpoint = `/api/servers/${props.serverId}/logs?limit=500${beforeParam}`; - break; - default: - return; + const endpoint = buildLogEndpoint(props, { + ...logEndpointOptions, + before: cursor, + }); + const response = await fetch(endpoint, { + cache: "no-store", + signal: controller.signal, + }); + const result = (await response.json()) as LogDataResponse & { + message?: string; + }; + if (paginationAbortRef.current !== controller) return; + if (!response.ok) { + throw new Error(result.message || "Failed to load older logs"); } - const response = await fetch(endpoint); - const result = await response.json(); - if (result.logs && result.logs.length > 0) { - setOlderLogs((prev) => [...result.logs, ...prev]); - setOlderHasMore(result.hasMore || false); + setOlderState((current) => ({ + key: requestKey, + logs: [ + ...result.logs, + ...(current.key === requestKey ? current.logs : []), + ], + hasMore: result.hasMore || false, + })); } else { - setOlderHasMore(false); + setOlderState((current) => ({ + key: requestKey, + logs: current.key === requestKey ? current.logs : [], + hasMore: false, + })); scrollRestorationRef.current = null; } } catch (error) { - console.error("Failed to load older logs:", error); + if (paginationAbortRef.current !== controller) return; scrollRestorationRef.current = null; + if (!(error instanceof DOMException && error.name === "AbortError")) { + console.error("Failed to load older logs:", error); + toast.error( + error instanceof Error ? error.message : "Failed to load older logs", + ); + } } finally { - setIsLoadingOlder(false); + if (paginationAbortRef.current === controller) { + paginationAbortRef.current = null; + setLoadingOlderKey(null); + } } }; @@ -765,68 +907,28 @@ export function LogViewer(props: LogViewerProps) { const level = detectLevel(entry.message); if (level && !levels.has(level)) return false; - - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; } else if (props.variant === "requests") { const entry = log as RequestEntry; const category = getStatusCategory(entry.status); if (!statusFilter.has(category)) return false; - - if (search) { - const searchLower = search.toLowerCase(); - const matchesPath = entry.path.toLowerCase().includes(searchLower); - const matchesMethod = entry.method - .toLowerCase() - .includes(searchLower); - const matchesStatus = entry.status.toString().includes(search); - const matchesIp = entry.clientIp.includes(search); - if (!matchesPath && !matchesMethod && !matchesStatus && !matchesIp) { - return false; - } - } - } else if (props.variant === "build-logs") { - const entry = log as BuildLogEntry; - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; } else if (props.variant === "server-logs") { const entry = log as ServerLogEntry; if (!levels.has(entry.level as LogLevel)) return false; - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; - } else if (props.variant === "rollout-logs") { - const entry = log as BuildLogEntry; - if ( - search && - !entry.message.toLowerCase().includes(search.toLowerCase()) - ) - return false; } return true; }); - }, [ - logs, - props.variant, - search, - levels, - showStdout, - showStderr, - statusFilter, - ]); + }, [logs, props.variant, levels, showStdout, showStderr, statusFilter]); + const logCount = logs.length; const filteredLogCount = filteredLogs.length; + const newestFilteredLogTimestamp = ( + filteredLogs.at(-1) as BaseEntry | undefined + )?.timestamp; useLayoutEffect(() => { + void logCount; void filteredLogCount; + void newestFilteredLogTimestamp; const container = containerRef.current; const restoration = scrollRestorationRef.current; @@ -838,7 +940,7 @@ export function LogViewer(props: LogViewerProps) { } else if (autoScroll && container) { container.scrollTop = container.scrollHeight; } - }, [filteredLogCount, autoScroll]); + }, [logCount, filteredLogCount, newestFilteredLogTimestamp, autoScroll]); const config = useMemo(() => { switch (props.variant) { @@ -894,6 +996,7 @@ export function LogViewer(props: LogViewerProps) { placeholder={config.searchPlaceholder} value={search} onChange={(e) => setSearch(e.target.value)} + maxLength={MAX_LOG_SEARCH_LENGTH} className="pl-8 pr-8 h-8" /> {search && ( @@ -929,6 +1032,13 @@ export function LogViewer(props: LogViewerProps) { )} + {supportsTimeRange(props.variant) && ( + void setRange(nextRange)} + /> + )} + {servers && servers.length > 1 && ( setAutoScroll(!autoScroll)} + onClick={() => setAutoScroll((current) => !current)} title={autoScroll ? "Auto-scroll on" : "Auto-scroll off"} > @@ -959,7 +1069,7 @@ export function LogViewer(props: LogViewerProps) { variant="ghost" size="sm" onClick={loadOlderLogs} - disabled={isLoadingOlder} + disabled={isLoadingOlder || isLoading} className="gap-1 text-muted-foreground" > {isLoadingOlder ? ( @@ -972,14 +1082,25 @@ export function LogViewer(props: LogViewerProps) {
)} - {isLoading ? ( + {error && logs.length === 0 ? ( + + Unable to load logs + + + + + ) : isLoading && logs.length === 0 ? (
) : filteredLogs.length === 0 ? ( - {logs.length === 0 ? config.emptyMessage : config.noMatchMessage} + {debouncedSearch.trim() || logs.length > 0 + ? config.noMatchMessage + : config.emptyMessage} ) : ( @@ -987,25 +1108,43 @@ export function LogViewer(props: LogViewerProps) { {filteredLogs.map((entry) => { if (props.variant === "service-logs") { const e = entry as ServiceLogEntry; - return ; + return ( + + ); } if (props.variant === "requests") { const e = entry as RequestEntry; - return ; + return ( + + ); } if (props.variant === "server-logs") { const e = entry as ServerLogEntry; - return ; + return ( + + ); } if (props.variant === "rollout-logs") { const e = entry as BuildLogEntry; return ( - + ); } const e = entry as BuildLogEntry; return ( - + ); })}
diff --git a/web/components/metrics/metrics-history-charts.tsx b/web/components/metrics/metrics-history-charts.tsx index 837f8028..90cb87e2 100644 --- a/web/components/metrics/metrics-history-charts.tsx +++ b/web/components/metrics/metrics-history-charts.tsx @@ -26,6 +26,11 @@ import { NativeSelectOption, } from "@/components/ui/native-select"; import { Skeleton } from "@/components/ui/skeleton"; +import { + formatCompactDateTime, + formatPreciseDateTime, + getTimestamp, +} from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; import { METRIC_RANGE_KEYS, type MetricRange } from "@/lib/metric-ranges"; import type { @@ -97,6 +102,11 @@ const CHARTS: ChartConfig[] = [ }, ]; +const CHART_THRESHOLDS = [ + { value: 70, color: "#f59e0b" }, + { value: 90, color: "#f43f5e" }, +]; + const SERVER_COLORS = [ "#10b981", "#0ea5e9", @@ -257,7 +267,7 @@ function MetricChartCard({ minTickGap={28} tickLine={false} axisLine={false} - tickFormatter={formatShortTime} + tickFormatter={(value) => formatCompactDateTime(value)} className="text-xs" /> - {thresholdsForChart().map((threshold) => ( + {CHART_THRESHOLDS.map((threshold) => ( -

{formatTooltipTime(String(label))}

+

{formatPreciseDateTime(String(label))}

{payload.map((item) => (
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + (a, b) => getTimestamp(a.timestamp, 0) - getTimestamp(b.timestamp, 0), ); } - -function thresholdsForChart() { - return [ - { value: 70, color: "#f59e0b" }, - { value: 90, color: "#f43f5e" }, - ]; -} - function formatTooltipValue(item: TooltipPayload) { const value = Number(item.value); if (!Number.isFinite(value)) return "-"; - if (isBytesSeries(String(item.dataKey))) return formatBytes(value); + const dataKey = String(item.dataKey); + if ( + dataKey.startsWith("memoryUsedBytes:") || + dataKey.startsWith("diskUsedBytes:") + ) { + return formatBytes(value); + } return `${value.toFixed(1)}%`; } @@ -463,32 +471,6 @@ function getServerColor(index: number) { return SERVER_COLORS[index % SERVER_COLORS.length]; } -function isBytesSeries(dataKey: string) { - return ( - dataKey.startsWith("memoryUsedBytes:") || - dataKey.startsWith("diskUsedBytes:") - ); -} - -function formatShortTime(value: string) { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(value)); -} - -function formatTooltipTime(value: string) { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }).format(new Date(value)); -} - function formatBytesCompact(value: number) { if (value >= 1024 ** 4) return `${(value / 1024 ** 4).toFixed(1)}T`; if (value >= 1024 ** 3) return `${(value / 1024 ** 3).toFixed(1)}G`; diff --git a/web/components/project/project-settings-panel.tsx b/web/components/project/project-settings-panel.tsx index 91ae3574..5de5bb50 100644 --- a/web/components/project/project-settings-panel.tsx +++ b/web/components/project/project-settings-panel.tsx @@ -1,28 +1,17 @@ "use client"; -import { useState } from "react"; +import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { Trash2 } from "lucide-react"; import { + deleteProject, updateProjectName, updateProjectSlug, - deleteProject, } from "@/actions/projects"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; -import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-dialog"; import { EditableText } from "@/components/core/editable-text"; +import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import type { DeleteConfirmation } from "@/lib/two-factor"; type Project = { id: string; @@ -87,20 +76,11 @@ export function ProjectSettingsPanel({ project }: { project: Project }) { export function ProjectDangerZone({ project }: { project: Project }) { const router = useRouter(); - const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteProject(project.id); - toast.success("Project deleted"); - router.push("/dashboard"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to delete project", - ); - setIsDeleting(false); - } + const handleDelete = async (confirmation?: DeleteConfirmation) => { + await deleteProject(project.id, confirmation); + toast.success("Project deleted"); + router.push("/dashboard"); }; return ( @@ -118,30 +98,13 @@ export function ProjectDangerZone({ project }: { project: Project }) { deployments will be permanently removed.

- - }> - Delete Project - - - - Delete {project.name}? - - This action cannot be undone. This will permanently delete the - project and all its environments, services, and deployments. - - - - Cancel - - {isDeleting ? "Deleting..." : "Delete"} - - - - +
diff --git a/web/components/server/server-danger-zone.tsx b/web/components/server/server-danger-zone.tsx index f956d682..b0b07ba1 100644 --- a/web/components/server/server-danger-zone.tsx +++ b/web/components/server/server-danger-zone.tsx @@ -1,23 +1,12 @@ "use client"; -import { useState } from "react"; +import { Trash2 } from "lucide-react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; -import { Trash2 } from "lucide-react"; import { deleteServer } from "@/actions/servers"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { Button } from "@/components/ui/button"; +import { DeleteConfirmationDialog } from "@/components/core/delete-confirmation-dialog"; import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; +import type { DeleteConfirmation } from "@/lib/two-factor"; export function ServerDangerZone({ serverId, @@ -27,20 +16,11 @@ export function ServerDangerZone({ serverName: string; }) { const router = useRouter(); - const [isDeleting, setIsDeleting] = useState(false); - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteServer(serverId); - toast.success("Server deleted"); - router.push("/dashboard"); - } catch (error) { - toast.error( - error instanceof Error ? error.message : "Failed to delete server", - ); - setIsDeleting(false); - } + const handleDelete = async (confirmation?: DeleteConfirmation) => { + await deleteServer(serverId, confirmation); + toast.success("Server deleted"); + router.push("/dashboard"); }; return ( @@ -58,30 +38,13 @@ export function ServerDangerZone({ longer be available for deployments.

- - }> - Delete Server - - - - Delete {serverName}? - - This action cannot be undone. This will permanently delete the - server and remove it from your infrastructure. - - - - Cancel - - {isDeleting ? "Deleting..." : "Delete"} - - - - +
diff --git a/web/components/service/deleted-services-panel.tsx b/web/components/service/deleted-services-panel.tsx index 957b8f63..054d5c1c 100644 --- a/web/components/service/deleted-services-panel.tsx +++ b/web/components/service/deleted-services-panel.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; import { restoreDeletedService } from "@/actions/projects"; +import { LocalDate } from "@/components/core/local-date"; import { AlertDialog, AlertDialogAction, @@ -31,11 +32,6 @@ type DeletedService = Pick< | "deletionError" >; -function formatDate(value: Date | string | null) { - if (!value) return "Unknown"; - return new Date(value).toLocaleString(); -} - export function DeletedServicesPanel({ services, }: { @@ -102,8 +98,10 @@ export function DeletedServicesPanel({ {service.image}

- Deleted {formatDate(service.deletedAt)}. Purges{" "} - {formatDate(service.purgeAfter)}. + Deleted{" "} + . + {" Purges "} + .

{service.deletionError && (

diff --git a/web/components/service/details/backup-tab.tsx b/web/components/service/details/backup-tab.tsx index f0ae417d..7d8757ec 100644 --- a/web/components/service/details/backup-tab.tsx +++ b/web/components/service/details/backup-tab.tsx @@ -15,6 +15,7 @@ import { toast } from "sonner"; import useSWR from "swr"; import { createBackup, deleteBackup, restoreBackup } from "@/actions/backups"; import { updateServiceBackupSettings } from "@/actions/projects"; +import { LocalDate } from "@/components/core/local-date"; import { AlertDialog, AlertDialogAction, @@ -32,7 +33,6 @@ import { NativeSelectOption, } from "@/components/ui/native-select"; import type { ServiceWithDetails as Service } from "@/db/types"; -import { formatDateTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; type BackupItem = { @@ -284,7 +284,7 @@ export const BackupTab = memo(function BackupTab({

{backup.volumeName}

- {formatDateTime(backup.createdAt)} + {backup.sizeBytes && ` · ${formatBytes(backup.sizeBytes)}`} {backup.serverName && ` · ${backup.serverName}`} diff --git a/web/components/service/details/rollout-details.tsx b/web/components/service/details/rollout-details.tsx index 0d597ef7..d6adce6f 100644 --- a/web/components/service/details/rollout-details.tsx +++ b/web/components/service/details/rollout-details.tsx @@ -12,7 +12,7 @@ import { useRouter } from "next/navigation"; import { LogViewer } from "@/components/logs/log-viewer"; import { Button } from "@/components/ui/button"; import type { Rollout, RolloutStatus, Service } from "@/db/types"; -import { formatRelativeTime } from "@/lib/date"; +import { formatElapsedDurationBetween, formatRelativeTime } from "@/lib/date"; type RolloutWithDates = Omit & { createdAt: string | Date; @@ -75,21 +75,6 @@ function formatStage(stage: string | null): string { return STAGE_LABELS[stage] || stage; } -function formatDuration( - start: string | Date, - end: string | Date | null, -): string { - const startDate = new Date(start); - const endDate = end ? new Date(end) : new Date(); - const diff = endDate.getTime() - startDate.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; -} - export function RolloutDetails({ projectSlug, envName, @@ -139,7 +124,11 @@ export function RolloutDetails({ Started {formatRelativeTime(rollout.createdAt)} - Duration: {formatDuration(rollout.createdAt, rollout.completedAt)} + Duration:{" "} + {formatElapsedDurationBetween( + rollout.createdAt, + rollout.completedAt, + )}

diff --git a/web/components/service/details/rollout-history.tsx b/web/components/service/details/rollout-history.tsx index 1627312c..74dd12ca 100644 --- a/web/components/service/details/rollout-history.tsx +++ b/web/components/service/details/rollout-history.tsx @@ -19,7 +19,7 @@ import { } from "@/components/ui/item"; import { Skeleton } from "@/components/ui/skeleton"; import type { RolloutStatus } from "@/db/types"; -import { formatRelativeTime } from "@/lib/date"; +import { formatElapsedDurationBetween, formatRelativeTime } from "@/lib/date"; import { fetcher } from "@/lib/fetcher"; type RolloutListItem = { @@ -96,18 +96,6 @@ function formatStage(stage: string | null): string { return STAGE_LABELS[stage] || stage; } -function formatDuration(start: string, end: string | null): string { - const startDate = new Date(start); - const endDate = end ? new Date(end) : new Date(); - const diff = endDate.getTime() - startDate.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - if (minutes > 0) { - return `${minutes}m ${seconds % 60}s`; - } - return `${seconds}s`; -} - function RolloutHistorySkeleton({ actions }: { actions?: React.ReactNode }) { return (
@@ -209,7 +197,10 @@ export function RolloutHistory({ {formatRelativeTime(rollout.createdAt)} Duration:{" "} - {formatDuration(rollout.createdAt, rollout.completedAt)} + {formatElapsedDurationBetween( + rollout.createdAt, + rollout.completedAt, + )} diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 3931e2da..479fc5dd 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -24,6 +24,11 @@ import { Badge } from "@/components/ui/badge"; import { Card } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; import type { ServiceWithDetails as Service } from "@/db/types"; +import { + formatCompactDate, + formatCompactDateTime, + getTimestamp, +} from "@/lib/date"; import { isObservedStarting } from "@/lib/deployment-status"; import { fetcher } from "@/lib/fetcher"; import { cn } from "@/lib/utils"; @@ -220,23 +225,28 @@ function ServiceMetricsPanel({ const isUnavailable = Boolean(error) || stats?.metricsEnabled === false; const hasMetricData = Boolean(stats && !isUnavailable); const summaryItems = useMemo( - () => buildServiceMetricSummaryItems(chartMode, stats, chartRows, hasMetricData), + () => + buildServiceMetricSummaryItems( + chartMode, + stats, + chartRows, + hasMetricData, + ), [chartMode, stats, chartRows, hasMetricData], ); - return (
-
+
{isLoading ? ( -
+
) : ( -
+
{summaryItems.map((item) => ( -
+

{item.value}

@@ -246,14 +256,11 @@ function ServiceMetricsPanel({
)}
-
-

{formatToday()}

- -
+
@@ -300,7 +307,7 @@ function ServiceMetricsPanel({ minTickGap={32} tickLine={false} axisLine={false} - tickFormatter={formatShortDate} + tickFormatter={(value) => formatCompactDate(value)} className="text-xs" /> +
{options.map((option) => { const isSelected = value === option.value; @@ -383,7 +390,7 @@ function ServiceChartModeToggle({ disabled={disabled} onClick={() => onChange(option.value)} className={cn( - "rounded-[5px] px-2 py-0.5 text-xs font-medium transition-colors", + "flex-1 rounded-[5px] px-2 py-0.5 text-xs font-medium transition-colors sm:flex-none", isSelected ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", @@ -647,7 +654,7 @@ function ServiceMetricsTooltip({ return (
-

{formatTooltipDate(String(label))}

+

{formatCompactDateTime(String(label))}

{items.map((item) => (
total + server.configured, 0, ); -} - -function formatInstanceSummary(overview: OverviewData): string { - const configured = getConfiguredReplicaCount(overview); const serverCount = overview.serverSummaries.length; if (configured === 0) { @@ -953,18 +948,16 @@ function getPrimaryEndpoint(endpoints: EndpointItem[]): EndpointItem { function formatPortSummary(ports: Service["ports"]): string { if (ports.length === 0) return "No Ports"; - if (ports.length === 1) return formatPortLabel(ports[0]); + if (ports.length === 1) { + const port = ports[0]; + const protocol = (port.protocol || "http").toUpperCase(); + const external = port.externalPort ? ` -> :${port.externalPort}` : ""; + return `${protocol} :${port.port}${external}`; + } return formatCount(ports.length, "port"); } -function formatPortLabel(port: Service["ports"][number]): string { - const protocol = (port.protocol || "http").toUpperCase(); - const external = port.externalPort ? ` -> :${port.externalPort}` : ""; - - return `${protocol} :${port.port}${external}`; -} - function formatCount(count: number, singular: string): string { const label = singular .split(" ") @@ -1201,8 +1194,8 @@ function getAverageRequestsPerSecond(stats: ServiceMetricsResponse): number { } function getStatsDurationSeconds(stats: ServiceMetricsResponse): number { - const windowStart = new Date(stats.windowStart).getTime(); - const windowEnd = new Date(stats.windowEnd).getTime(); + const windowStart = getTimestamp(stats.windowStart); + const windowEnd = getTimestamp(stats.windowEnd); if ( Number.isFinite(windowStart) && @@ -1227,7 +1220,7 @@ 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); + return formatCompactNumber(value); } function formatRateTick(value: number): string { @@ -1243,10 +1236,6 @@ function getYAxisMargin(mode: ServiceChartMode): number { return -20; } -function formatRequestTick(value: number): string { - return formatCompactNumber(value); -} - function formatRequestCount(value: number): string { if (!Number.isFinite(value)) return "-"; @@ -1277,26 +1266,3 @@ function formatBytes(value: number): string { 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", - day: "numeric", - }).format(new Date(value)); -} - -function formatTooltipDate(value: string): string { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }).format(new Date(value)); -} - -function formatToday(): string { - return new Intl.DateTimeFormat(undefined, { - day: "numeric", - month: "short", - }).format(new Date()); -} diff --git a/web/components/settings/api-key-settings.tsx b/web/components/settings/api-key-settings.tsx index 36206cd2..362abc9d 100644 --- a/web/components/settings/api-key-settings.tsx +++ b/web/components/settings/api-key-settings.tsx @@ -3,6 +3,7 @@ import { KeyRound, RefreshCw, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; +import { LocalDate } from "@/components/core/local-date"; import { AlertDialog, AlertDialogAction, @@ -25,6 +26,7 @@ import { import { Item, ItemContent, ItemMedia, ItemTitle } from "@/components/ui/item"; import { Spinner } from "@/components/ui/spinner"; import { authClient } from "@/lib/auth-client"; +import { getTimestamp } from "@/lib/date"; type ApiKeyRecord = { id: string; @@ -66,11 +68,6 @@ type ApiKeyClient = { const apiKeysClient = authClient as unknown as ApiKeyClient; -const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - function getErrorMessage( error: { message?: string; error_description?: string } | null | undefined, fallback: string, @@ -78,28 +75,6 @@ function getErrorMessage( return error?.message || error?.error_description || fallback; } -function formatDate(value: string | Date | null) { - if (!value) return "Never"; - - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return "Unknown"; - - return dateTimeFormatter.format(date); -} - -function describeSource(apiKey: ApiKeyRecord) { - const source = apiKey.metadata?.creationSource; - if (source === "techulus-cli") return "CLI"; - if (source === "dashboard") return "Dashboard"; - return "Manual"; -} - -function getKeyPreview(apiKey: ApiKeyRecord) { - if (apiKey.start) return `${apiKey.start}••••`; - if (apiKey.prefix) return `${apiKey.prefix}••••`; - return "Hidden"; -} - export function ApiKeySettings() { const [apiKeys, setApiKeys] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -109,8 +84,7 @@ export function ApiKeySettings() { const sortedApiKeys = useMemo( () => apiKeys.toSorted( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + (a, b) => getTimestamp(b.createdAt, 0) - getTimestamp(a.createdAt, 0), ), [apiKeys], ); @@ -227,68 +201,95 @@ export function ApiKeySettings() { ) : (
- {sortedApiKeys.map((apiKey, index) => ( -
0 ? "border-t" : "" - }`} - > -
-
-

- {apiKey.name ?? "Untitled key"} + {sortedApiKeys.map((apiKey, index) => { + const creationSource = apiKey.metadata?.creationSource; + const sourceLabel = + creationSource === "techulus-cli" + ? "CLI" + : creationSource === "dashboard" + ? "Dashboard" + : "Manual"; + const keyPreview = apiKey.start + ? `${apiKey.start}••••` + : apiKey.prefix + ? `${apiKey.prefix}••••` + : "Hidden"; + + return ( +

0 ? "border-t" : "" + }`} + > +
+
+

+ {apiKey.name ?? "Untitled key"} +

+ + {apiKey.enabled ? "Active" : "Disabled"} + + {sourceLabel} +
+

+ {keyPreview}

- - {apiKey.enabled ? "Active" : "Disabled"} - - {describeSource(apiKey)}
-

- {getKeyPreview(apiKey)} -

-
-
-

Created

-

{formatDate(apiKey.createdAt)}

-
-
-

Last used

-

{formatDate(apiKey.lastRequest)}

+
+

Created

+

+ +

+
+
+

Last used

+

+ +

+
+ + } + > + + Revoke + + + + + Revoke {apiKey.name ?? "this API key"}? + + + Any script or CLI using this key will stop working + immediately. This cannot be undone. + + + + Cancel + void handleRevoke(apiKey)} + disabled={revokingId === apiKey.id} + > + {revokingId === apiKey.id + ? "Revoking..." + : "Revoke"} + + + +
- - } - > - - Revoke - - - - - Revoke {apiKey.name ?? "this API key"}? - - - Any script or CLI using this key will stop working - immediately. This cannot be undone. - - - - Cancel - void handleRevoke(apiKey)} - disabled={revokingId === apiKey.id} - > - {revokingId === apiKey.id ? "Revoking..." : "Revoke"} - - - - -
- ))} + ); + })}
)}
diff --git a/web/components/settings/global-settings.tsx b/web/components/settings/global-settings.tsx index 189ef3ce..740f0750 100644 --- a/web/components/settings/global-settings.tsx +++ b/web/components/settings/global-settings.tsx @@ -25,6 +25,7 @@ import { updateProxyDomain, upgradeControlPlane, } from "@/actions/settings"; +import { LocalDate } from "@/components/core/local-date"; import { ApiKeySettings } from "@/components/settings/api-key-settings"; import { EmailSettings } from "@/components/settings/email-settings"; import { MemberSettings } from "@/components/settings/member-settings"; @@ -92,21 +93,9 @@ type Props = { appVersion: string | null; }; -const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - const CONTROL_PLANE_UPGRADE_DOCS_URL = "https://docs.techulus.com/installation#manual-upgrades"; -function formatCheckedAt(value: string | null | undefined) { - if (!value) return "Never"; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return "Unknown"; - return dateTimeFormatter.format(date); -} - export function GlobalSettings({ servers, membersData, @@ -583,7 +572,10 @@ export function GlobalSettings({

Last checked

- {formatCheckedAt(updateState?.checkedAt)} +

diff --git a/web/components/settings/member-settings.tsx b/web/components/settings/member-settings.tsx index 61a9295f..6863ec90 100644 --- a/web/components/settings/member-settings.tsx +++ b/web/components/settings/member-settings.tsx @@ -11,6 +11,7 @@ import { revokeInvitation, updateMemberRole, } from "@/actions/members"; +import { LocalDate } from "@/components/core/local-date"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -44,23 +45,12 @@ type Props = { initialInvitations: InvitationRecord[]; }; -const dateFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - const roleBadgeVariants = { admin: "default", developer: "secondary", reader: "outline", } as const; -function formatDate(value: string | Date) { - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) return "Unknown"; - return dateFormatter.format(date); -} - export function MemberSettings({ initialMembers, initialInvitations }: Props) { const router = useRouter(); const [email, setEmail] = useState(""); @@ -226,7 +216,8 @@ export function MemberSettings({ initialMembers, initialInvitations }: Props) { {member.email}

- Joined {formatDate(member.createdAt)} + Joined{" "} +

{member.role === "admin" ? ( @@ -287,7 +278,11 @@ export function MemberSettings({ initialMembers, initialInvitations }: Props) {

{invitation.email}

- Expires {formatDate(invitation.expiresAt)} + Expires{" "} +

{invitation.role} diff --git a/web/components/ui/canvas-wrapper.tsx b/web/components/ui/canvas-wrapper.tsx index 439e7556..7ed0baee 100644 --- a/web/components/ui/canvas-wrapper.tsx +++ b/web/components/ui/canvas-wrapper.tsx @@ -102,35 +102,6 @@ export function getStatusColorFromDeployments( return defaultColors; } -export type HealthColors = { - dot: string; - text: string; -}; - -const healthColorMap: Record = { - healthy: { - dot: "bg-emerald-500", - text: "text-emerald-600 dark:text-emerald-400", - }, - starting: { - dot: "bg-amber-500", - text: "text-amber-600 dark:text-amber-400", - }, - unhealthy: { - dot: "bg-rose-500", - text: "text-rose-600 dark:text-rose-400", - }, -}; - -const defaultHealthColors: HealthColors = { - dot: "bg-slate-400", - text: "text-slate-500", -}; - -export function getHealthColor(healthStatus: string): HealthColors { - return healthColorMap[healthStatus] || defaultHealthColors; -} - interface CanvasWrapperProps { children?: React.ReactNode; height?: string; diff --git a/web/db/queries.ts b/web/db/queries.ts index 7bf34803..7f70e81b 100644 --- a/web/db/queries.ts +++ b/web/db/queries.ts @@ -18,7 +18,11 @@ import type { SmtpConfig, SmtpEncryption, } from "@/lib/settings-keys"; -import { DEFAULT_SMTP_PORT, DEFAULT_SMTP_TIMEOUT } from "@/lib/settings-keys"; +import { + DEFAULT_BACKUP_RETENTION_DAYS, + DEFAULT_SMTP_PORT, + DEFAULT_SMTP_TIMEOUT, +} from "@/lib/settings-keys"; import { type NodeMetricsSnapshot, queryNodeMetricsSnapshots, @@ -360,6 +364,22 @@ export function getBackupStorageConfig(): BackupStorageConfig | null { return null; } + const configuredRetentionDays = Number( + process.env.BACKUP_STORAGE_RETENTION_DAYS ?? DEFAULT_BACKUP_RETENTION_DAYS, + ); + const retentionDays = + Number.isInteger(configuredRetentionDays) && + configuredRetentionDays >= 1 && + configuredRetentionDays <= 3_650 + ? configuredRetentionDays + : DEFAULT_BACKUP_RETENTION_DAYS; + + if (retentionDays !== configuredRetentionDays) { + console.warn( + `[backup-storage] invalid BACKUP_STORAGE_RETENTION_DAYS; using ${DEFAULT_BACKUP_RETENTION_DAYS}`, + ); + } + return { provider, bucket, @@ -367,10 +387,7 @@ export function getBackupStorageConfig(): BackupStorageConfig | null { endpoint: process.env.BACKUP_STORAGE_ENDPOINT ?? "", accessKey, secretKey, - retentionDays: parseInt( - process.env.BACKUP_STORAGE_RETENTION_DAYS ?? "7", - 10, - ), + retentionDays, }; } diff --git a/web/lib/acme-manager.ts b/web/lib/acme-manager.ts index ceedd2d3..c4d0314f 100644 --- a/web/lib/acme-manager.ts +++ b/web/lib/acme-manager.ts @@ -2,12 +2,18 @@ import { randomUUID } from "node:crypto"; import * as acme from "acme-client"; import { eq, lt } from "drizzle-orm"; import { db } from "@/db"; -import { acmeChallenges, domainCertificates, settings } from "@/db/schema"; import { getSetting } from "@/db/queries"; +import { acmeChallenges, domainCertificates, settings } from "@/db/schema"; +import { + addMilliseconds, + addUtcDays, + isExpired, + MINUTE_IN_MILLISECONDS, +} from "@/lib/date"; import { SETTING_KEYS } from "@/lib/settings-keys"; const ACME_ACCOUNT_KEY_SETTING = "acme_account_key"; -const CHALLENGE_TTL_MS = 10 * 60 * 1000; +const CHALLENGE_TTL_MS = 10 * MINUTE_IN_MILLISECONDS; let acmeClient: acme.Client | null = null; @@ -57,7 +63,7 @@ async function storeChallenge( token: string, keyAuthorization: string, ): Promise { - const expiresAt = new Date(Date.now() + CHALLENGE_TTL_MS); + const expiresAt = addMilliseconds(new Date(), CHALLENGE_TTL_MS); await db .insert(acmeChallenges) .values({ @@ -186,21 +192,23 @@ export async function getChallenge( .where(eq(acmeChallenges.token, token)); console.log(`[getChallenge] token=${token} found=${!!result[0]}`); - if (result[0]) { + const challenge = result[0]; + const now = new Date(); + if (challenge) { console.log( - `[getChallenge] expires_at=${result[0].expiresAt}, now=${new Date()}, valid=${new Date() < result[0].expiresAt}`, + `[getChallenge] expires_at=${challenge.expiresAt}, now=${now}, valid=${!isExpired(challenge.expiresAt, now)}`, ); } - if (!result[0] || new Date() > result[0].expiresAt) { + if (!challenge || isExpired(challenge.expiresAt, now)) { return null; } - return { keyAuthorization: result[0].keyAuthorization }; + return { keyAuthorization: challenge.keyAuthorization }; } export async function renewExpiringCertificates(): Promise { - const thirtyDaysFromNow = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const thirtyDaysFromNow = addUtcDays(new Date(), 30); const expiring = await db .select() diff --git a/web/lib/agent-auth.ts b/web/lib/agent-auth.ts index 49b4e3e4..37d756c9 100644 --- a/web/lib/agent-auth.ts +++ b/web/lib/agent-auth.ts @@ -1,10 +1,11 @@ +import { eq } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { db } from "@/db"; import { servers } from "@/db/schema"; -import { eq } from "drizzle-orm"; +import { MINUTE_IN_MILLISECONDS } from "@/lib/date"; import { verifyEd25519Signature } from "./crypto"; -const TIMESTAMP_TOLERANCE_MS = 60 * 1000; +const TIMESTAMP_TOLERANCE_MS = MINUTE_IN_MILLISECONDS; export type AuthResult = | { success: true; serverId: string; serverName: string } @@ -26,10 +27,10 @@ export async function verifyAgentRequest( }; } - const timestampMs = Number.parseInt(timestamp, 10); + const timestampMs = Number(timestamp); const now = Date.now(); if ( - Number.isNaN(timestampMs) || + !Number.isSafeInteger(timestampMs) || Math.abs(now - timestampMs) > TIMESTAMP_TOLERANCE_MS ) { return { diff --git a/web/lib/agent/expected-state.ts b/web/lib/agent/expected-state.ts index 74c6f4a5..cb13d987 100644 --- a/web/lib/agent/expected-state.ts +++ b/web/lib/agent/expected-state.ts @@ -264,15 +264,6 @@ async function fetchDeploymentPorts(deploymentIds: string[]) { .where(inArray(deploymentPorts.deploymentId, deploymentIds)); } -async function fetchServicePorts(serviceIds: string[]) { - if (serviceIds.length === 0) return []; - - return db - .select() - .from(servicePorts) - .where(inArray(servicePorts.serviceId, serviceIds)); -} - export function buildExpectedContainersFromRows({ deployments: deploymentRows, services: serviceRows, diff --git a/web/lib/api-auth.ts b/web/lib/api-auth.ts index 0ba7413d..a411461a 100644 --- a/web/lib/api-auth.ts +++ b/web/lib/api-auth.ts @@ -111,7 +111,3 @@ export async function requireRequestRole( export async function requireRequestDeveloperRole(request: Request) { return requireRequestRole(request, ["admin", "developer"]); } - -export async function requireRequestAdminRole(request: Request) { - return requireRequestRole(request, ["admin"]); -} diff --git a/web/lib/auth.ts b/web/lib/auth.ts index 73bc27fb..8e38b566 100644 --- a/web/lib/auth.ts +++ b/web/lib/auth.ts @@ -1,6 +1,7 @@ import { apiKey } from "@better-auth/api-key"; import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { isAPIError } from "better-auth/api"; import { bearer, deviceAuthorization, twoFactor } from "better-auth/plugins"; import { admin } from "better-auth/plugins/admin"; import { userAc } from "better-auth/plugins/admin/access"; @@ -9,6 +10,7 @@ import { db } from "@/db"; import * as schema from "@/db/schema"; import type { MemberRole } from "@/db/types"; import { getUserRole, hasAnyRole } from "@/lib/members"; +import { type DeleteConfirmation, getDeleteTotpCode } from "@/lib/two-factor"; const TECHULUS_CLI_CLIENT_ID = "techulus-cli"; const APP_NAME = "Techulus Cloud"; @@ -96,6 +98,37 @@ export async function requireDeveloperRole() { return requireRole(["admin", "developer"]); } +export async function verifyDeleteConfirmation( + session: Awaited>, + confirmation: DeleteConfirmation | undefined, + resource: string, +) { + if (!session) { + throw new Error("Unauthorized"); + } + + const totpCode = getDeleteTotpCode( + Boolean( + (session.user as { twoFactorEnabled?: boolean | null }).twoFactorEnabled, + ), + confirmation, + resource, + ); + if (!totpCode) return; + + try { + await auth.api.verifyTOTP({ + body: { code: totpCode }, + headers: await headers(), + }); + } catch (error) { + if (isAPIError(error)) { + throw new Error("Invalid authenticator code"); + } + throw error; + } +} + export async function requireAdminRole() { return requireRole(["admin"]); } diff --git a/web/lib/backup-scheduler.ts b/web/lib/backup-scheduler.ts index 6a0a2190..47260dbb 100644 --- a/web/lib/backup-scheduler.ts +++ b/web/lib/backup-scheduler.ts @@ -4,6 +4,11 @@ import { db } from "@/db"; import { getBackupStorageConfig } from "@/db/queries"; import { services, serviceVolumes, volumeBackups } from "@/db/schema"; import { triggerBackup } from "@/lib/backups/trigger-backup"; +import { + differenceInElapsedDays, + differenceInElapsedHours, + subtractUtcDays, +} from "@/lib/date"; import { DEFAULT_BACKUP_RETENTION_DAYS } from "@/lib/settings-keys"; function shouldRunSchedule( @@ -20,8 +25,7 @@ function shouldRunSchedule( } if (lastBackupTime) { - const hoursSince = - (now.getTime() - lastBackupTime.getTime()) / (1000 * 60 * 60); + const hoursSince = differenceInElapsedHours(now, lastBackupTime); if (hoursSince < 20) { return false; } @@ -36,8 +40,7 @@ function shouldRunSchedule( } if (lastBackupTime) { - const daysSince = - (now.getTime() - lastBackupTime.getTime()) / (1000 * 60 * 60 * 24); + const daysSince = differenceInElapsedDays(now, lastBackupTime); if (daysSince < 6) { return false; } @@ -124,8 +127,7 @@ export async function cleanupOldBackups() { const retentionDays = storageConfig?.retentionDays ?? DEFAULT_BACKUP_RETENTION_DAYS; - const cutoffDate = new Date(); - cutoffDate.setDate(cutoffDate.getDate() - retentionDays); + const cutoffDate = subtractUtcDays(new Date(), retentionDays); const oldBackups = await db .select({ id: volumeBackups.id }) diff --git a/web/lib/date.ts b/web/lib/date.ts index 81325055..fc518df0 100644 --- a/web/lib/date.ts +++ b/web/lib/date.ts @@ -1,40 +1,326 @@ -export function formatTime(date: string | Date): string { - return new Date(date).toLocaleTimeString("en-US", { - hour12: false, +/** A Date, parseable date string, or Unix timestamp in milliseconds. */ +export type DateInput = Date | string | number; + +export type DateFormatOptions = { + fallback?: string; + timeZone?: string; +}; + +type DateFormat = + | "date" + | "dateTime" + | "preciseDateTime" + | "time" + | "compactDate" + | "compactDateTime" + | "utcDateTime"; + +const DEFAULT_FALLBACK = "—"; +const DATE_LOCALE = "en-US"; + +export const SECOND_IN_MILLISECONDS = 1_000; +export const MINUTE_IN_MILLISECONDS = 60 * SECOND_IN_MILLISECONDS; +export const HOUR_IN_MILLISECONDS = 60 * MINUTE_IN_MILLISECONDS; +export const DAY_IN_MILLISECONDS = 24 * HOUR_IN_MILLISECONDS; + +const DATE_FORMATS: Record = { + date: { + year: "numeric", + month: "short", + day: "numeric", + }, + dateTime: { + year: "numeric", + month: "short", + day: "numeric", hour: "2-digit", minute: "2-digit", - second: "2-digit", - }); -} - -export function formatDateTime(date: string | Date): string { - return new Date(date).toLocaleString("en-US", { - hour12: false, + hourCycle: "h23", + }, + preciseDateTime: { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", + hourCycle: "h23", + }, + time: { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }, + compactDate: { + month: "short", + day: "numeric", + }, + compactDateTime: { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }, + utcDateTime: { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZone: "UTC", + timeZoneName: "short", + }, +}; + +const formatterCache = new Map(); + +function getFormatter(format: DateFormat, timeZone?: string) { + const cacheKey = `${format}:${timeZone ?? "local"}`; + const cached = formatterCache.get(cacheKey); + if (cached) return cached; + + const formatter = new Intl.DateTimeFormat(DATE_LOCALE, { + ...DATE_FORMATS[format], + ...(timeZone ? { timeZone } : {}), }); + formatterCache.set(cacheKey, formatter); + return formatter; } -function formatDate(date: string | Date): string { - return new Date(date).toLocaleDateString(); +function requireDate(value: DateInput): Date { + const date = toDate(value); + if (!date) throw new RangeError("Invalid date"); + return date; } -export function formatRelativeTime(date: string | Date): string { - const now = new Date(); - const then = new Date(date); - const diffMs = now.getTime() - then.getTime(); - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); +function requireValidResult(date: Date): Date { + if (Number.isNaN(date.getTime())) { + throw new RangeError("Date arithmetic produced an out-of-range date"); + } + return date; +} + +function requireFinite(value: number, name: string) { + if (!Number.isFinite(value)) { + throw new RangeError(`${name} must be a finite number`); + } +} + +function requireInteger(value: number, name: string) { + requireFinite(value, name); + if (!Number.isInteger(value)) { + throw new RangeError(`${name} must be an integer`); + } +} + +function formatWith( + value: DateInput | null | undefined, + format: DateFormat, + options: DateFormatOptions = {}, +) { + const date = toDate(value); + if (!date) return options.fallback ?? DEFAULT_FALLBACK; + return getFormatter(format, options.timeZone).format(date); +} + +/** Parses leniently and returns null for missing or invalid inputs. */ +export function toDate(value: DateInput | null | undefined): Date | null { + if (value === null || value === undefined || value === "") return null; + + const date = + value instanceof Date ? new Date(value.getTime()) : new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** Returns epoch milliseconds, or the supplied fallback for invalid inputs. */ +export function getTimestamp( + value: DateInput | null | undefined, + fallback = Number.NaN, +): number { + return toDate(value)?.getTime() ?? fallback; +} + +export function formatDate( + value: DateInput | null | undefined, + options?: DateFormatOptions, +): string { + return formatWith(value, "date", options); +} + +export function formatDateTime( + value: DateInput | null | undefined, + options?: DateFormatOptions, +): string { + return formatWith(value, "dateTime", options); +} + +export function formatPreciseDateTime( + value: DateInput | null | undefined, + options?: DateFormatOptions, +): string { + return formatWith(value, "preciseDateTime", options); +} + +export function formatDateTimeUtc( + value: DateInput | null | undefined, + options: Pick = {}, +): string { + return formatWith(value, "utcDateTime", options); +} + +export function formatTime( + value: DateInput | null | undefined, + options?: DateFormatOptions, +): string { + return formatWith(value, "time", options); +} + +export function formatCompactDate( + value: DateInput | null | undefined, + options?: DateFormatOptions, +): string { + return formatWith(value, "compactDate", options); +} + +export function formatCompactDateTime( + value: DateInput | null | undefined, + options?: DateFormatOptions, +): string { + return formatWith(value, "compactDateTime", options); +} + +export function formatRelativeTime( + value: DateInput | null | undefined, + options: DateFormatOptions & { now?: DateInput } = {}, +): string { + const date = toDate(value); + const now = toDate(options.now ?? new Date()); + if (!date || !now) return options.fallback ?? DEFAULT_FALLBACK; + + const differenceMs = date.getTime() - now.getTime(); + const isFuture = differenceMs > 0; + const absoluteSeconds = Math.floor( + Math.abs(differenceMs) / SECOND_IN_MILLISECONDS, + ); + + if (absoluteSeconds < 60) return "just now"; + + const absoluteMinutes = Math.floor(absoluteSeconds / 60); + if (absoluteMinutes < 60) { + return isFuture ? `in ${absoluteMinutes}m` : `${absoluteMinutes}m ago`; + } + + const absoluteHours = Math.floor(absoluteMinutes / 60); + if (absoluteHours < 24) { + return isFuture ? `in ${absoluteHours}h` : `${absoluteHours}h ago`; + } + + const absoluteDays = Math.floor(absoluteHours / 24); + if (absoluteDays < 7) { + return isFuture ? `in ${absoluteDays}d` : `${absoluteDays}d ago`; + } + + return formatDate(date, options); +} + +export function formatElapsedDuration( + durationMs: number, + options: Pick = {}, +): string { + if (!Number.isFinite(durationMs)) { + return options.fallback ?? DEFAULT_FALLBACK; + } + + const totalSeconds = Math.max( + 0, + Math.floor(durationMs / SECOND_IN_MILLISECONDS), + ); + const hours = Math.floor(totalSeconds / 3_600); + const minutes = Math.floor((totalSeconds % 3_600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`; + } + if (minutes > 0) return `${minutes}m ${seconds}s`; + return `${seconds}s`; +} + +export function formatElapsedDurationBetween( + start: DateInput | null | undefined, + end: DateInput | null | undefined, + options: Pick & { now?: DateInput } = {}, +): string { + const startDate = toDate(start); + const endDate = toDate(end ?? options.now ?? new Date()); + if (!startDate || !endDate) return options.fallback ?? DEFAULT_FALLBACK; + + return formatElapsedDuration( + endDate.getTime() - startDate.getTime(), + options, + ); +} + +/** Compares valid inputs and throws RangeError if either input is invalid. */ +export function isDateBefore(left: DateInput, right: DateInput): boolean { + return requireDate(left) < requireDate(right); +} + +/** Compares valid inputs and throws RangeError if either input is invalid. */ +export function isDateAfter(left: DateInput, right: DateInput): boolean { + return requireDate(left) > requireDate(right); +} + +/** Fail-closed: a missing or invalid expiry is treated as expired. */ +export function isExpired( + expiresAt: DateInput | null | undefined, + now: DateInput = new Date(), +): boolean { + const expiryDate = toDate(expiresAt); + if (!expiryDate) return true; + return expiryDate.getTime() <= requireDate(now).getTime(); +} + +export function addMilliseconds(date: DateInput, amount: number): Date { + requireInteger(amount, "amount"); + return requireValidResult(new Date(requireDate(date).getTime() + amount)); +} + +export function subtractMilliseconds(date: DateInput, amount: number): Date { + return addMilliseconds(date, -amount); +} + +export function addUtcDays(date: DateInput, days: number): Date { + requireInteger(days, "days"); + const result = requireDate(date); + result.setUTCDate(result.getUTCDate() + days); + return requireValidResult(result); +} + +export function subtractUtcDays(date: DateInput, days: number): Date { + return addUtcDays(date, -days); +} + +export function differenceInMilliseconds( + later: DateInput, + earlier: DateInput, +): number { + return requireDate(later).getTime() - requireDate(earlier).getTime(); +} + +export function differenceInElapsedHours( + later: DateInput, + earlier: DateInput, +): number { + return differenceInMilliseconds(later, earlier) / HOUR_IN_MILLISECONDS; +} - if (diffSecs < 60) return "just now"; - if (diffMins < 60) return `${diffMins}m ago`; - if (diffHours < 24) return `${diffHours}h ago`; - if (diffDays < 7) return `${diffDays}d ago`; - return formatDate(then); +export function differenceInElapsedDays( + later: DateInput, + earlier: DateInput, +): number { + return differenceInMilliseconds(later, earlier) / DAY_IN_MILLISECONDS; } diff --git a/web/lib/deployment-status.ts b/web/lib/deployment-status.ts index ed4619ad..efbbfb97 100644 --- a/web/lib/deployment-status.ts +++ b/web/lib/deployment-status.ts @@ -63,12 +63,6 @@ export function isTrafficActive(trafficState: TrafficState): boolean { return trafficState === "active"; } -export function isDeploymentExpected( - deployment: Pick, -): boolean { - return isRuntimeExpected(deployment.runtimeDesiredState); -} - export function isDeploymentRoutable( deployment: Pick, ): boolean { diff --git a/web/lib/email/index.ts b/web/lib/email/index.ts index c12ce9e6..60977b48 100644 --- a/web/lib/email/index.ts +++ b/web/lib/email/index.ts @@ -6,7 +6,7 @@ import type { ReactElement } from "react"; import { db } from "@/db"; import { getEmailAlertsConfig, getSmtpConfig } from "@/db/queries"; import { environments, projects, servers, services } from "@/db/schema"; -import { formatDateTime } from "@/lib/date"; +import { formatDateTimeUtc } from "@/lib/date"; import type { SmtpConfig } from "@/lib/settings-keys"; import { Alert } from "./templates/alert"; import { MemberInvitation } from "./templates/member-invitation"; @@ -152,7 +152,7 @@ export async function sendServerOfflineAlert( ...(options.serverIp ? [{ label: "IP Address", value: options.serverIp }] : []), - { label: "Detected At", value: formatDateTime(new Date()) }, + { label: "Detected At", value: formatDateTimeUtc(new Date()) }, ]; await sendAlert({ @@ -203,7 +203,7 @@ export async function sendManualRecoveryRequiredAlert( : []), { label: "Impacted Replicas", value: String(options.impactedReplicas) }, { label: "Services", value: serviceSummary }, - { label: "Detected At", value: formatDateTime(new Date()) }, + { label: "Detected At", value: formatDateTimeUtc(new Date()) }, ]; await sendAlert({ diff --git a/web/lib/inngest/functions/rollout-utils.ts b/web/lib/inngest/functions/rollout-utils.ts index f78c3857..30f4432c 100644 --- a/web/lib/inngest/functions/rollout-utils.ts +++ b/web/lib/inngest/functions/rollout-utils.ts @@ -4,24 +4,6 @@ import { deployments, rollouts } from "@/db/schema"; import { markDeploymentFailedRemoved } from "@/lib/deployment-status"; import { sendDeploymentFailureAlert } from "@/lib/email"; -export function shouldRollBackDeploymentState(deployment: { - trafficState: string; - runtimeDesiredState: string; -}) { - void deployment.trafficState; - return deployment.runtimeDesiredState !== "removed"; -} - -export function shouldRestoreDrainingDeployment(deployment: { - trafficState: string; - runtimeDesiredState: string; -}) { - return ( - deployment.trafficState === "draining" && - deployment.runtimeDesiredState !== "removed" - ); -} - export async function restoreDrainingDeploymentsForRollback(serviceId: string) { await db .update(deployments) diff --git a/web/lib/inngest/functions/service-deletion-workflow.ts b/web/lib/inngest/functions/service-deletion-workflow.ts index 60a15634..640e533b 100644 --- a/web/lib/inngest/functions/service-deletion-workflow.ts +++ b/web/lib/inngest/functions/service-deletion-workflow.ts @@ -12,6 +12,7 @@ import { serviceVolumes, volumeBackups, } from "@/db/schema"; +import { addUtcDays, toDate } from "@/lib/date"; import { deployServiceInternal } from "@/lib/deploy-service"; import { markDeploymentRemoved } from "@/lib/deployment-status"; import { enqueueWork } from "@/lib/work-queue"; @@ -31,16 +32,6 @@ async function markServiceDeletionFailed(serviceId: string, error: unknown) { .where(eq(services.id, serviceId)); } -function purgeDateFrom(date: Date) { - const purgeAfter = new Date(date); - purgeAfter.setDate(purgeAfter.getDate() + DELETED_SERVICE_RETENTION_DAYS); - return purgeAfter; -} - -function restoreDate(value: Date | string | null) { - return value ? new Date(value) : null; -} - export const serviceDeletionWorkflow = inngest.createFunction( { id: "service-deletion-workflow", @@ -145,59 +136,61 @@ export const serviceDeletionWorkflow = inngest.createFunction( if (newBackupIds.length > 0) { const backupResults = await Promise.all( newBackupIds.map((backupId) => - group.parallel(async (): Promise<{ - status: "completed" | "failed" | "pending" | "timed_out"; - error?: string; - }> => { - const readBackup = async () => - db - .select({ - status: volumeBackups.status, - errorMessage: volumeBackups.errorMessage, - }) - .from(volumeBackups) - .where(eq(volumeBackups.id, backupId)) - .then((r) => r[0]); - - const before = await step.run( - `check-delete-backup-${backupId}-before`, - readBackup, - ); - if (before?.status === "completed") { - return { status: "completed" as const }; - } - if (before?.status === "failed") { - return { - status: "failed" as const, - error: before.errorMessage || "Deletion backup failed", - }; - } - - const wakeup = await step.waitForEvent( - `wait-delete-backup-status-${backupId}`, - { - event: inngestEvents.resourceStatusChanged, - timeout: "30m", - if: `async.data.type == "backup" && async.data.id == "${backupId}"`, - }, - ); - - const after = await step.run( - `check-delete-backup-${backupId}-after`, - readBackup, - ); - if (after?.status === "completed") { - return { status: "completed" as const }; - } - if (after?.status === "failed") { - return { - status: "failed" as const, - error: after.errorMessage || "Deletion backup failed", - }; - } - - return { status: wakeup ? "pending" : "timed_out" } as const; - }), + group.parallel( + async (): Promise<{ + status: "completed" | "failed" | "pending" | "timed_out"; + error?: string; + }> => { + const readBackup = async () => + db + .select({ + status: volumeBackups.status, + errorMessage: volumeBackups.errorMessage, + }) + .from(volumeBackups) + .where(eq(volumeBackups.id, backupId)) + .then((r) => r[0]); + + const before = await step.run( + `check-delete-backup-${backupId}-before`, + readBackup, + ); + if (before?.status === "completed") { + return { status: "completed" as const }; + } + if (before?.status === "failed") { + return { + status: "failed" as const, + error: before.errorMessage || "Deletion backup failed", + }; + } + + const wakeup = await step.waitForEvent( + `wait-delete-backup-status-${backupId}`, + { + event: inngestEvents.resourceStatusChanged, + timeout: "30m", + if: `async.data.type == "backup" && async.data.id == "${backupId}"`, + }, + ); + + const after = await step.run( + `check-delete-backup-${backupId}-after`, + readBackup, + ); + if (after?.status === "completed") { + return { status: "completed" as const }; + } + if (after?.status === "failed") { + return { + status: "failed" as const, + error: after.errorMessage || "Deletion backup failed", + }; + } + + return { status: wakeup ? "pending" : "timed_out" } as const; + }, + ), ), ); @@ -271,7 +264,7 @@ export const serviceDeletionWorkflow = inngest.createFunction( .update(services) .set({ deletedAt, - purgeAfter: purgeDateFrom(deletedAt), + purgeAfter: addUtcDays(deletedAt, DELETED_SERVICE_RETENTION_DAYS), originalHostname: setup.service.hostname, hostname: null, deletionStatus: null, @@ -437,8 +430,8 @@ export const serviceRestoreWorkflow = inngest.createFunction( await db .update(services) .set({ - deletedAt: restoreDate(setup.service.deletedAt), - purgeAfter: restoreDate(setup.service.purgeAfter), + deletedAt: toDate(setup.service.deletedAt), + purgeAfter: toDate(setup.service.purgeAfter), hostname: null, originalHostname: setup.service.originalHostname, deletionStatus: "failed", @@ -493,8 +486,8 @@ export const serviceRestoreWorkflow = inngest.createFunction( await db .update(services) .set({ - deletedAt: restoreDate(setup.service.deletedAt), - purgeAfter: restoreDate(setup.service.purgeAfter), + deletedAt: toDate(setup.service.deletedAt), + purgeAfter: toDate(setup.service.purgeAfter), hostname: null, originalHostname: setup.service.originalHostname, deletionStatus: "failed", diff --git a/web/lib/log-query.ts b/web/lib/log-query.ts new file mode 100644 index 00000000..baaffd70 --- /dev/null +++ b/web/lib/log-query.ts @@ -0,0 +1,146 @@ +export const LOG_TIME_RANGES = ["1h", "6h", "24h", "7d"] as const; +export type LogTimeRange = (typeof LOG_TIME_RANGES)[number]; + +export const DEFAULT_LOG_TIME_RANGE: LogTimeRange = "24h"; +export const MAX_LOG_SEARCH_LENGTH = 200; + +const LOG_CURSOR_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-](\d{2}):(\d{2}))$/; + +export function normalizeLogSearch( + value: string | null | undefined, +): string | undefined { + const search = value?.trim(); + if (!search) return undefined; + if (search.length > MAX_LOG_SEARCH_LENGTH) { + throw new RangeError( + `Search must be ${MAX_LOG_SEARCH_LENGTH} characters or fewer`, + ); + } + return search; +} + +export function isLogCursor(value: string): boolean { + const match = LOG_CURSOR_PATTERN.exec(value); + if (!match) return false; + + const [ + , + yearValue, + monthValue, + dayValue, + hourValue, + minuteValue, + secondValue, + ] = match; + const year = Number(yearValue); + const month = Number(monthValue); + const day = Number(dayValue); + const hour = Number(hourValue); + const minute = Number(minuteValue); + const second = Number(secondValue); + const offsetHour = Number(match[7] || 0); + const offsetMinute = Number(match[8] || 0); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const daysInMonth = [ + 31, + leapYear ? 29 : 28, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ][month - 1]; + + return ( + daysInMonth !== undefined && + day >= 1 && + day <= daysInMonth && + hour <= 23 && + minute <= 59 && + second <= 59 && + offsetHour <= 23 && + offsetMinute <= 59 + ); +} + +export function normalizeLogCursor( + value: string | null | undefined, +): string | undefined { + const cursor = value?.trim(); + if (!cursor) return undefined; + if (!isLogCursor(cursor)) { + throw new RangeError("Invalid log cursor"); + } + return cursor; +} + +export function parseLogLimit( + value: string | null | undefined, + defaultValue: number, + maxValue: number = 1000, +): number { + if (value === null || value === undefined || value === "") { + return defaultValue; + } + if (!/^\d+$/.test(value)) { + throw new RangeError("Invalid log limit"); + } + + const limit = Number(value); + if (!Number.isSafeInteger(limit) || limit < 1) { + throw new RangeError("Invalid log limit"); + } + return Math.min(limit, maxValue); +} + +export function parseLogListParams( + searchParams: URLSearchParams, + defaultLimit: number, +): { + search: string | undefined; + before: string | undefined; + limit: number; + range: LogTimeRange; +} { + const search = normalizeLogSearch(searchParams.get("q")); + const before = normalizeLogCursor(searchParams.get("before")); + const limit = parseLogLimit(searchParams.get("limit"), defaultLimit); + const rangeValue = searchParams.get("range") || DEFAULT_LOG_TIME_RANGE; + const range = LOG_TIME_RANGES.find((option) => option === rangeValue); + if (!range) { + throw new RangeError("Invalid log range"); + } + + return { search, before, limit, range }; +} + +export function invalidLogQueryResponse(error: unknown): Response { + return Response.json( + { message: error instanceof Error ? error.message : "Invalid log query" }, + { status: 400 }, + ); +} + +export function escapeLogRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function splitLogSearchMatches( + text: string, + search: string, +): Array<{ text: string; isMatch: boolean }> { + if (!search) return [{ text, isMatch: false }]; + + const regex = new RegExp(`(${escapeLogRegex(search)})`, "gi"); + const normalizedSearch = search.toLowerCase(); + return text.split(regex).map((part) => ({ + text: part, + isMatch: part.toLowerCase() === normalizedSearch, + })); +} diff --git a/web/lib/members.ts b/web/lib/members.ts index 9612d322..a05b59b6 100644 --- a/web/lib/members.ts +++ b/web/lib/members.ts @@ -4,7 +4,6 @@ import { db } from "@/db"; import { user } from "@/db/schema"; import type { InvitableMemberRole, MemberRole } from "@/db/types"; -export const MEMBER_ROLES = ["admin", "developer", "reader"] as const; export const INVITABLE_MEMBER_ROLES = ["developer", "reader"] as const; export const ADMIN_NOT_CONFIGURED_MESSAGE = "No admin user is configured. Run `pnpm admin:create ` from the web app to create the first admin."; @@ -20,13 +19,6 @@ export class AdminNotConfiguredError extends Error { } } -export function isMemberRole(value: unknown): value is MemberRole { - return ( - typeof value === "string" && - MEMBER_ROLES.includes(value as (typeof MEMBER_ROLES)[number]) - ); -} - export function isInvitableMemberRole( value: unknown, ): value is InvitableMemberRole { diff --git a/web/lib/metric-ranges.ts b/web/lib/metric-ranges.ts index 5fa5f669..54efa012 100644 --- a/web/lib/metric-ranges.ts +++ b/web/lib/metric-ranges.ts @@ -1,9 +1,14 @@ +import { DAY_IN_MILLISECONDS, HOUR_IN_MILLISECONDS } from "@/lib/date"; + export const METRIC_RANGE_OPTIONS = { - "1h": { durationMs: 60 * 60 * 1000, stepSeconds: 60 }, - "6h": { durationMs: 6 * 60 * 60 * 1000, stepSeconds: 60 }, - "24h": { durationMs: 24 * 60 * 60 * 1000, stepSeconds: 5 * 60 }, - "7d": { durationMs: 7 * 24 * 60 * 60 * 1000, stepSeconds: 30 * 60 }, - "30d": { durationMs: 30 * 24 * 60 * 60 * 1000, stepSeconds: 2 * 60 * 60 }, + "1h": { durationMs: HOUR_IN_MILLISECONDS, stepSeconds: 60 }, + "6h": { durationMs: 6 * HOUR_IN_MILLISECONDS, stepSeconds: 60 }, + "24h": { durationMs: DAY_IN_MILLISECONDS, stepSeconds: 5 * 60 }, + "7d": { durationMs: 7 * DAY_IN_MILLISECONDS, stepSeconds: 30 * 60 }, + "30d": { + durationMs: 30 * DAY_IN_MILLISECONDS, + stepSeconds: 2 * 60 * 60, + }, } as const; export type MetricRange = keyof typeof METRIC_RANGE_OPTIONS; diff --git a/web/lib/scheduler.ts b/web/lib/scheduler.ts index 3bd882d2..700f6233 100644 --- a/web/lib/scheduler.ts +++ b/web/lib/scheduler.ts @@ -9,6 +9,13 @@ import { services, workQueue, } from "@/db/schema"; +import { + DAY_IN_MILLISECONDS, + isDateAfter, + MINUTE_IN_MILLISECONDS, + SECOND_IN_MILLISECONDS, + subtractMilliseconds, +} from "@/lib/date"; import { deployServiceInternal } from "@/lib/deploy-service"; import { sendManualRecoveryRequiredAlert, @@ -19,7 +26,7 @@ import { WORK_QUEUE_MAX_ATTEMPTS, } from "@/lib/work-queue"; -const STALE_THRESHOLD_MS = 75_000; // 75 seconds +const STALE_THRESHOLD_MS = 75 * SECOND_IN_MILLISECONDS; async function triggerRecoveryForOfflineServers( offlineServerIds: string[], @@ -99,7 +106,7 @@ async function triggerRecoveryForOfflineServers( export async function checkAndRecoverStaleServers( excludeServerId?: string, ): Promise { - const staleThreshold = new Date(Date.now() - STALE_THRESHOLD_MS); + const staleThreshold = subtractMilliseconds(new Date(), STALE_THRESHOLD_MS); const conditions = [ eq(servers.status, "online"), @@ -174,7 +181,7 @@ export async function checkAndRunScheduledDeployments(): Promise { currentDate: service.lastScheduledDeploymentRunAt, }); const nextScheduledRun = interval.next().toDate(); - if (nextScheduledRun > now) { + if (isDateAfter(nextScheduledRun, now)) { continue; } } @@ -226,11 +233,14 @@ export async function checkAndRunScheduledDeployments(): Promise { console.log("[scheduler] finished checking scheduled deployments"); } -const OLD_ITEM_THRESHOLD_MS = 90 * 24 * 60 * 60 * 1000; -const AGENT_UPGRADE_TIMEOUT_MS = 5 * 60 * 1000; +const OLD_ITEM_THRESHOLD_MS = 90 * DAY_IN_MILLISECONDS; +const AGENT_UPGRADE_TIMEOUT_MS = 5 * MINUTE_IN_MILLISECONDS; export async function failTimedOutAgentUpgrades(): Promise { - const timeoutThreshold = new Date(Date.now() - AGENT_UPGRADE_TIMEOUT_MS); + const timeoutThreshold = subtractMilliseconds( + new Date(), + AGENT_UPGRADE_TIMEOUT_MS, + ); const timedOut = await db .update(servers) @@ -268,10 +278,11 @@ export async function failTimedOutAgentUpgrades(): Promise { } export async function cleanupStaleItems(): Promise { - const workItemLeaseThreshold = new Date( - Date.now() - WORK_QUEUE_LEASE_DURATION_MS, + const workItemLeaseThreshold = subtractMilliseconds( + new Date(), + WORK_QUEUE_LEASE_DURATION_MS, ); - const oldThreshold = new Date(Date.now() - OLD_ITEM_THRESHOLD_MS); + const oldThreshold = subtractMilliseconds(new Date(), OLD_ITEM_THRESHOLD_MS); // Pending work is intentionally retained so commands can run when an agent // reconnects. Only exhausted processing attempts are failed here. diff --git a/web/lib/two-factor.ts b/web/lib/two-factor.ts index c45e0502..2ba72485 100644 --- a/web/lib/two-factor.ts +++ b/web/lib/two-factor.ts @@ -1,5 +1,9 @@ export const DEFAULT_AUTH_REDIRECT = "/dashboard"; +export type DeleteConfirmation = { + totpCode?: string; +}; + const AUTH_REDIRECT_ORIGIN = "https://auth.local"; function hasUnsafeAuthRedirectCharacter(value: string) { @@ -37,3 +41,20 @@ export function getTotpSecret(totpURI: string) { return ""; } } + +export function getDeleteTotpCode( + twoFactorEnabled: boolean, + confirmation: DeleteConfirmation | undefined, + resource: string, +) { + if (!twoFactorEnabled) return null; + + const totpCode = confirmation?.totpCode; + if (typeof totpCode !== "string" || !/^\d{6}$/.test(totpCode)) { + throw new Error( + `Authenticator code is required to delete this ${resource}`, + ); + } + + return totpCode; +} diff --git a/web/lib/victoria-logs.ts b/web/lib/victoria-logs.ts index 9540568d..784d6f13 100644 --- a/web/lib/victoria-logs.ts +++ b/web/lib/victoria-logs.ts @@ -1,3 +1,10 @@ +import { + escapeLogRegex, + type LogTimeRange, + normalizeLogCursor, + normalizeLogSearch, +} from "@/lib/log-query"; + const VICTORIA_LOGS_URL = process.env.VICTORIA_LOGS_URL; const VICTORIA_LOGS_PRIVATE_URL = process.env.VICTORIA_LOGS_PRIVATE_URL; @@ -33,6 +40,7 @@ function buildFetchOptions(config: EndpointConfig): RequestInit { } export type LogType = "container" | "http"; +type LogSearchField = "_msg" | "path" | "method" | "status" | "client_ip"; export type StoredLog = { _msg: string; @@ -64,6 +72,20 @@ function formatLogSqlExactFilter(field: string, value: string): string { return `${field}:${trimmed}`; } +export function formatLogSqlSearchFilter( + value: string | null | undefined, + fields: readonly LogSearchField[] = ["_msg"], +): string | undefined { + const search = normalizeLogSearch(value); + if (!search) return undefined; + + const pattern = `(?i)${escapeLogRegex(search)}`; + const quotedPattern = JSON.stringify(pattern); + const filters = fields.map((field) => `${field}:~${quotedPattern}`); + + return filters.length === 1 ? filters[0] : `(${filters.join(" OR ")})`; +} + type QueryLogsByServiceOptions = { serviceId: string; limit: number; @@ -71,12 +93,15 @@ type QueryLogsByServiceOptions = { before?: string; logType?: LogType; serverId?: string; + search?: string; + range?: LogTimeRange; }; export async function queryLogsByService( options: QueryLogsByServiceOptions, ): Promise<{ logs: StoredLog[]; hasMore: boolean }> { - const { serviceId, limit, after, before, logType, serverId } = options; + const { serviceId, limit, after, before, logType, serverId, search, range } = + options; const endpoint = getQueryEndpoint(); if (!endpoint) { @@ -94,11 +119,25 @@ export async function queryLogsByService( if (serverId) { query += ` ${formatLogSqlExactFilter("server_id", serverId)}`; } - if (after) { - query += ` _time:>${after}`; + if (range) { + query += ` _time:${range}`; + } + const afterCursor = normalizeLogCursor(after); + if (afterCursor) { + query += ` _time:>${afterCursor}`; + } + const beforeCursor = normalizeLogCursor(before); + if (beforeCursor) { + query += ` _time:<${beforeCursor}`; } - if (before) { - query += ` _time:<${before}`; + const searchFilter = formatLogSqlSearchFilter( + search, + logType === "http" + ? ["_msg", "path", "method", "status", "client_ip"] + : ["_msg"], + ); + if (searchFilter) { + query += ` ${searchFilter}`; } query += " | sort by (_time desc)"; @@ -127,7 +166,7 @@ export async function queryLogsByService( export async function queryLogsByDeployment( deploymentId: string, limit: number, - before?: string, + after?: string, ): Promise<{ logs: StoredLog[]; hasMore: boolean }> { const endpoint = getQueryEndpoint(); if (!endpoint) { @@ -135,8 +174,9 @@ export async function queryLogsByDeployment( } let query = formatLogSqlExactFilter("deployment_id", deploymentId); - if (before) { - query += ` _time:<${before}`; + const afterCursor = normalizeLogCursor(after); + if (afterCursor) { + query += ` _time:>${afterCursor}`; } query += " | sort by (_time desc)"; @@ -179,19 +219,40 @@ export type AgentLog = { log_type: "agent"; }; -export async function queryLogsByServer( - serverId: string, - limit: number = 500, - before?: string, -): Promise<{ logs: AgentLog[]; hasMore: boolean }> { +type QueryLogsByServerOptions = { + serverId: string; + limit?: number; + before?: string; + search?: string; + range?: LogTimeRange; +}; + +export async function queryLogsByServer({ + serverId, + limit = 500, + before, + search, + range, +}: QueryLogsByServerOptions): Promise<{ + logs: AgentLog[]; + hasMore: boolean; +}> { const endpoint = getQueryEndpoint(); if (!endpoint) { throw new Error("VICTORIA_LOGS_URL is not configured"); } let query = `${formatLogSqlExactFilter("server_id", serverId)} log_type:agent`; - if (before) { - query += ` _time:<${before}`; + if (range) { + query += ` _time:${range}`; + } + const beforeCursor = normalizeLogCursor(before); + if (beforeCursor) { + query += ` _time:<${beforeCursor}`; + } + const searchFilter = formatLogSqlSearchFilter(search); + if (searchFilter) { + query += ` ${searchFilter}`; } query += " | sort by (_time desc)"; @@ -264,14 +325,19 @@ export async function ingestRolloutLog( export async function queryLogsByRollout( rolloutId: string, - limit: number = 1000, + { limit = 1000, search }: { limit?: number; search?: string } = {}, ): Promise<{ logs: RolloutLog[] }> { const endpoint = getQueryEndpoint(); if (!endpoint) { throw new Error("VICTORIA_LOGS_URL is not configured"); } - const query = `${formatLogSqlExactFilter("rollout_id", rolloutId)} log_type:rollout | sort by (_time)`; + let query = `${formatLogSqlExactFilter("rollout_id", rolloutId)} log_type:rollout`; + const searchFilter = formatLogSqlSearchFilter(search); + if (searchFilter) { + query += ` ${searchFilter}`; + } + query += " | sort by (_time)"; const url = new URL(`${endpoint.url}/select/logsql/query`); url.searchParams.set("query", query); @@ -294,14 +360,19 @@ export async function queryLogsByRollout( export async function queryLogsByBuild( buildId: string, - limit: number = 1000, + { limit = 1000, search }: { limit?: number; search?: string } = {}, ): Promise<{ logs: BuildLog[] }> { const endpoint = getQueryEndpoint(); if (!endpoint) { throw new Error("VICTORIA_LOGS_URL is not configured"); } - const query = `build_id:${buildId} log_type:build | sort by (_time)`; + let query = `${formatLogSqlExactFilter("build_id", buildId)} log_type:build`; + const searchFilter = formatLogSqlSearchFilter(search); + if (searchFilter) { + query += ` ${searchFilter}`; + } + query += " | sort by (_time)"; const url = new URL(`${endpoint.url}/select/logsql/query`); url.searchParams.set("query", query); diff --git a/web/lib/victoria-metrics.ts b/web/lib/victoria-metrics.ts index 806a8717..2bc4b7d1 100644 --- a/web/lib/victoria-metrics.ts +++ b/web/lib/victoria-metrics.ts @@ -1,3 +1,9 @@ +import { + addMilliseconds, + getTimestamp, + SECOND_IN_MILLISECONDS, + subtractMilliseconds, +} from "@/lib/date"; import { METRIC_RANGE_OPTIONS, type MetricRange, @@ -323,8 +329,9 @@ export async function queryServiceMetrics(options: { 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 queryStart = addMilliseconds( + window.start, + window.stepSeconds * SECOND_IN_MILLISECONDS, ); const [ @@ -671,9 +678,9 @@ export function getMetricWindow( stepSeconds: number; } { const option = METRIC_RANGE_OPTIONS[range]; - const stepMs = option.stepSeconds * 1000; - const end = new Date(Math.floor(now.getTime() / stepMs) * stepMs); - const start = new Date(end.getTime() - option.durationMs); + const stepMs = option.stepSeconds * SECOND_IN_MILLISECONDS; + const end = new Date(Math.floor(getTimestamp(now) / stepMs) * stepMs); + const start = subtractMilliseconds(end, option.durationMs); return { start, end, @@ -707,10 +714,11 @@ function createServiceMetricBuckets(window: { stepSeconds: number; }): ServiceMetricsBucket[] { const buckets: ServiceMetricsBucket[] = []; - const stepMs = window.stepSeconds * 1000; + const stepMs = window.stepSeconds * SECOND_IN_MILLISECONDS; + const endMs = window.end.getTime(); for ( let timestampMs = window.start.getTime() + stepMs; - timestampMs <= window.end.getTime(); + timestampMs <= endMs; timestampMs += stepMs ) { buckets.push({ diff --git a/web/lib/work-queue.ts b/web/lib/work-queue.ts index 807334a5..82af9533 100644 --- a/web/lib/work-queue.ts +++ b/web/lib/work-queue.ts @@ -3,11 +3,12 @@ import { and, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/db"; import { deployments, servers, workQueue } from "@/db/schema"; import type { WorkQueue } from "@/db/types"; +import { MINUTE_IN_MILLISECONDS, subtractMilliseconds } from "@/lib/date"; import { inngest } from "@/lib/inngest/client"; import { inngestEvents } from "@/lib/inngest/events"; export const WORK_QUEUE_MAX_ATTEMPTS = 3; -export const WORK_QUEUE_LEASE_DURATION_MS = 2 * 60 * 1000; +export const WORK_QUEUE_LEASE_DURATION_MS = 2 * MINUTE_IN_MILLISECONDS; export type WorkItemResult = { id: string; @@ -126,7 +127,10 @@ export async function renewActiveWorkItems( export async function claimNextWorkItem( serverId: string, ): Promise { - const staleThreshold = new Date(Date.now() - WORK_QUEUE_LEASE_DURATION_MS); + const staleThreshold = subtractMilliseconds( + new Date(), + WORK_QUEUE_LEASE_DURATION_MS, + ); const result = await db.execute(sql` UPDATE work_queue diff --git a/web/tests/date.test.ts b/web/tests/date.test.ts new file mode 100644 index 00000000..5150f9c3 --- /dev/null +++ b/web/tests/date.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; +import { + addMilliseconds, + addUtcDays, + DAY_IN_MILLISECONDS, + differenceInElapsedDays, + differenceInElapsedHours, + differenceInMilliseconds, + formatCompactDate, + formatCompactDateTime, + formatDate, + formatDateTime, + formatDateTimeUtc, + formatElapsedDuration, + formatElapsedDurationBetween, + formatPreciseDateTime, + formatRelativeTime, + formatTime, + getTimestamp, + HOUR_IN_MILLISECONDS, + isDateAfter, + isDateBefore, + isExpired, + subtractMilliseconds, + subtractUtcDays, + toDate, +} from "@/lib/date"; + +const TIMESTAMP = "2026-07-10T04:05:06.789Z"; +const UTC = { timeZone: "UTC" } as const; + +describe("date formatting", () => { + it("uses the shared display formats", () => { + expect(formatDate(TIMESTAMP, UTC)).toBe("Jul 10, 2026"); + expect(formatDateTime(TIMESTAMP, UTC)).toBe("Jul 10, 2026, 04:05"); + expect(formatPreciseDateTime(TIMESTAMP, UTC)).toBe( + "Jul 10, 2026, 04:05:06", + ); + expect(formatTime(TIMESTAMP, UTC)).toBe("04:05:06"); + expect(formatTime("2026-07-10T00:00:00Z", UTC)).toBe("00:00:00"); + expect(formatCompactDate(TIMESTAMP, UTC)).toBe("Jul 10"); + expect(formatCompactDateTime(TIMESTAMP, UTC)).toBe("Jul 10, 04:05"); + expect(formatDateTimeUtc(TIMESTAMP)).toBe("Jul 10, 2026, 04:05 UTC"); + }); + + it("handles missing and invalid inputs consistently", () => { + expect(formatDate(null)).toBe("—"); + expect(formatDateTime("not-a-date", { fallback: "Unknown" })).toBe( + "Unknown", + ); + expect(toDate(undefined)).toBeNull(); + expect(toDate("not-a-date")).toBeNull(); + }); + + it("clones Date inputs instead of returning mutable shared instances", () => { + const source = new Date(TIMESTAMP); + const parsed = toDate(source); + + expect(parsed).toEqual(source); + expect(parsed).not.toBe(source); + }); +}); + +describe("relative time and durations", () => { + const now = "2026-07-10T12:00:00Z"; + + it("formats past and future relative values", () => { + expect(formatRelativeTime("2026-07-10T11:59:31Z", { now })).toBe( + "just now", + ); + expect(formatRelativeTime("2026-07-10T11:48:00Z", { now })).toBe("12m ago"); + expect(formatRelativeTime("2026-07-10T16:00:00Z", { now })).toBe("in 4h"); + expect(formatRelativeTime("2026-07-06T12:00:00Z", { now })).toBe("4d ago"); + expect( + formatRelativeTime("2026-07-01T12:00:00Z", { now, timeZone: "UTC" }), + ).toBe("Jul 1, 2026"); + expect( + formatRelativeTime("2026-07-03T12:00:00Z", { now, timeZone: "UTC" }), + ).toBe("Jul 3, 2026"); + }); + + it("formats elapsed durations at standard boundaries", () => { + expect(formatElapsedDuration(42_999)).toBe("42s"); + expect(formatElapsedDuration(192_000)).toBe("3m 12s"); + expect(formatElapsedDuration(3_790_000)).toBe("1h 03m 10s"); + expect(formatElapsedDuration(-1_000)).toBe("0s"); + expect(formatElapsedDuration(Number.NaN, { fallback: "Unknown" })).toBe( + "Unknown", + ); + }); + + it("formats a running or completed interval", () => { + expect( + formatElapsedDurationBetween( + "2026-07-10T11:58:00Z", + "2026-07-10T12:00:12Z", + ), + ).toBe("2m 12s"); + expect( + formatElapsedDurationBetween("2026-07-10T11:58:00Z", null, { now }), + ).toBe("2m 0s"); + expect(formatElapsedDurationBetween("invalid", null, { now })).toBe("—"); + }); +}); + +describe("date comparisons and arithmetic", () => { + it("compares absolute instants and treats the expiry boundary as expired", () => { + const earlier = "2026-07-10T11:00:00Z"; + const later = "2026-07-10T12:00:00Z"; + + expect(isDateBefore(earlier, later)).toBe(true); + expect(isDateAfter(later, earlier)).toBe(true); + expect(isExpired(later, later)).toBe(true); + expect(isExpired(later, earlier)).toBe(false); + expect(isExpired("invalid", later)).toBe(true); + expect(isExpired(null, later)).toBe(true); + expect(isExpired(undefined, later)).toBe(true); + expect(() => isDateBefore("invalid", later)).toThrow("Invalid date"); + expect(() => isDateAfter(1e20, later)).toThrow("Invalid date"); + }); + + it("adds and subtracts elapsed milliseconds", () => { + const date = new Date("2026-07-10T12:00:00Z"); + + expect(addMilliseconds(date, HOUR_IN_MILLISECONDS).toISOString()).toBe( + "2026-07-10T13:00:00.000Z", + ); + expect(subtractMilliseconds(date, HOUR_IN_MILLISECONDS).toISOString()).toBe( + "2026-07-10T11:00:00.000Z", + ); + expect(date.toISOString()).toBe("2026-07-10T12:00:00.000Z"); + }); + + it("uses UTC calendar days without mutating the source", () => { + const date = new Date("2026-03-08T06:30:00Z"); + + expect(addUtcDays(date, 1).toISOString()).toBe("2026-03-09T06:30:00.000Z"); + expect(subtractUtcDays(date, 7).toISOString()).toBe( + "2026-03-01T06:30:00.000Z", + ); + expect(date.toISOString()).toBe("2026-03-08T06:30:00.000Z"); + }); + + it("calculates elapsed differences and timestamps", () => { + const earlier = "2026-07-08T00:00:00Z"; + const later = "2026-07-10T12:00:00Z"; + + expect(differenceInElapsedHours(later, earlier)).toBe(60); + expect(differenceInElapsedDays(later, earlier)).toBe(2.5); + expect(() => differenceInMilliseconds(later, 1e20)).toThrow("Invalid date"); + expect(getTimestamp(later)).toBe(Date.parse(later)); + expect(getTimestamp("invalid", 0)).toBe(0); + expect(DAY_IN_MILLISECONDS).toBe(24 * HOUR_IN_MILLISECONDS); + }); + + it("rejects invalid arithmetic inputs", () => { + expect(() => addMilliseconds("invalid", 1)).toThrow("Invalid date"); + expect(() => addMilliseconds(TIMESTAMP, 1.5)).toThrow( + "amount must be an integer", + ); + expect(() => addMilliseconds(TIMESTAMP, 1e20)).toThrow( + "Date arithmetic produced an out-of-range date", + ); + expect(() => addUtcDays(TIMESTAMP, 0.5)).toThrow("days must be an integer"); + expect(() => addUtcDays(TIMESTAMP, Number.NaN)).toThrow( + "days must be a finite number", + ); + expect(() => subtractUtcDays(TIMESTAMP, 99_999_999_999)).toThrow( + "Date arithmetic produced an out-of-range date", + ); + }); + + it("keeps local and explicit UTC formatter cache entries separate", () => { + const nearMidnight = "2026-07-10T02:00:00Z"; + + expect(formatDate(nearMidnight)).toBe("Jul 9, 2026"); + expect(formatDate(nearMidnight, UTC)).toBe("Jul 10, 2026"); + }); +}); diff --git a/web/tests/local-date.test.tsx b/web/tests/local-date.test.tsx new file mode 100644 index 00000000..bf8d99b2 --- /dev/null +++ b/web/tests/local-date.test.tsx @@ -0,0 +1,21 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { LocalDate } from "@/components/core/local-date"; + +describe("LocalDate", () => { + it("renders a hydration-stable hidden UTC value on the server", () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('dateTime="2026-07-10T02:00:00.000Z"'); + expect(html).toContain('class="invisible"'); + expect(html).toContain("Jul 10, 2026, 02:00"); + }); + + it("renders fallbacks immediately for missing values", () => { + expect( + renderToStaticMarkup(), + ).toBe("Never"); + }); +}); diff --git a/web/tests/log-routes.test.ts b/web/tests/log-routes.test.ts new file mode 100644 index 00000000..a50131d8 --- /dev/null +++ b/web/tests/log-routes.test.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("log routes", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + vi.doUnmock("next/headers"); + vi.doUnmock("@/lib/api-auth"); + vi.doUnmock("@/lib/auth"); + vi.doUnmock("@/lib/cli-service"); + vi.doUnmock("@/lib/victoria-logs"); + }); + + it.each([ + { + name: "an oversized search", + params: { q: "x".repeat(201) }, + message: "Search must be 200 characters or fewer", + }, + { + name: "an injected cursor", + params: { + before: "2026-07-10T01:02:03Z OR service_id:service-2", + }, + message: "Invalid log cursor", + }, + { + name: "a non-numeric limit", + params: { limit: "abc" }, + message: "Invalid log limit", + }, + { + name: "an unsupported range", + params: { range: "30d" }, + message: "Invalid log range", + }, + ])("rejects $name before querying VictoriaLogs", async ({ + params, + message, + }) => { + const { GET, queryLogsByService } = await loadServiceLogsRoute(); + const url = new URL("http://localhost/api/services/service-1/logs"); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + + const response = await GET(new Request(url), { + params: Promise.resolve({ id: "service-1" }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ message }); + expect(queryLogsByService).not.toHaveBeenCalled(); + }); + + it("returns a gateway error when VictoriaLogs fails", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const queryLogsByService = vi.fn(async () => { + throw new Error("VictoriaLogs unavailable"); + }); + const { GET } = await loadServiceLogsRoute(queryLogsByService); + + const response = await GET( + new Request("http://localhost/api/services/service-1/logs"), + { params: Promise.resolve({ id: "service-1" }) }, + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + message: "Failed to query service logs", + }); + }); + + it("passes a validated after cursor to the deployment query", async () => { + const { GET, queryLogsByDeployment } = await loadDeploymentLogsRoute(); + const cursor = "2026-07-10T01:02:03Z"; + const response = await GET( + new Request( + `http://localhost/api/deployments/deployment-1/logs?after=${cursor}`, + ), + { params: Promise.resolve({ id: "deployment-1" }) }, + ); + + expect(response.status).toBe(200); + expect(queryLogsByDeployment).toHaveBeenCalledWith( + "deployment-1", + 100, + cursor, + ); + }); + + it("rejects an invalid deployment cursor before querying logs", async () => { + const { GET, queryLogsByDeployment } = await loadDeploymentLogsRoute(); + const url = new URL("http://localhost/api/deployments/deployment-1/logs"); + url.searchParams.set("after", "2026-07-10T01:02:03Z | stats count()"); + + const response = await GET(new Request(url), { + params: Promise.resolve({ id: "deployment-1" }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ message: "Invalid log cursor" }); + expect(queryLogsByDeployment).not.toHaveBeenCalled(); + }); + + it("returns a gateway error when deployment logs fail", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + const queryLogsByDeployment = vi.fn(async () => { + throw new Error("VictoriaLogs unavailable"); + }); + const { GET } = await loadDeploymentLogsRoute(queryLogsByDeployment); + + const response = await GET( + new Request("http://localhost/api/deployments/deployment-1/logs"), + { params: Promise.resolve({ id: "deployment-1" }) }, + ); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ + message: "Failed to query deployment logs", + }); + }); + + it("rejects an injected manifest cursor before resolving the service", async () => { + vi.resetModules(); + const getManifestStatus = vi.fn(); + const queryLogsByService = vi.fn(); + vi.doMock("@/lib/api-auth", () => ({ + requireRequestRole: async () => ({ ok: true }), + })); + vi.doMock("@/lib/cli-service", () => ({ getManifestStatus })); + vi.doMock("@/lib/victoria-logs", () => ({ + isLoggingEnabled: () => true, + queryLogsByService, + })); + const { GET } = await import("@/app/api/v1/manifest/logs/route"); + const url = new URL("http://localhost/api/v1/manifest/logs"); + url.searchParams.set("project", "project-1"); + url.searchParams.set("environment", "production"); + url.searchParams.set("service", "web"); + url.searchParams.set("after", "2026-07-10T01:02:03Z | stats count()"); + + const response = await GET(new Request(url)); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: "Invalid log cursor" }); + expect(getManifestStatus).not.toHaveBeenCalled(); + expect(queryLogsByService).not.toHaveBeenCalled(); + }); +}); + +async function loadServiceLogsRoute( + queryLogsByService = vi.fn(async () => ({ logs: [], hasMore: false })), +) { + vi.resetModules(); + vi.doMock("next/headers", () => ({ + headers: async () => new Headers(), + })); + vi.doMock("@/lib/auth", () => ({ + auth: { + api: { + getSession: async () => ({ user: { id: "user-1" } }), + }, + }, + })); + vi.doMock("@/lib/victoria-logs", () => ({ + isLoggingEnabled: () => true, + queryLogsByService, + })); + + const { GET } = await import("@/app/api/services/[id]/logs/route"); + return { GET, queryLogsByService }; +} + +async function loadDeploymentLogsRoute( + queryLogsByDeployment = vi.fn(async () => ({ logs: [], hasMore: false })), +) { + vi.resetModules(); + vi.doMock("next/headers", () => ({ + headers: async () => new Headers(), + })); + vi.doMock("@/lib/auth", () => ({ + auth: { + api: { + getSession: async () => ({ user: { id: "user-1" } }), + }, + }, + })); + vi.doMock("@/lib/victoria-logs", () => ({ + isLoggingEnabled: () => true, + queryLogsByDeployment, + })); + + const { GET } = await import("@/app/api/deployments/[id]/logs/route"); + return { GET, queryLogsByDeployment }; +} diff --git a/web/tests/members.test.ts b/web/tests/members.test.ts index df6062dd..0d71856e 100644 --- a/web/tests/members.test.ts +++ b/web/tests/members.test.ts @@ -3,17 +3,9 @@ import { createInviteToken, hashInviteToken, isInvitableMemberRole, - isMemberRole, } from "@/lib/members"; describe("member roles", () => { - it("validates supported roles", () => { - expect(isMemberRole("admin")).toBe(true); - expect(isMemberRole("developer")).toBe(true); - expect(isMemberRole("reader")).toBe(true); - expect(isMemberRole("owner")).toBe(false); - }); - it("validates invitable roles", () => { expect(isInvitableMemberRole("developer")).toBe(true); expect(isInvitableMemberRole("reader")).toBe(true); diff --git a/web/tests/rollout-utils.test.ts b/web/tests/rollout-utils.test.ts deleted file mode 100644 index 7dc9bb7e..00000000 --- a/web/tests/rollout-utils.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("@/db", () => ({ db: {} })); -vi.mock("@/lib/email", () => ({ - sendDeploymentFailureAlert: vi.fn(), -})); - -import { - shouldRestoreDrainingDeployment, - shouldRollBackDeploymentState, -} from "@/lib/inngest/functions/rollout-utils"; - -describe("rollout failure helpers", () => { - it("cleans up live rollout deployments even after DNS promotion", () => { - expect( - shouldRollBackDeploymentState({ - trafficState: "candidate", - runtimeDesiredState: "running", - }), - ).toBe(true); - expect( - shouldRollBackDeploymentState({ - trafficState: "active", - runtimeDesiredState: "running", - }), - ).toBe(true); - expect( - shouldRollBackDeploymentState({ - trafficState: "candidate", - runtimeDesiredState: "removed", - }), - ).toBe(false); - }); - - it("restores draining deployments by traffic intent only", () => { - expect( - shouldRestoreDrainingDeployment({ - trafficState: "draining", - runtimeDesiredState: "running", - }), - ).toBe(true); - expect( - shouldRestoreDrainingDeployment({ - trafficState: "draining", - runtimeDesiredState: "stopped", - }), - ).toBe(true); - expect( - shouldRestoreDrainingDeployment({ - trafficState: "draining", - runtimeDesiredState: "removed", - }), - ).toBe(false); - }); -}); diff --git a/web/tests/two-factor.test.ts b/web/tests/two-factor.test.ts index 2a25755f..ed1cbeae 100644 --- a/web/tests/two-factor.test.ts +++ b/web/tests/two-factor.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_AUTH_REDIRECT, + type DeleteConfirmation, + getDeleteTotpCode, getSafeAuthRedirect, getTotpSecret, } from "@/lib/two-factor"; @@ -35,4 +37,24 @@ describe("two-factor helpers", () => { it("returns an empty secret for malformed TOTP URIs", () => { expect(getTotpSecret("not a uri")).toBe(""); }); + + it("requires a valid delete code only when 2FA is enabled", () => { + expect(getDeleteTotpCode(false, undefined, "project")).toBeNull(); + expect(() => getDeleteTotpCode(true, undefined, "project")).toThrow( + "Authenticator code is required to delete this project", + ); + expect(() => + getDeleteTotpCode(true, { totpCode: "12345" }, "project"), + ).toThrow("Authenticator code is required to delete this project"); + expect(() => + getDeleteTotpCode( + true, + { totpCode: 123456 } as unknown as DeleteConfirmation, + "project", + ), + ).toThrow("Authenticator code is required to delete this project"); + expect(getDeleteTotpCode(true, { totpCode: "123456" }, "project")).toBe( + "123456", + ); + }); }); diff --git a/web/tests/victoria-logs.test.ts b/web/tests/victoria-logs.test.ts new file mode 100644 index 00000000..2592ccf2 --- /dev/null +++ b/web/tests/victoria-logs.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + DEFAULT_LOG_TIME_RANGE, + escapeLogRegex, + isLogCursor, + MAX_LOG_SEARCH_LENGTH, + normalizeLogCursor, + normalizeLogSearch, + parseLogLimit, + parseLogListParams, + splitLogSearchMatches, +} from "@/lib/log-query"; + +describe("VictoriaLogs queries", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("validates log search and time range inputs", () => { + expect(DEFAULT_LOG_TIME_RANGE).toBe("24h"); + expect(normalizeLogSearch(" database error ")).toBe("database error"); + expect(normalizeLogSearch(" ")).toBeUndefined(); + expect(() => + normalizeLogSearch("x".repeat(MAX_LOG_SEARCH_LENGTH + 1)), + ).toThrow(`Search must be ${MAX_LOG_SEARCH_LENGTH} characters or fewer`); + + expect(isLogCursor("2026-07-10T01:02:03Z")).toBe(true); + expect(isLogCursor("2026-07-10T01:02:03.123456789Z")).toBe(true); + expect(isLogCursor("2026-07-10T01:02:03+10:00")).toBe(true); + expect(isLogCursor("2026-02-29T01:02:03Z")).toBe(false); + expect(isLogCursor("2024-02-29T01:02:03Z")).toBe(true); + expect(isLogCursor("2026-07-10T01:02:03Z OR service_id:service-2")).toBe( + false, + ); + expect(isLogCursor("2026-07-10T01:02:03Z | stats count()")).toBe(false); + expect(normalizeLogCursor(" 2026-07-10T01:02:03Z ")).toBe( + "2026-07-10T01:02:03Z", + ); + expect(() => + normalizeLogCursor("2026-07-10T01:02:03Z | stats count()"), + ).toThrow("Invalid log cursor"); + + expect(parseLogLimit(null, 100)).toBe(100); + expect(parseLogLimit("500", 100)).toBe(500); + expect(parseLogLimit("1001", 100)).toBe(1000); + expect(() => parseLogLimit("0", 100)).toThrow("Invalid log limit"); + expect(() => parseLogLimit("abc", 100)).toThrow("Invalid log limit"); + + expect( + parseLogListParams( + new URLSearchParams({ + q: " database error ", + before: "2026-07-10T01:02:03Z", + limit: "500", + range: "7d", + }), + 100, + ), + ).toEqual({ + search: "database error", + before: "2026-07-10T01:02:03Z", + limit: 500, + range: "7d", + }); + expect(parseLogListParams(new URLSearchParams(), 100).range).toBe("24h"); + expect(() => + parseLogListParams(new URLSearchParams({ range: "30d" }), 100), + ).toThrow("Invalid log range"); + + expect(splitLogSearchMatches("error and ERROR", "error")).toEqual([ + { text: "", isMatch: false }, + { text: "error", isMatch: true }, + { text: " and ", isMatch: false }, + { text: "ERROR", isMatch: true }, + { text: "", isMatch: false }, + ]); + }); + + it("escapes search text as a literal case-insensitive regex", async () => { + const { formatLogSqlSearchFilter } = await loadVictoriaLogs(); + const search = 'error ") OR log_type:build .* [db] \\path'; + const filter = formatLogSqlSearchFilter(search); + + expect(filter).toBeDefined(); + const encodedPattern = filter?.slice("_msg:~".length) || ""; + expect(JSON.parse(encodedPattern)).toBe(`(?i)${escapeLogRegex(search)}`); + }); + + it("rejects service-log cursors before issuing a LogsQL request", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + queryLogsByService({ + serviceId: "service-1", + limit: 100, + before: "2026-07-10T01:02:03Z OR service_id:service-2", + }), + ).rejects.toThrow("Invalid log cursor"); + await expect( + queryLogsByService({ + serviceId: "service-1", + limit: 100, + after: "2026-07-10T01:02:03Z | stats count()", + }), + ).rejects.toThrow("Invalid log cursor"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("searches service logs before applying the result limit and range", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + const urls: URL[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + urls.push(new URL(String(input))); + return jsonLinesResponse([ + storedLog("2026-07-10T01:00:00Z", "first match"), + storedLog("2026-07-10T00:00:00Z", "older match"), + ]); + }), + ); + + const result = await queryLogsByService({ + serviceId: "service-1", + limit: 1, + before: "2026-07-10T02:00:00Z", + logType: "container", + search: "first match", + range: "24h", + }); + + expect(result.logs).toHaveLength(1); + expect(result.hasMore).toBe(true); + const url = urls[0]; + expect(url?.searchParams.get("limit")).toBe("2"); + const query = url?.searchParams.get("query") || ""; + expect(query).toContain("service_id:service-1"); + expect(query).toContain("_time:24h"); + expect(query).toContain("_time:<2026-07-10T02:00:00Z"); + expect(query).toContain('_msg:~"(?i)first match"'); + expect(query.indexOf('_msg:~"(?i)first match"')).toBeLessThan( + query.indexOf("| sort"), + ); + }); + + it("queries deployment logs after the supplied cursor", async () => { + const { queryLogsByDeployment } = await loadVictoriaLogs(); + let query = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + query = new URL(String(input)).searchParams.get("query") || ""; + return jsonLinesResponse([]); + }), + ); + + await queryLogsByDeployment("deployment-1", 100, "2026-07-10T01:02:03Z"); + + expect(query).toContain("deployment_id:deployment-1"); + expect(query).toContain("_time:>2026-07-10T01:02:03Z"); + expect(query).not.toContain("_time:<2026-07-10T01:02:03Z"); + }); + + it("searches every field exposed by the request-log search box", async () => { + const { queryLogsByService } = await loadVictoriaLogs(); + let query = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + query = new URL(String(input)).searchParams.get("query") || ""; + return jsonLinesResponse([]); + }), + ); + + await queryLogsByService({ + serviceId: "service-1", + limit: 500, + logType: "http", + search: "10.0.0.1", + range: "6h", + }); + + expect(query).toContain( + '(_msg:~"(?i)10\\\\.0\\\\.0\\\\.1" OR path:~"(?i)10\\\\.0\\\\.0\\\\.1" OR method:~"(?i)10\\\\.0\\\\.0\\\\.1" OR status:~"(?i)10\\\\.0\\\\.0\\\\.1" OR client_ip:~"(?i)10\\\\.0\\\\.0\\\\.1")', + ); + expect(query).toContain("_time:6h"); + }); + + it("applies search to server, build, and rollout log queries", async () => { + const { queryLogsByBuild, queryLogsByRollout, queryLogsByServer } = + await loadVictoriaLogs(); + const queries: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL | Request) => { + queries.push(new URL(String(input)).searchParams.get("query") || ""); + return jsonLinesResponse([]); + }), + ); + + await queryLogsByServer({ + serverId: "server-1", + search: "connection lost", + range: "7d", + }); + await queryLogsByBuild("build-1", { search: "connection lost" }); + await queryLogsByRollout("rollout-1", { search: "connection lost" }); + + expect(queries[0]).toContain("server_id:server-1"); + expect(queries[0]).toContain("_time:7d"); + expect(queries[1]).toContain("build_id:build-1"); + expect(queries[2]).toContain("rollout_id:rollout-1"); + for (const query of queries) { + expect(query).toContain('_msg:~"(?i)connection lost"'); + } + }); +}); + +async function loadVictoriaLogs() { + vi.resetModules(); + vi.stubEnv("VICTORIA_LOGS_URL", "http://victoria.test"); + vi.stubEnv("VICTORIA_LOGS_PRIVATE_URL", ""); + return import("@/lib/victoria-logs"); +} + +function storedLog(time: string, message: string) { + return { + _msg: message, + _time: time, + service_id: "service-1", + log_type: "container", + }; +} + +function jsonLinesResponse(logs: unknown[]) { + return new Response(logs.map((log) => JSON.stringify(log)).join("\n"), { + status: 200, + }); +} diff --git a/web/vitest.config.ts b/web/vitest.config.ts index 97bfd922..7e021fe7 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -2,6 +2,11 @@ import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ + test: { + env: { + TZ: "America/New_York", + }, + }, resolve: { alias: { "@": path.resolve(__dirname),