Improve timestamp handling and event navigation

This commit is contained in:
Stanislav Rossovskii
2026-05-27 11:01:27 +04:00
parent edc51e9c59
commit 71f0035b0b
37 changed files with 485 additions and 109 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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>

View File

@@ -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>
))}

View File

@@ -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";
}

View File

@@ -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>
))}

View File

@@ -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>
);

View File

@@ -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>
))}

View File

@@ -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>

View 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>
);
}

View 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";
}
}

View File

@@ -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
View 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);
}

View File

@@ -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 }) => {