Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/[locale]/status/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export default async function PublicStatusGroupPage({
targetGroup,
} = await loadGroupContext(slug);
if (!targetGroup) {
notFound();
return notFound();
}

const filteredPayload = { ...initialPayload, groups: [targetGroup] };
Expand Down
103 changes: 62 additions & 41 deletions src/app/[locale]/status/_components/public-status-timeline.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import type { FilledTimelineCell } from "../_lib/fill-display-timeline";
import { formatTtfb } from "../_lib/format-ttfb";
Expand Down Expand Up @@ -74,57 +74,78 @@ export function PublicStatusTimeline({
locale,
labels,
}: PublicStatusTimelineProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const activeCell = activeIndex === null ? null : (cells[activeIndex] ?? null);
const activeBucket = activeCell?.bucket ?? null;
const activeIsPlaceholder = activeBucket?.bucketStart.startsWith("empty-") ?? false;
const activeSummary = useMemo(() => {
if (!activeBucket) {
return null;
}

return {
range: activeIsPlaceholder
? null
: formatRange(activeBucket.bucketStart, activeBucket.bucketEnd, locale, timeZone),
availability:
activeBucket.availabilityPct === null ? "—" : `${activeBucket.availabilityPct.toFixed(2)}%`,
ttfb: formatTtfb(activeBucket.ttfbMs),
tps: activeBucket.tps === null ? "—" : activeBucket.tps.toFixed(1),
};
}, [activeBucket, activeIsPlaceholder, locale, timeZone]);

return (
<TooltipProvider delayDuration={80}>
<div
className="space-y-2"
onMouseLeave={() => setActiveIndex(null)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget)) {
setActiveIndex(null);
}
}}
>
<div
className="flex w-full items-center gap-[2px]"
role="list"
aria-label={labels.historyAriaLabel}
>
{cells.map((cell, index) => {
const { bucket } = cell;
const isPlaceholder = bucket.bucketStart.startsWith("empty-");
return (
<Tooltip key={`${bucket.bucketStart}-${index}`}>
<TooltipTrigger asChild>
<button
type="button"
role="listitem"
aria-label={`${labels.availability}: ${bucket.availabilityPct ?? "—"}`}
className={cn(
"h-6 flex-1 rounded-[2px] outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring",
cellColor(cell)
)}
/>
</TooltipTrigger>
<TooltipContent
side="top"
className="max-w-xs space-y-1 rounded-md bg-popover px-3 py-2 text-popover-foreground shadow-md"
>
{!isPlaceholder ? (
<p className="font-medium tabular-nums">
{formatRange(bucket.bucketStart, bucket.bucketEnd, locale, timeZone)}
</p>
) : null}
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 font-mono">
<span className="text-muted-foreground">{labels.availability}</span>
<span className="text-right">
{bucket.availabilityPct === null
? "—"
: `${bucket.availabilityPct.toFixed(2)}%`}
</span>
<span className="text-muted-foreground">{labels.ttfb}</span>
<span className="text-right">{formatTtfb(bucket.ttfbMs)}</span>
<span className="text-muted-foreground">{labels.tps}</span>
<span className="text-right">
{bucket.tps === null ? "—" : bucket.tps.toFixed(1)}
</span>
</div>
</TooltipContent>
</Tooltip>
<button
key={`${bucket.bucketStart}-${index}`}
type="button"
role="listitem"
aria-label={`${labels.availability}: ${bucket.availabilityPct ?? "—"}`}
className={cn(
"h-6 flex-1 rounded-[2px] outline-none transition-opacity hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ring",
cellColor(cell)
)}
onFocus={() => setActiveIndex(index)}
onMouseEnter={() => setActiveIndex(index)}
/>
);
})}
</div>
</TooltipProvider>
{activeSummary ? (
<div className="rounded-md border border-border/50 bg-popover px-3 py-2 text-xs text-popover-foreground shadow-sm">
{activeSummary.range ? (
<p className="mb-1 font-medium tabular-nums">{activeSummary.range}</p>
) : null}
<div className="grid grid-cols-3 gap-2 font-mono">
<span>
<span className="text-muted-foreground">{labels.availability}</span>{" "}
{activeSummary.availability}
</span>
<span>
<span className="text-muted-foreground">{labels.ttfb}</span> {activeSummary.ttfb}
</span>
<span>
<span className="text-muted-foreground">{labels.tps}</span> {activeSummary.tps}
</span>
</div>
</div>
) : null}
</div>
);
}
55 changes: 52 additions & 3 deletions src/app/[locale]/status/_components/public-status-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import { SortableGroupPanel } from "./sortable-group-panel";
import { StatusHero } from "./status-hero";
import { StatusToolbar } from "./status-toolbar";

type ViewModelSnapshot = PublicStatusPayload["groups"][number]["models"][number] & {
timelineReusedFromPrevious?: boolean;
};

interface PublicStatusViewProps {
initialPayload: PublicStatusPayload;
initialStatus?: PublicStatusRouteStatus;
Expand Down Expand Up @@ -151,6 +155,17 @@ function aggregateByFailed(states: DisplayState[]): DisplayState {
return "operational";
}

function deriveCurrentModelState(model: ViewModelSnapshot): DisplayState {
if (model.timeline.length === 0 || model.timelineReusedFromPrevious) {
return model.latestState;
}
return deriveLatestModelState(model);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

function shouldUseServerModelSummary(model: ViewModelSnapshot): boolean {
return model.timeline.length === 0 || Boolean(model.timelineReusedFromPrevious);
}

export function PublicStatusView({
initialPayload,
initialStatus,
Expand Down Expand Up @@ -184,6 +199,7 @@ export function PublicStatusView({
if (filterSlug) {
params.set("groupSlug", filterSlug);
}
params.set("include", "meta,defaults,groups");
const requestUrl =
params.size > 0 ? `/api/public-status?${params.toString()}` : "/api/public-status";
const response = await fetch(requestUrl, { cache: "no-store" });
Expand All @@ -195,7 +211,35 @@ export function PublicStatusView({
? { ...next, groups: next.groups.filter((g) => g.publicGroupSlug === filterSlug) }
: next;
startTransition(() => {
setPayload(scoped);
setPayload((previous) => ({
...scoped,
groups: scoped.groups.map((group) => {
const previousGroup = previous.groups.find(
(candidate) => candidate.publicGroupSlug === group.publicGroupSlug
);
if (!previousGroup) {
return group;
}
Comment on lines +220 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load timeline when transitioning from no snapshot state

Polling always requests include=meta,defaults,groups (no timeline), and when a group appears for the first time this branch returns it as-is, leaving timeline empty. If a user opens the page while the system is rebuilding (initial payload has no groups), then later receives the first ready payload via polling, charts never get real history data and remain empty until a full page reload.

Useful? React with 👍 / 👎.


return {
...group,
models: group.models.map((model) => {
const previousModel = previousGroup.models.find(
(candidate) => candidate.publicModelKey === model.publicModelKey
);
if (model.timeline.length > 0) {
return model;
}

return {
...model,
timeline: previousModel?.timeline ?? [],
timelineReusedFromPrevious: Boolean(previousModel?.timeline.length),
};
}),
};
}),
}));
setRouteStatus(nextResponse.status);
});
} catch {
Expand All @@ -222,9 +266,14 @@ export function PublicStatusView({
const derivedModels = group.models.map((model) => {
const filled = fillDisplayTimeline(model.timeline);
const chartCells = sliceTimelineForChart(filled, CHART_BUCKETS);
const uptime24h = computeUptimePct(model.timeline);
const viewModel = model as ViewModelSnapshot;
const useServerSummary = shouldUseServerModelSummary(viewModel);
const uptime24h =
useServerSummary && model.availabilityPct !== null
? model.availabilityPct
: computeUptimePct(model.timeline);
const ttfb24h = computeAvgTtfb(model.timeline);
const latest = deriveLatestModelState(model);
const latest = deriveCurrentModelState(viewModel);
return { model, chartCells, uptime24h, ttfb24h, latest };
});
const issueCount = derivedModels.filter((d) => d.latest === "failed").length;
Expand Down
31 changes: 30 additions & 1 deletion src/lib/model-vendor-icons.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { getModelVendor, PRICE_FILTER_VENDORS } from "./model-vendor-icons";

describe("getModelVendor", () => {
Expand Down Expand Up @@ -119,6 +119,35 @@ describe("getModelVendor", () => {
expect(getModelVendor("o1")?.i18nKey).toBe("openai");
expect(getModelVendor("yi")?.i18nKey).toBe("yi");
});

it("warns in development when a vendor rule has no registered icon", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

vi.resetModules();
vi.doMock("@/lib/model-vendor-rules", () => ({
getModelVendor: () => ({
prefix: "missing",
hasColor: false,
i18nKey: "missing-vendor",
}),
}));
vi.stubEnv("NODE_ENV", "development");

try {
const { getModelVendor: getMockedModelVendor } = await import("./model-vendor-icons");
const result = getMockedModelVendor("missing-model");

expect(result?.i18nKey).toBe("missing-vendor");
expect(warnSpy).toHaveBeenCalledWith(
'[model-vendor-icons] No icon registered for i18nKey "missing-vendor"'
);
} finally {
vi.unstubAllEnvs();
warnSpy.mockRestore();
vi.doUnmock("@/lib/model-vendor-rules");
vi.resetModules();
}
});
});

describe("PRICE_FILTER_VENDORS", () => {
Expand Down
Loading
Loading