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 && (
-
-
-
- Authenticator code
-
-
- 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 && (
+
+ Authenticator code
+ 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 (
+
+ {label}
+
+ );
+}
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
+
+ void mutate()}>
+ Try again
+
+
+
+ ) : 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({
)}
-
+
@@ -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)}
+
+
+
+ }
+ >
+
+ 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),