Improve timestamp handling and event navigation
This commit is contained in:
@@ -13,7 +13,9 @@ FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
HOSTNAME=0.0.0.0 \
|
||||
MONLET_WEB_TIME_ZONE=UTC \
|
||||
TZ=UTC
|
||||
COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
|
||||
@@ -35,6 +35,7 @@ Do not install Node packages globally. Project dependencies live in `web/node_mo
|
||||
|---|---|---|
|
||||
| `MONLET_API_BASE_URL` | `http://127.0.0.1:8000` | Server base URL (server-side fetch) |
|
||||
| `MONLET_API_TOKEN` | _(none)_ | Bearer token sent to server |
|
||||
| `MONLET_WEB_TIME_ZONE` | `UTC` | IANA timezone used to render timestamps |
|
||||
|
||||
The token never reaches the browser — all server calls happen in Server Components / route handlers.
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { ErrorPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -93,7 +95,9 @@ export default async function AgentDetailPage({
|
||||
<dt className="text-neutral-400">status</dt>
|
||||
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
|
||||
<dt className="text-neutral-400">last_seen_at</dt>
|
||||
<dd>{fmtDate(agent.last_seen_at)}</dd>
|
||||
<dd>
|
||||
<DateTime iso={agent.last_seen_at} />
|
||||
</dd>
|
||||
<dt className="text-neutral-400">features</dt>
|
||||
<dd>
|
||||
<AgentFeatures features={agent.features} />
|
||||
@@ -104,11 +108,7 @@ export default async function AgentDetailPage({
|
||||
</dd>
|
||||
</dl>
|
||||
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
|
||||
{tab === "checks" ? (
|
||||
<ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} />
|
||||
) : (
|
||||
<EventsTable events={events} />
|
||||
)}
|
||||
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -169,10 +169,14 @@ function ChecksTable({
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
<tr key={c.check_id}>
|
||||
<Td>{c.check_id}</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
||||
<Td>{c.exit_code ?? "—"}</Td>
|
||||
<Td>{fmtDate(c.last_observed_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -197,8 +201,12 @@ function EventsTable({ events }: { events: StoredEvent[] }) {
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>{fmtDate(e.observed_at)}</Td>
|
||||
<Td>{e.check_id}</Td>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
</tr>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -47,10 +49,14 @@ export default async function ChecksPage({
|
||||
{c.agent_id}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>{c.check_id}</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={c.agent_id} checkId={c.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
||||
<Td>{c.exit_code ?? "—"}</Td>
|
||||
<Td>{fmtDate(c.last_observed_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</Td>
|
||||
<Td className="text-xs text-neutral-400">{c.incident_key ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -24,20 +28,7 @@ export default async function EventsPage({
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Events" count={data.items.length} />
|
||||
<form
|
||||
action="/events"
|
||||
method="get"
|
||||
className="mb-4 flex flex-wrap gap-3 text-sm items-end"
|
||||
>
|
||||
<Field label="agent_id" name="agent_id" defaultValue={sp.agent_id} />
|
||||
<Field label="check_id" name="check_id" defaultValue={sp.check_id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-neutral-700 px-3 py-1 hover:border-neutral-500"
|
||||
>
|
||||
query
|
||||
</button>
|
||||
</form>
|
||||
<ActiveFilters agentId={sp.agent_id} checkId={sp.check_id} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
@@ -57,10 +48,16 @@ export default async function EventsPage({
|
||||
<tbody>
|
||||
{data.items.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>{fmtDate(e.observed_at)}</Td>
|
||||
<Td>{fmtDate(e.received_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={e.received_at} />
|
||||
</Td>
|
||||
<Td>{e.agent_id}</Td>
|
||||
<Td>{e.check_id}</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.exit_code}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
@@ -80,15 +77,42 @@ export default async function EventsPage({
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, name, defaultValue }: { label: string; name: string; defaultValue?: string }) {
|
||||
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
|
||||
if (!agentId && !checkId) return null;
|
||||
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs uppercase tracking-wide text-neutral-400">
|
||||
{label}
|
||||
<input
|
||||
name={name}
|
||||
defaultValue={defaultValue ?? ""}
|
||||
className="bg-neutral-900 border border-neutral-800 px-2 py-1 text-sm text-neutral-100 font-mono"
|
||||
/>
|
||||
</label>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
{agentId ? (
|
||||
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
|
||||
) : null}
|
||||
{checkId ? (
|
||||
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
|
||||
) : null}
|
||||
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
|
||||
clear
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
|
||||
title={`${label}=${value}`}
|
||||
>
|
||||
<span className="text-neutral-500">{label}=</span>
|
||||
<span className="truncate">{value}</span>
|
||||
<span className="text-neutral-600">x</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
||||
if (params.check_id) qs.set("check_id", params.check_id);
|
||||
const query = qs.toString();
|
||||
return query ? `/events?${query}` : "/events";
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -58,12 +60,18 @@ export default async function IncidentsPage({
|
||||
<tbody>
|
||||
{data.items.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<Td>{fmtDate(i.opened_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={i.opened_at} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(i.state)}>{i.state}</Td>
|
||||
<Td className={statusBadgeClass(i.severity)}>{i.severity}</Td>
|
||||
<Td>{i.agent_id ?? "—"}</Td>
|
||||
<Td>{i.check_id ?? "—"}</Td>
|
||||
<Td>{fmtDate(i.resolved_at)}</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={i.agent_id} checkId={i.check_id} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={i.resolved_at} />
|
||||
</Td>
|
||||
<Td className="text-neutral-400">{i.summary ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AutoRefresh } from "@/components/AutoRefresh";
|
||||
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
|
||||
import { getUiTimeZone } from "@/lib/timezone";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -46,22 +48,26 @@ function Brand() {
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const refreshIntervalMs = getRefreshIntervalMs();
|
||||
const configuredTimeZone = getUiTimeZone();
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
<body className="min-h-full flex flex-col bg-neutral-950 text-neutral-100 font-mono">
|
||||
<header className="border-b border-neutral-800 px-4 py-3 flex items-center gap-6">
|
||||
<Brand />
|
||||
<nav className="flex gap-4 text-sm text-neutral-300">
|
||||
{NAV.map((n) => (
|
||||
<Link key={n.href} href={n.href} className="hover:text-white">
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 px-4 py-6">{children}</main>
|
||||
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
|
||||
<TimeZoneProvider configuredTimeZone={configuredTimeZone}>
|
||||
<header className="border-b border-neutral-800 px-4 py-3 flex flex-wrap items-center gap-4">
|
||||
<Brand />
|
||||
<nav className="flex flex-wrap gap-4 text-sm text-neutral-300">
|
||||
{NAV.map((n) => (
|
||||
<Link key={n.href} href={n.href} className="hover:text-white">
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<TimeZoneCarousel />
|
||||
</header>
|
||||
<main className="flex-1 px-4 py-6">{children}</main>
|
||||
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
|
||||
</TimeZoneProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -38,12 +39,16 @@ export default async function OutboxPage({
|
||||
<tbody>
|
||||
{data.items.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<Td>{fmtDate(o.updated_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={o.updated_at} />
|
||||
</Td>
|
||||
<Td>{o.notifier}</Td>
|
||||
<Td>{o.event_type}</Td>
|
||||
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
|
||||
<Td>{o.attempts}</Td>
|
||||
<Td>{fmtDate(o.next_attempt_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={o.next_attempt_at} />
|
||||
</Td>
|
||||
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { Agent, CheckState } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
type CheckStatus = CheckState["status"];
|
||||
type CheckCounts = Record<CheckStatus, number>;
|
||||
@@ -104,7 +105,9 @@ export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) {
|
||||
<Td>
|
||||
<AgentStatus status={a.status} checks={checks} />
|
||||
</Td>
|
||||
<Td>{fmtDate(a.last_seen_at)}</Td>
|
||||
<Td>
|
||||
<DateTime iso={a.last_seen_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<AgentFeatures features={a.features} />
|
||||
</Td>
|
||||
|
||||
21
web/src/components/CheckEventsLink.tsx
Normal file
21
web/src/components/CheckEventsLink.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function CheckEventsLink({
|
||||
agentId,
|
||||
checkId,
|
||||
}: {
|
||||
agentId?: string | null;
|
||||
checkId?: string | null;
|
||||
}) {
|
||||
if (!checkId) return <span className="text-neutral-500">—</span>;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (agentId) params.set("agent_id", agentId);
|
||||
params.set("check_id", checkId);
|
||||
|
||||
return (
|
||||
<Link href={`/events?${params.toString()}`} className="text-blue-300 hover:text-blue-200">
|
||||
{checkId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
97
web/src/components/TimeZoneControls.tsx
Normal file
97
web/src/components/TimeZoneControls.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
|
||||
import { DEFAULT_TIME_ZONE, fmtDate, normalizeTimeZone } from "@/lib/format";
|
||||
|
||||
type TimeZoneMode = "configured" | "browser" | "utc";
|
||||
|
||||
const modes: TimeZoneMode[] = ["configured", "browser", "utc"];
|
||||
|
||||
type TimeZoneContextValue = {
|
||||
mode: TimeZoneMode;
|
||||
configuredTimeZone: string;
|
||||
browserTimeZone: string | null;
|
||||
timeZone: string;
|
||||
cycleMode: () => void;
|
||||
};
|
||||
|
||||
const TimeZoneContext = createContext<TimeZoneContextValue | null>(null);
|
||||
|
||||
export function TimeZoneProvider({
|
||||
configuredTimeZone,
|
||||
children,
|
||||
}: {
|
||||
configuredTimeZone: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const configured = normalizeTimeZone(configuredTimeZone);
|
||||
const [mode, setMode] = useState<TimeZoneMode>("configured");
|
||||
const [browserTimeZone, setBrowserTimeZone] = useState<string | null>(null);
|
||||
|
||||
const value = useMemo<TimeZoneContextValue>(() => {
|
||||
const timeZone =
|
||||
mode === "configured"
|
||||
? configured
|
||||
: mode === "browser"
|
||||
? (browserTimeZone ?? configured)
|
||||
: DEFAULT_TIME_ZONE;
|
||||
return {
|
||||
mode,
|
||||
configuredTimeZone: configured,
|
||||
browserTimeZone,
|
||||
timeZone,
|
||||
cycleMode: () => {
|
||||
const next = modes[(modes.indexOf(mode) + 1) % modes.length];
|
||||
if (next === "browser" && browserTimeZone === null) {
|
||||
setBrowserTimeZone(normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone));
|
||||
}
|
||||
setMode(next);
|
||||
},
|
||||
};
|
||||
}, [browserTimeZone, configured, mode]);
|
||||
|
||||
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
|
||||
}
|
||||
|
||||
export function DateTime({ iso }: { iso?: string | null }) {
|
||||
const { timeZone } = useDisplayTimeZone();
|
||||
return <time dateTime={iso ?? undefined}>{fmtDate(iso, timeZone)}</time>;
|
||||
}
|
||||
|
||||
export function TimeZoneCarousel() {
|
||||
const { mode, configuredTimeZone, browserTimeZone, timeZone, cycleMode } = useDisplayTimeZone();
|
||||
const title = `Timezone: ${modeLabel(mode)} (${timeZone}). Default: ${configuredTimeZone}; browser: ${browserTimeZone ?? "on switch"}; UTC: ${DEFAULT_TIME_ZONE}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cycleMode}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
className="ml-auto inline-flex shrink-0 items-center gap-2 rounded border border-neutral-800 bg-neutral-950 px-2.5 py-1.5 text-xs text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
|
||||
>
|
||||
<span className="text-neutral-500">TZ</span>
|
||||
<span>{modeLabel(mode)}</span>
|
||||
<span className="max-w-36 truncate text-emerald-300">{timeZone}</span>
|
||||
<span className="text-neutral-600">→</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function useDisplayTimeZone(): TimeZoneContextValue {
|
||||
const value = useContext(TimeZoneContext);
|
||||
if (!value) throw new Error("TimeZoneProvider is missing");
|
||||
return value;
|
||||
}
|
||||
|
||||
function modeLabel(mode: TimeZoneMode): string {
|
||||
switch (mode) {
|
||||
case "configured":
|
||||
return "default";
|
||||
case "browser":
|
||||
return "browser";
|
||||
case "utc":
|
||||
return "utc";
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,34 @@
|
||||
export function fmtDate(iso?: string | null): string {
|
||||
export const DEFAULT_TIME_ZONE = "UTC";
|
||||
|
||||
export function normalizeTimeZone(timeZone?: string | null): string {
|
||||
const value = timeZone?.trim();
|
||||
if (!value) return DEFAULT_TIME_ZONE;
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date(0));
|
||||
return value;
|
||||
} catch {
|
||||
return DEFAULT_TIME_ZONE;
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtDate(iso?: string | null, timeZone = DEFAULT_TIME_ZONE): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toISOString().replace("T", " ").replace(/\..+$/, "Z");
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: normalizeTimeZone(timeZone),
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
timeZoneName: "short",
|
||||
}).formatToParts(d);
|
||||
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
||||
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")} ${get("timeZoneName")}`;
|
||||
}
|
||||
|
||||
export function statusBadgeClass(s: string): string {
|
||||
|
||||
7
web/src/lib/timezone.ts
Normal file
7
web/src/lib/timezone.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { DEFAULT_TIME_ZONE, normalizeTimeZone } from "@/lib/format";
|
||||
|
||||
export function getUiTimeZone(): string {
|
||||
return normalizeTimeZone(process.env.MONLET_WEB_TIME_ZONE ?? process.env.TZ ?? DEFAULT_TIME_ZONE);
|
||||
}
|
||||
@@ -19,6 +19,12 @@ test("agents list links to detail", async ({ page }) => {
|
||||
await expect(page.locator("thead")).toContainText("↓");
|
||||
await expect(page.locator("body")).toContainText("checks 2");
|
||||
await expect(page.locator("body")).toContainText("warn 1");
|
||||
await expect(page.getByRole("button", { name: /Timezone: default/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Timezone: default/ }).click();
|
||||
await expect(page.getByRole("button", { name: /Timezone: browser/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Timezone: browser/ }).click();
|
||||
await expect(page.getByRole("button", { name: /Timezone: utc \(UTC\)/ })).toBeVisible();
|
||||
await expect(page.locator("tbody")).toContainText("UTC");
|
||||
await page.getByPlaceholder("search agent").fill("host-1");
|
||||
await expect(page).toHaveURL(/\/agents$/);
|
||||
await expect(page.locator("tbody")).toContainText("host-1");
|
||||
@@ -45,6 +51,8 @@ test("checks page lists rows", async ({ page }) => {
|
||||
await page.goto("/checks");
|
||||
await expect(page.locator("body")).toContainText("disk");
|
||||
await expect(page.locator("body")).toContainText("load");
|
||||
await page.getByRole("link", { name: "disk" }).click();
|
||||
await expect(page).toHaveURL(/\/events\?agent_id=agent-1&check_id=disk$/);
|
||||
});
|
||||
|
||||
test("incidents filter renders", async ({ page }) => {
|
||||
@@ -53,8 +61,11 @@ test("incidents filter renders", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("events page", async ({ page }) => {
|
||||
await page.goto("/events");
|
||||
await page.goto("/events?agent_id=agent-1&check_id=disk");
|
||||
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: /agent_id=.*agent-1/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /check_id=.*disk/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("outbox page", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user