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/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..38b937ec 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 @@ -7,6 +7,7 @@ import { useCallback, useState } from "react"; import { toast } from "sonner"; import { useSWRConfig } from "swr"; import { deleteService } from "@/actions/projects"; +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"; @@ -47,11 +48,6 @@ 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(); @@ -193,10 +189,13 @@ export default function ConfigurationPage() { {" "} 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. )} 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/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/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/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/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/local-date.tsx b/web/components/core/local-date.tsx new file mode 100644 index 00000000..ef933f0f --- /dev/null +++ b/web/components/core/local-date.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { + type DateFormatOptions, + type DateInput, + formatCompactDate, + formatCompactDateTime, + formatDate, + formatDateTime, + formatPreciseDateTime, + formatTime, + toDate, +} from "@/lib/date"; + +type LocalDateFormat = + | "date" + | "dateTime" + | "preciseDateTime" + | "time" + | "compactDate" + | "compactDateTime"; + +type DateFormatter = ( + value: DateInput | null | undefined, + options?: DateFormatOptions, +) => string; + +const FORMATTERS: Record = { + date: formatDate, + dateTime: formatDateTime, + preciseDateTime: formatPreciseDateTime, + time: formatTime, + compactDate: formatCompactDate, + compactDateTime: formatCompactDateTime, +}; + +const subscribe = () => () => {}; +const getClientSnapshot = () => true; +const getServerSnapshot = () => false; + +export function LocalDate({ + value, + format = "dateTime", + fallback = "—", +}: { + value: DateInput | null | undefined; + format?: LocalDateFormat; + fallback?: string; +}) { + const isHydrated = useSyncExternalStore( + subscribe, + getClientSnapshot, + getServerSnapshot, + ); + const date = toDate(value); + + if (!date) return <>{fallback}; + + const label = FORMATTERS[format](date, { + fallback, + timeZone: isHydrated ? undefined : "UTC", + }); + + return ( + + ); +} diff --git a/web/components/logs/log-viewer.tsx b/web/components/logs/log-viewer.tsx index e4a881ac..6ec7a501 100644 --- a/web/components/logs/log-viewer.tsx +++ b/web/components/logs/log-viewer.tsx @@ -28,7 +28,7 @@ import { import { Empty, 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"; type LogLevel = "error" | "warn" | "info" | "debug"; @@ -478,7 +478,7 @@ function ServiceLogRow({
{formatTime(entry.timestamp)} @@ -524,7 +524,7 @@ function RequestRow({
{formatTime(entry.timestamp)} @@ -560,7 +560,7 @@ function BuildLogRow({
{formatTime(entry.timestamp)} @@ -582,7 +582,7 @@ function ServerLogRow({
{formatTime(entry.timestamp)} diff --git a/web/components/metrics/metrics-history-charts.tsx b/web/components/metrics/metrics-history-charts.tsx index 88572662..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 { @@ -262,7 +267,7 @@ function MetricChartCard({ minTickGap={28} tickLine={false} axisLine={false} - tickFormatter={formatShortTime} + tickFormatter={(value) => formatCompactDateTime(value)} className="text-xs" /> -

{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 formatTooltipValue(item: TooltipPayload) { @@ -466,25 +471,6 @@ function getServerColor(index: number) { return SERVER_COLORS[index % SERVER_COLORS.length]; } -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/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 dfc6e740..028d4f6a 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -19,11 +19,17 @@ import { YAxis, } from "recharts"; import useSWR from "swr"; +import { LocalDate } from "@/components/core/local-date"; import { useService } from "@/components/service/service-layout-client"; 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,14 +226,15 @@ 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], ); - const todayLabel = new Intl.DateTimeFormat(undefined, { - day: "numeric", - month: "short", - }).format(new Date()); - return (
@@ -251,7 +258,11 @@ function ServiceMetricsPanel({ )}
-

{todayLabel}

+ {stats ? ( +

+ +

+ ) : null} formatCompactDate(value)} className="text-xs" /> -

{formatTooltipDate(String(label))}

+

{formatCompactDateTime(String(label))}

{items.map((item) => (
= 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)); -} diff --git a/web/components/settings/api-key-settings.tsx b/web/components/settings/api-key-settings.tsx index b80a4f4d..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,15 +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); -} - export function ApiKeySettings() { const [apiKeys, setApiKeys] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -96,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], ); @@ -253,11 +240,21 @@ export function ApiKeySettings() {

Created

-

{formatDate(apiKey.createdAt)}

+

+ +

Last used

-

{formatDate(apiKey.lastRequest)}

+

+ +

Last checked

-

{checkedAtLabel}

+

+ +

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/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/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/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/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/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/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/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),