animation

This commit is contained in:
null 2026-05-27 19:22:55 -05:00
parent 4d3f57870a
commit f8daef6ee4
9 changed files with 218 additions and 37 deletions

View File

@ -1413,6 +1413,7 @@ export default function DashboardPage() {
secondary={`${formatCount(agents.length)} total`} secondary={`${formatCount(agents.length)} total`}
icon={<Bot className="h-4 w-4" />} icon={<Bot className="h-4 w-4" />}
tone="accent" tone="accent"
isLoading={!metrics && metricsQuery.isLoading}
/> />
<DashboardMetricCard <DashboardMetricCard
title="Tasks In Progress" title="Tasks In Progress"
@ -1420,6 +1421,7 @@ export default function DashboardPage() {
secondary={`${formatCount(tasksTotal)} total`} secondary={`${formatCount(tasksTotal)} total`}
icon={<LayoutGrid className="h-4 w-4" />} icon={<LayoutGrid className="h-4 w-4" />}
tone="success" tone="success"
isLoading={!metrics && metricsQuery.isLoading}
/> />
<DashboardMetricCard <DashboardMetricCard
title="Error Rate" title="Error Rate"
@ -1427,6 +1429,7 @@ export default function DashboardPage() {
secondary={`${formatCount(Number(latestThroughputPoint?.value ?? 0))} completed (latest)`} secondary={`${formatCount(Number(latestThroughputPoint?.value ?? 0))} completed (latest)`}
icon={<Activity className="h-4 w-4" />} icon={<Activity className="h-4 w-4" />}
tone="warning" tone="warning"
isLoading={!metrics && metricsQuery.isLoading}
/> />
<DashboardMetricCard <DashboardMetricCard
title="Completion Speed" title="Completion Speed"
@ -1435,6 +1438,7 @@ export default function DashboardPage() {
infoText={`Based on ${DASHBOARD_RANGE_LABEL}`} infoText={`Based on ${DASHBOARD_RANGE_LABEL}`}
icon={<Timer className="h-4 w-4" />} icon={<Timer className="h-4 w-4" />}
tone="success" tone="success"
isLoading={!metrics && metricsQuery.isLoading}
/> />
</div> </div>
@ -1491,12 +1495,14 @@ export default function DashboardPage() {
title="Workload" title="Workload"
rows={workloadRows} rows={workloadRows}
tone="accent" tone="accent"
isLoading={!metrics && metricsQuery.isLoading}
/> />
<DashboardInfoBlock <DashboardInfoBlock
title="Throughput" title="Throughput"
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`} infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
rows={throughputRows} rows={throughputRows}
tone="success" tone="success"
isLoading={!metrics && metricsQuery.isLoading}
/> />
<DashboardInfoBlock <DashboardInfoBlock
title="Gateway Health" title="Gateway Health"
@ -1509,6 +1515,7 @@ export default function DashboardPage() {
? "danger" ? "danger"
: "warning" : "warning"
} }
isLoading={!metrics && metricsQuery.isLoading}
/> />
</div> </div>
@ -1552,6 +1559,7 @@ export default function DashboardPage() {
<RecentActivitySection <RecentActivitySection
events={recentLogs} events={recentLogs}
feedHref={activityFeedHref} feedHref={activityFeedHref}
isLoading={activityQuery.isLoading}
onRowClick={handleLogRowClick} onRowClick={handleLogRowClick}
onRowKeyDown={handleLogRowKeyDown} onRowKeyDown={handleLogRowKeyDown}
buildHref={buildActivityEventHref} buildHref={buildActivityEventHref}

View File

@ -14,6 +14,7 @@ interface DashboardInfoBlockProps {
badge?: { text: string; tone: BadgeTone }; badge?: { text: string; tone: BadgeTone };
rows: InfoRow[]; rows: InfoRow[];
tone?: SectionToneKey; tone?: SectionToneKey;
isLoading?: boolean;
} }
/** /**
@ -26,6 +27,7 @@ export function DashboardInfoBlock({
badge, badge,
rows, rows,
tone = "neutral", tone = "neutral",
isLoading = false,
}: DashboardInfoBlockProps) { }: DashboardInfoBlockProps) {
return ( return (
<DashboardSection <DashboardSection
@ -35,32 +37,51 @@ export function DashboardInfoBlock({
tone={tone} tone={tone}
> >
<div className="overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)]"> <div className="overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)]">
{rows.map((row) => ( {isLoading
<div ? [28, 20, 36, 24, 32].map((w, i) => (
key={`${row.label}-${row.value}`} <div
className="flex items-start justify-between gap-3 border-b border-[color:var(--border)] px-3 py-2 last:border-b-0" key={i}
> className="flex items-center justify-between gap-3 border-b border-[color:var(--border)] px-3 py-2 last:border-b-0"
<span className="flex min-w-0 items-center gap-2 text-sm text-muted"> >
<span <span className="flex items-center gap-2">
className={`h-1.5 w-1.5 rounded-full ${ <span className="h-1.5 w-1.5 rounded-full bg-[color:var(--border-strong)] opacity-50" />
row.tone === "success" <div
? "bg-[color:var(--success)]" className="h-3.5 animate-pulse rounded bg-[color:var(--surface-strong)]"
: row.tone === "warning" style={{ width: `${w * 4}px` }}
? "bg-[color:var(--warning)]" />
: row.tone === "danger" </span>
? "bg-[color:var(--danger)]" <div
: "bg-[color:var(--border-strong)]" className="h-3.5 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-70"
}`} style={{ width: `${(5 - i % 3) * 12}px` }}
/> />
{row.label} </div>
</span> ))
<span : rows.map((row) => (
className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${toneText[row.tone ?? "default"]}`} <div
> key={`${row.label}-${row.value}`}
{row.value} className="flex items-start justify-between gap-3 border-b border-[color:var(--border)] px-3 py-2 last:border-b-0"
</span> >
</div> <span className="flex min-w-0 items-center gap-2 text-sm text-muted">
))} <span
className={`h-1.5 w-1.5 rounded-full ${
row.tone === "success"
? "bg-[color:var(--success)]"
: row.tone === "warning"
? "bg-[color:var(--warning)]"
: row.tone === "danger"
? "bg-[color:var(--danger)]"
: "bg-[color:var(--border-strong)]"
}`}
/>
{row.label}
</span>
<span
className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${toneText[row.tone ?? "default"]}`}
>
{row.value}
</span>
</div>
))}
</div> </div>
</DashboardSection> </DashboardSection>
); );

View File

@ -9,6 +9,7 @@ interface DashboardMetricCardProps {
infoText?: string; infoText?: string;
icon: ReactNode; icon: ReactNode;
tone: MetricToneKey; tone: MetricToneKey;
isLoading?: boolean;
} }
/** /**
@ -22,6 +23,7 @@ export function DashboardMetricCard({
infoText, infoText,
icon, icon,
tone, tone,
isLoading = false,
}: DashboardMetricCardProps) { }: DashboardMetricCardProps) {
return ( return (
<section <section
@ -47,12 +49,18 @@ export function DashboardMetricCard({
)} )}
</div> </div>
<div className="mt-2 flex items-end gap-2"> <div className="mt-2 flex items-end gap-2">
<p className="font-heading text-4xl font-bold text-strong"> {isLoading ? (
{value} <div className="h-10 w-20 animate-pulse rounded bg-[color:var(--surface-strong)]" />
</p> ) : (
{secondary && ( <p className="font-heading text-4xl font-bold text-strong">
<p className="pb-1 text-xs text-muted">{secondary}</p> {value}
</p>
)} )}
{isLoading ? (
<div className="mb-1 h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
) : secondary ? (
<p className="pb-1 text-xs text-muted">{secondary}</p>
) : null}
</div> </div>
</div> </div>
<div className={`rounded-lg p-2 ${toneIcon[tone]}`}>{icon}</div> <div className={`rounded-lg p-2 ${toneIcon[tone]}`}>{icon}</div>

View File

@ -56,7 +56,27 @@ export function GatewayCronPanel({
action={jobs.length > 0 ? { label: `${jobs.length}`, href: "#" } : undefined} action={jobs.length > 0 ? { label: `${jobs.length}`, href: "#" } : undefined}
> >
{isLoading && !cron ? ( {isLoading && !cron ? (
<DashboardEmptyState message="Loading cron data…" /> <div className="space-y-1.5">
{[0, 1].map((i) => (
<div
key={i}
className="flex min-w-0 items-start gap-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2"
>
<div className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-pulse rounded-full bg-[color:var(--surface-strong)]" />
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-baseline gap-2">
<div className="h-3.5 w-32 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="h-3 w-20 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
<div className="flex gap-3">
<div className="h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-50" />
<div className="h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-50" />
</div>
</div>
<div className="h-3 w-8 shrink-0 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
))}
</div>
) : jobs.length === 0 ? ( ) : jobs.length === 0 ? (
<DashboardEmptyState message="No cron jobs found on this gateway." /> <DashboardEmptyState message="No cron jobs found on this gateway." />
) : ( ) : (

View File

@ -63,7 +63,23 @@ export function GatewayHealthPanel({
return ( return (
<DashboardSection title="Gateway Health"> <DashboardSection title="Gateway Health">
{isLoading && !health ? ( {isLoading && !health ? (
<DashboardEmptyState message="Loading health data…" /> <div className="space-y-3">
{["CPU", "Memory", "Disk"].map((label) => (
<div key={label}>
<div className="mb-1.5 h-3 w-12 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[color:var(--surface-strong)]">
<div className="h-full w-1/3 animate-pulse rounded-full bg-[color:var(--surface-strong)] opacity-60" />
</div>
<div className="h-3 w-10 animate-pulse rounded bg-[color:var(--surface-strong)]" />
</div>
</div>
))}
<div className="flex gap-4 border-t border-[color:var(--border)] pt-3">
<div className="h-3 w-20 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="h-3 w-24 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
</div>
) : !health || (!c?.cpu_pct && !c?.memory_pct && !c?.uptime_seconds) ? ( ) : !health || (!c?.cpu_pct && !c?.memory_pct && !c?.uptime_seconds) ? (
<DashboardEmptyState message="Health data unavailable. The gateway may not expose system metrics." /> <DashboardEmptyState message="Health data unavailable. The gateway may not expose system metrics." />
) : ( ) : (

View File

@ -35,7 +35,23 @@ export function PendingApprovalsSection({
tone={items.length > 0 ? "warning" : "success"} tone={items.length > 0 ? "warning" : "success"}
> >
{isLoading ? ( {isLoading ? (
<DashboardEmptyState message="Loading pending approvals..." /> <div className="overflow-hidden rounded-lg border border-[color:rgba(251,191,36,0.24)] bg-[color:var(--surface-muted)]">
{[0, 1, 2].map((i) => (
<div
key={i}
className="flex items-center justify-between gap-3 border-b border-[color:var(--border)] px-3 py-2 last:border-b-0"
>
<span className="min-w-0 flex-1">
<span className="mb-1 flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--warning)] opacity-40" />
<span className="h-3.5 w-40 animate-pulse rounded bg-[color:var(--surface-strong)]" />
</span>
<span className="h-3 w-28 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</span>
<span className="h-3 w-10 shrink-0 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
))}
</div>
) : hasError ? ( ) : hasError ? (
<DashboardEmptyState <DashboardEmptyState
tone="warning" tone="warning"

View File

@ -11,6 +11,7 @@ export type ActivityEvent = ActivityEventRead;
interface RecentActivitySectionProps { interface RecentActivitySectionProps {
events: ActivityEvent[]; events: ActivityEvent[];
feedHref: string; feedHref: string;
isLoading?: boolean;
onRowClick: (e: MouseEvent<HTMLDivElement>, href: string) => void; onRowClick: (e: MouseEvent<HTMLDivElement>, href: string) => void;
onRowKeyDown: (e: KeyboardEvent<HTMLDivElement>, href: string) => void; onRowKeyDown: (e: KeyboardEvent<HTMLDivElement>, href: string) => void;
buildHref: (event: ActivityEvent) => string; buildHref: (event: ActivityEvent) => string;
@ -51,6 +52,7 @@ const eventTone = (eventType: string) => {
export function RecentActivitySection({ export function RecentActivitySection({
events, events,
feedHref, feedHref,
isLoading = false,
onRowClick, onRowClick,
onRowKeyDown, onRowKeyDown,
buildHref, buildHref,
@ -64,7 +66,33 @@ export function RecentActivitySection({
tone="accent" tone="accent"
> >
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1"> <div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{events.length > 0 ? ( {isLoading && events.length === 0 ? (
<>
{[60, 80, 48, 72].map((titleW, i) => (
<div
key={i}
className="overflow-hidden rounded-lg border border-l-4 border-[color:var(--border)] border-l-[color:var(--accent)] bg-[color:var(--surface-muted)] px-3 py-2"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1.5">
<div
className="h-3.5 animate-pulse rounded bg-[color:var(--surface-strong)]"
style={{ width: `${titleW}%` }}
/>
<div className="flex items-center gap-2">
<span className="h-1.5 w-1.5 rounded-full bg-[color:var(--accent)] opacity-40" />
<div className="h-3 w-24 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
</div>
<div className="shrink-0 space-y-1 text-right">
<div className="ml-auto h-3 w-10 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
<div className="ml-auto h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-40" />
</div>
</div>
</div>
))}
</>
) : events.length > 0 ? (
events.map((event) => { events.map((event) => {
const href = buildHref(event); const href = buildHref(event);
const tone = eventTone(event.event_type); const tone = eventTone(event.event_type);

View File

@ -413,7 +413,50 @@ export function RuntimeUsageSection({
return ( return (
<DashboardSection title="Runtime Usage"> <DashboardSection title="Runtime Usage">
{isLoading && !usage ? ( {isLoading && !usage ? (
<DashboardEmptyState message="Loading usage data…" /> <div className="space-y-3">
{/* Provider windows skeleton */}
<div className="space-y-2 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3">
<div className="h-3 w-40 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="space-y-2">
{[0, 1].map((i) => (
<div
key={i}
className="rounded-md border border-[color:var(--border)] bg-[color:var(--surface)] p-2"
>
<div className="mb-1 flex items-center justify-between gap-2">
<div className="h-3.5 w-32 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="h-3 w-14 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[color:var(--surface-muted)]">
<div
className="h-full animate-pulse rounded-full bg-[color:var(--surface-strong)]"
style={{ width: `${45 + i * 20}%` }}
/>
</div>
<div className="mt-1 h-3 w-48 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-50" />
</div>
))}
</div>
</div>
{/* Stat cards skeleton */}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
{ w: "w-16" },
{ w: "w-14" },
{ w: "w-20" },
{ w: "w-12" },
].map(({ w }, i) => (
<div
key={i}
className="flex flex-col gap-1 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3"
>
<div className="h-3 w-20 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className={`h-6 ${w} animate-pulse rounded bg-[color:var(--surface-strong)]`} />
<div className="h-3 w-16 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-50" />
</div>
))}
</div>
</div>
) : noData ? ( ) : noData ? (
<DashboardEmptyState message="No usage data yet. Usage appears after the first model call." /> <DashboardEmptyState message="No usage data yet. Usage appears after the first model call." />
) : hasRuntimeData || providerRows.length > 0 || perGatewayUsage.length > 0 ? ( ) : hasRuntimeData || providerRows.length > 0 || perGatewayUsage.length > 0 ? (

View File

@ -47,7 +47,28 @@ export function SessionsSection({
{!hasConfiguredGateways ? ( {!hasConfiguredGateways ? (
<DashboardEmptyState message="No gateways are configured for any board yet." /> <DashboardEmptyState message="No gateways are configured for any board yet." />
) : isLoading ? ( ) : isLoading ? (
<DashboardEmptyState message="Loading sessions..." /> <div className="space-y-2">
{[0, 1].map((i) => (
<div
key={i}
className="overflow-hidden rounded-lg border border-l-4 border-[color:rgba(52,211,153,0.28)] border-l-[color:var(--success)] bg-[color:var(--surface-muted)] px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-[color:var(--success)] opacity-40" />
<div className="h-3.5 w-36 animate-pulse rounded bg-[color:var(--surface-strong)]" />
</div>
<div className="h-3 w-28 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
<div className="min-w-0 max-w-[45%] space-y-1 text-right">
<div className="ml-auto h-3.5 w-16 animate-pulse rounded bg-[color:var(--surface-strong)]" />
<div className="ml-auto h-3 w-24 animate-pulse rounded bg-[color:var(--surface-strong)] opacity-60" />
</div>
</div>
</div>
))}
</div>
) : sessions.length > 0 ? ( ) : sessions.length > 0 ? (
<> <>
{gatewayUnavailableCount > 0 && ( {gatewayUnavailableCount > 0 && (