fix: (dashboard) total ui rewrite

This commit is contained in:
null 2026-05-19 23:11:32 -05:00
parent edb92047a6
commit c88dfc1762
10 changed files with 537 additions and 347 deletions

View File

@ -3,26 +3,24 @@
export const dynamic = "force-dynamic";
import { type KeyboardEvent, type MouseEvent, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
import {
Activity,
ArrowUpRight,
Bot,
Info,
LayoutGrid,
Shield,
Timer,
} from "lucide-react";
import { Activity, Bot, LayoutGrid, Timer } from "lucide-react";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Markdown } from "@/components/atoms/Markdown";
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
import {
DashboardMetricCard,
DashboardInfoBlock,
DashboardEmptyState,
PendingApprovalsSection,
SessionsSection,
RecentActivitySection,
} from "@/components/dashboard";
import { ApiError } from "@/api/mutator";
import {
type dashboardMetricsApiV1MetricsDashboardGetResponse,
@ -386,129 +384,6 @@ const toSessionSummaries = (
});
};
function TopMetricCard({
title,
value,
secondary,
infoText,
icon,
accent,
}: {
title: string;
value: string;
secondary?: string;
infoText?: string;
icon: React.ReactNode;
accent: "blue" | "green" | "violet" | "emerald";
}) {
const iconTone =
accent === "blue"
? "bg-[color:var(--accent-soft)] text-[color:var(--accent)]"
: accent === "green"
? "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]"
: accent === "violet"
? "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]"
: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]";
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-6 shadow-lush transition hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-1.5">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
{title}
</p>
{infoText ? (
<span
className="inline-flex text-muted"
title={infoText}
aria-label={infoText}
>
<Info className="h-3.5 w-3.5" />
</span>
) : null}
</div>
<div className="mt-2 flex items-end gap-2">
<p className="font-heading text-4xl font-bold text-strong">
{value}
</p>
{secondary ? (
<p className="pb-1 text-xs text-muted">{secondary}</p>
) : null}
</div>
</div>
<div className={`rounded-lg p-2 ${iconTone}`}>{icon}</div>
</div>
</section>
);
}
function InfoBlock({
title,
badge,
infoText,
rows,
}: {
title: string;
badge?: { text: string; tone: "online" | "offline" | "neutral" };
infoText?: string;
rows: SummaryRow[];
}) {
return (
<section className="rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-6 shadow-lush">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{infoText ? (
<span
className="inline-flex text-muted"
title={infoText}
aria-label={infoText}
>
<Info className="h-3.5 w-3.5" />
</span>
) : null}
</div>
{badge ? (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
badge.tone === "online"
? "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]"
: badge.tone === "offline"
? "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]"
: "bg-[color:var(--surface-strong)] text-muted"
}`}
>
{badge.text}
</span>
) : null}
</div>
<div className="divide-y divide-[color:var(--border)] rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)]">
{rows.map((row) => (
<div
key={`${row.label}-${row.value}`}
className="flex items-start justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 text-sm text-muted">{row.label}</span>
<span
className={`max-w-[65%] break-words text-right text-sm font-medium leading-5 ${
row.tone === "success"
? "text-[color:var(--success)]"
: row.tone === "warning"
? "text-[color:var(--warning)]"
: row.tone === "danger"
? "text-[color:var(--danger)]"
: "text-strong"
}`}
>
{row.value}
</span>
</div>
))}
</div>
</section>
);
}
export default function DashboardPage() {
const router = useRouter();
@ -1031,34 +906,34 @@ export default function DashboardPage() {
) : null}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<TopMetricCard
<DashboardMetricCard
title="Online Agents"
value={formatCount(activeAgentsMetric)}
secondary={`${formatCount(agents.length)} total`}
icon={<Bot className="h-4 w-4" />}
accent="blue"
tone="accent"
/>
<TopMetricCard
<DashboardMetricCard
title="Tasks In Progress"
value={formatCount(tasksInProgressMetric)}
secondary={`${formatCount(tasksTotal)} total`}
icon={<LayoutGrid className="h-4 w-4" />}
accent="green"
tone="success"
/>
<TopMetricCard
<DashboardMetricCard
title="Error Rate"
value={formatPercent(errorRateMetric)}
secondary={`${formatCount(Number(latestThroughputPoint?.value ?? 0))} completed (latest)`}
icon={<Activity className="h-4 w-4" />}
accent="violet"
tone="warning"
/>
<TopMetricCard
<DashboardMetricCard
title="Completion Speed"
value={formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS)}
secondary={`${formatCount(throughputTotal)} completed`}
infoText={`Based on ${DASHBOARD_RANGE_LABEL}`}
icon={<Timer className="h-4 w-4" />}
accent="emerald"
tone="success"
/>
</div>
@ -1072,222 +947,49 @@ export default function DashboardPage() {
</div>
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<InfoBlock title="Workload" rows={workloadRows} />
<InfoBlock
<DashboardInfoBlock title="Workload" rows={workloadRows} />
<DashboardInfoBlock
title="Throughput"
infoText={`All throughput values are calculated for ${DASHBOARD_RANGE_LABEL}`}
rows={throughputRows}
/>
<InfoBlock
<DashboardInfoBlock
title="Gateway Health"
badge={{
text: gatewayStatusLabel,
tone: gatewayBadgeTone,
}}
badge={{ text: gatewayStatusLabel, tone: gatewayBadgeTone }}
rows={gatewayRows}
/>
</div>
<section className="mt-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-6 shadow-lush">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-strong">
Pending Approvals
</h3>
<Link
href="/approvals"
className="inline-flex items-center gap-1 text-xs text-muted transition hover:text-strong"
>
Open global approvals
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
{!metrics && metricsQuery.isLoading ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
Loading pending approvals...
</div>
) : !metrics && metricsQuery.error ? (
<div className="rounded-lg border border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] p-3 text-sm text-[color:var(--warning)]">
Pending approvals are temporarily unavailable.
</div>
) : hasPendingApprovals ? (
<div className="space-y-2">
<div className="divide-y divide-[color:var(--border)] rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)]">
{pendingApprovalItems.map((item) => (
<Link
key={item.approval_id}
href={`/boards/${item.board_id}/approvals`}
className="flex items-center justify-between gap-3 px-3 py-2 transition hover:bg-[color:var(--surface-strong)]"
>
<span className="min-w-0 text-sm text-strong">
<span className="block truncate font-medium text-strong">
{item.task_title || "Pending approval"}
</span>
<span className="block truncate text-xs text-muted">
{item.board_name} · {item.confidence}% score
</span>
</span>
<span className="shrink-0 text-xs text-muted">
{formatRelativeTimestamp(item.created_at)}
</span>
</Link>
))}
</div>
{pendingApprovalsTotal > pendingApprovalItems.length ? (
<p className="text-xs text-muted">
Showing latest {formatCount(pendingApprovalItems.length)}{" "}
of {formatCount(pendingApprovalsTotal)} pending approvals.
</p>
) : null}
</div>
) : (
<div className="rounded-lg border border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] p-3 text-sm text-[color:var(--success)]">
No pending approvals across your boards.
</div>
)}
</section>
<PendingApprovalsSection
items={pendingApprovalItems}
total={pendingApprovalsTotal}
isLoading={!metrics && metricsQuery.isLoading}
hasError={!metrics && Boolean(metricsQuery.error)}
formatCount={formatCount}
formatRelative={formatRelativeTimestamp}
/>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<section className="min-w-0 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-6 shadow-lush">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-strong">
Sessions
</h3>
<span className="text-xs text-muted">
{formatCount(activeSessions)}
</span>
</div>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{!hasConfiguredGateways ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
No gateways are configured for any board yet.
</div>
) : gatewayStatusesQuery.isLoading ? (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
Loading sessions...
</div>
) : sessionSummaries.length > 0 ? (
<>
{gatewayUnavailableCount > 0 ? (
<div className="rounded-lg border border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] p-3 text-sm text-[color:var(--warning)]">
{formatCount(gatewayUnavailableCount)} gateway
{gatewayUnavailableCount === 1 ? "" : "s"}{" "}
unavailable; showing sessions from reachable gateways.
</div>
) : null}
{sessionSummaries.map((session) => (
<div
key={session.key}
className="overflow-hidden rounded-lg border border-[color:var(--border)] 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">
<p className="truncate text-sm font-medium text-strong">
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${
session.isMain
? "bg-[color:var(--success)]"
: "bg-[color:var(--border-strong)]"
}`}
/>
{session.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted">
{session.subtitle}
</p>
</div>
<div className="min-w-0 max-w-[45%] text-right">
<p className="truncate text-xs font-medium text-strong">
{session.usage === DASH
? "Usage unavailable"
: session.usage}
</p>
<p className="text-[11px] text-muted">
{session.lastSeenAt
? formatRelativeTimestamp(session.lastSeenAt)
: "Activity unavailable"}
</p>
</div>
</div>
</div>
))}
</>
) : gatewayUnavailableCount === gatewayTargets.length ? (
<div className="rounded-lg border border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] p-3 text-sm text-[color:var(--danger)]">
Session data is unavailable for all configured gateways.
</div>
) : (
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
No active sessions detected.
</div>
)}
</div>
</section>
<section className="min-w-0 overflow-hidden rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-6 shadow-lush">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-strong">
Recent Activity
</h3>
<Link
href={activityFeedHref}
className="inline-flex items-center gap-1 text-xs text-muted transition hover:text-strong"
>
Open feed
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{recentLogs.length > 0 ? (
recentLogs.map((event) => {
const eventHref = buildActivityEventHref(event);
return (
<div
key={event.id}
role="link"
tabIndex={0}
aria-label={`Open related context for ${event.event_type} activity`}
onClick={(interactionEvent) =>
handleLogRowClick(interactionEvent, eventHref)
}
onKeyDown={(interactionEvent) =>
handleLogRowKeyDown(interactionEvent, eventHref)
}
className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="break-words text-sm font-medium text-strong [&_ol]:mb-0 [&_p]:mb-0 [&_pre]:my-1 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_ul]:mb-0">
<Markdown
content={
event.message?.trim() || event.event_type
}
variant="comment"
/>
</div>
<p className="mt-0.5 text-xs uppercase tracking-wider text-muted">
{event.event_type}
</p>
</div>
<div className="shrink-0 text-right text-[11px] text-muted">
<p>{formatRelativeTimestamp(event.created_at)}</p>
<p>{formatTimestamp(event.created_at)}</p>
</div>
</div>
</div>
);
})
) : (
<div className="flex h-[240px] flex-col items-center justify-center rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] text-sm text-muted">
<Shield className="mb-2 h-5 w-5 text-muted" />
No activity yet
<p className="mt-1 text-xs text-muted">
Activity appears here when events are emitted.
</p>
</div>
)}
</div>
</section>
<SessionsSection
sessions={sessionSummaries}
activeSessions={activeSessions}
hasConfiguredGateways={hasConfiguredGateways}
isLoading={gatewayStatusesQuery.isLoading}
gatewayUnavailableCount={gatewayUnavailableCount}
gatewayTargetsCount={gatewayTargets.length}
formatCount={formatCount}
formatRelative={formatRelativeTimestamp}
dash={DASH}
/>
<RecentActivitySection
events={recentLogs}
feedHref={activityFeedHref}
onRowClick={handleLogRowClick}
onRowKeyDown={handleLogRowKeyDown}
buildHref={buildActivityEventHref}
formatRelative={formatRelativeTimestamp}
formatTimestamp={formatTimestamp}
/>
</div>
</div>
</main>

View File

@ -0,0 +1,35 @@
import type { ReactNode } from "react";
import { toneBanner, type Tone } from "./tokens";
interface DashboardEmptyStateProps {
icon?: ReactNode;
message: string;
sub?: string;
tone?: Tone;
}
/**
* Reusable empty / status callout used throughout dashboard sections.
* One component replaces five copies of the same inline div pattern.
*/
export function DashboardEmptyState({
icon,
message,
sub,
tone = "default",
}: DashboardEmptyStateProps) {
if (icon) {
return (
<div className={`flex flex-col items-center justify-center rounded-lg border p-6 text-sm ${toneBanner[tone]}`}>
<span className="mb-2 opacity-60">{icon}</span>
<span>{message}</span>
{sub && <p className="mt-1 text-xs opacity-70">{sub}</p>}
</div>
);
}
return (
<div className={`rounded-lg border p-3 text-sm ${toneBanner[tone]}`}>
{message}
</div>
);
}

View File

@ -0,0 +1,40 @@
import { toneText, type Tone } from "./tokens";
import { DashboardSection } from "./DashboardSection";
import type { BadgeTone } from "./tokens";
export type InfoRow = {
label: string;
value: string;
tone?: Tone;
};
interface DashboardInfoBlockProps {
title: string;
infoText?: string;
badge?: { text: string; tone: BadgeTone };
rows: InfoRow[];
}
/**
* Labeled key/value block used for Workload, Throughput, Gateway Health.
* Tone color is a lookup, not a ternary chain.
*/
export function DashboardInfoBlock({ title, infoText, badge, rows }: DashboardInfoBlockProps) {
return (
<DashboardSection title={title} infoText={infoText} badge={badge}>
<div className="divide-y divide-[color:var(--border)] rounded-lg surface-muted overflow-hidden">
{rows.map((row) => (
<div
key={`${row.label}-${row.value}`}
className="flex items-start justify-between gap-3 px-3 py-2"
>
<span className="min-w-0 text-sm text-muted">{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>
</DashboardSection>
);
}

View File

@ -0,0 +1,51 @@
import type { ReactNode } from "react";
import { Info } from "lucide-react";
import { toneIcon, type MetricToneKey } from "./tokens";
interface DashboardMetricCardProps {
title: string;
value: string;
secondary?: string;
infoText?: string;
icon: ReactNode;
tone: MetricToneKey;
}
/**
* Top-row metric card. Tone drives the icon container color only
* card background and text always come from design tokens.
*/
export function DashboardMetricCard({
title,
value,
secondary,
infoText,
icon,
tone,
}: DashboardMetricCardProps) {
return (
<section className="surface-card rounded-xl p-4 md:p-6 transition hover:-translate-y-0.5 hover:shadow-md">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-1.5">
<p className="text-xs font-semibold uppercase tracking-wider text-muted">
{title}
</p>
{infoText && (
<span className="inline-flex text-muted" title={infoText} aria-label={infoText}>
<Info className="h-3.5 w-3.5" />
</span>
)}
</div>
<div className="mt-2 flex items-end gap-2">
<p className="font-heading text-4xl font-bold text-strong">{value}</p>
{secondary && (
<p className="pb-1 text-xs text-muted">{secondary}</p>
)}
</div>
</div>
<div className={`rounded-lg p-2 ${toneIcon[tone]}`}>{icon}</div>
</div>
</section>
);
}

View File

@ -0,0 +1,60 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { ArrowUpRight, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { toneBadge, type BadgeTone } from "./tokens";
interface DashboardSectionProps {
title: string;
infoText?: string;
badge?: { text: string; tone: BadgeTone };
action?: { label: string; href: string };
children: ReactNode;
className?: string;
}
/**
* Standard dashboard card section.
* Uses .surface-card so all color values come from design tokens
* no hardcoded palette classes.
*/
export function DashboardSection({
title,
infoText,
badge,
action,
children,
className,
}: DashboardSectionProps) {
return (
<section className={cn("surface-card rounded-xl p-4 md:p-6", className)}>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-1.5">
<h3 className="text-lg font-semibold text-strong">{title}</h3>
{infoText && (
<span className="inline-flex text-muted" title={infoText} aria-label={infoText}>
<Info className="h-3.5 w-3.5" />
</span>
)}
</div>
<div className="flex items-center gap-2">
{badge && (
<span className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium", toneBadge[badge.tone])}>
{badge.text}
</span>
)}
{action && (
<Link
href={action.href}
className="inline-flex items-center gap-1 text-xs text-muted transition hover:text-strong"
>
{action.label}
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
)}
</div>
</div>
{children}
</section>
);
}

View File

@ -0,0 +1,80 @@
import Link from "next/link";
import { DashboardSection } from "./DashboardSection";
import { DashboardEmptyState } from "./DashboardEmptyState";
type ApprovalItem = {
approval_id: string;
board_id: string;
board_name: string;
task_title?: string | null;
confidence: number;
created_at: string;
};
interface PendingApprovalsSectionProps {
items: ApprovalItem[];
total: number;
isLoading: boolean;
hasError: boolean;
formatCount: (n: number) => string;
formatRelative: (ts: string) => string;
}
export function PendingApprovalsSection({
items,
total,
isLoading,
hasError,
formatCount,
formatRelative,
}: PendingApprovalsSectionProps) {
return (
<DashboardSection
title="Pending Approvals"
action={{ label: "Open global approvals", href: "/approvals" }}
>
{isLoading ? (
<DashboardEmptyState message="Loading pending approvals..." />
) : hasError ? (
<DashboardEmptyState
tone="warning"
message="Pending approvals are temporarily unavailable."
/>
) : items.length > 0 ? (
<div className="space-y-2">
<div className="divide-y divide-[color:var(--border)] rounded-lg surface-muted overflow-hidden">
{items.map((item) => (
<Link
key={item.approval_id}
href={`/boards/${item.board_id}/approvals`}
className="flex items-center justify-between gap-3 px-3 py-2 transition hover:bg-[color:var(--surface-strong)]"
>
<span className="min-w-0 text-sm">
<span className="block truncate font-medium text-strong">
{item.task_title || "Pending approval"}
</span>
<span className="block truncate text-xs text-muted">
{item.board_name} · {item.confidence}% score
</span>
</span>
<span className="shrink-0 text-xs text-muted">
{formatRelative(item.created_at)}
</span>
</Link>
))}
</div>
{total > items.length && (
<p className="text-xs text-muted">
Showing latest {formatCount(items.length)} of {formatCount(total)} pending approvals.
</p>
)}
</div>
) : (
<DashboardEmptyState
tone="success"
message="No pending approvals across your boards."
/>
)}
</DashboardSection>
);
}

View File

@ -0,0 +1,78 @@
import { type KeyboardEvent, type MouseEvent } from "react";
import { Shield } from "lucide-react";
import { DashboardSection } from "./DashboardSection";
import { DashboardEmptyState } from "./DashboardEmptyState";
import { Markdown } from "@/components/atoms/Markdown";
import type { ActivityEventRead } from "@/api/generated/model";
export type ActivityEvent = ActivityEventRead;
interface RecentActivitySectionProps {
events: ActivityEvent[];
feedHref: string;
onRowClick: (e: MouseEvent<HTMLDivElement>, href: string) => void;
onRowKeyDown: (e: KeyboardEvent<HTMLDivElement>, href: string) => void;
buildHref: (event: ActivityEvent) => string;
formatRelative: (ts: string) => string;
formatTimestamp: (ts: string) => string;
}
export function RecentActivitySection({
events,
feedHref,
onRowClick,
onRowKeyDown,
buildHref,
formatRelative,
formatTimestamp,
}: RecentActivitySectionProps) {
return (
<DashboardSection
title="Recent Activity"
action={{ label: "Open feed", href: feedHref }}
>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{events.length > 0 ? (
events.map((event) => {
const href = buildHref(event);
return (
<div
key={event.id}
role="link"
tabIndex={0}
aria-label={`Open related context for ${event.event_type} activity`}
onClick={(e) => onRowClick(e, href)}
onKeyDown={(e) => onRowKeyDown(e, href)}
className="cursor-pointer overflow-hidden rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-3 py-2 transition hover:border-[color:var(--border-strong)] hover:bg-[color:var(--surface-strong)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--accent)]"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 overflow-hidden">
<div className="break-words text-sm font-medium text-strong [&_ol]:mb-0 [&_p]:mb-0 [&_pre]:my-1 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_ul]:mb-0">
<Markdown
content={event.message?.trim() || event.event_type}
variant="comment"
/>
</div>
<p className="mt-0.5 text-xs uppercase tracking-wider text-muted">
{event.event_type}
</p>
</div>
<div className="shrink-0 text-right text-[11px] text-muted">
<p>{formatRelative(event.created_at)}</p>
<p>{formatTimestamp(event.created_at)}</p>
</div>
</div>
</div>
);
})
) : (
<DashboardEmptyState
icon={<Shield className="h-5 w-5" />}
message="No activity yet"
sub="Activity appears here when events are emitted."
/>
)}
</div>
</DashboardSection>
);
}

View File

@ -0,0 +1,93 @@
import { DashboardSection } from "./DashboardSection";
import { DashboardEmptyState } from "./DashboardEmptyState";
type SessionSummary = {
key: string;
title: string;
subtitle: string;
usage: string;
lastSeenAt: string | null;
isMain: boolean;
};
interface SessionsSectionProps {
sessions: SessionSummary[];
activeSessions: number;
hasConfiguredGateways: boolean;
isLoading: boolean;
gatewayUnavailableCount: number;
gatewayTargetsCount: number;
formatCount: (n: number) => string;
formatRelative: (ts: string) => string;
dash: string;
}
export function SessionsSection({
sessions,
activeSessions,
hasConfiguredGateways,
isLoading,
gatewayUnavailableCount,
gatewayTargetsCount,
formatCount,
formatRelative,
dash,
}: SessionsSectionProps) {
return (
<DashboardSection title="Sessions" action={{ label: formatCount(activeSessions), href: "#" }}>
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
{!hasConfiguredGateways ? (
<DashboardEmptyState message="No gateways are configured for any board yet." />
) : isLoading ? (
<DashboardEmptyState message="Loading sessions..." />
) : sessions.length > 0 ? (
<>
{gatewayUnavailableCount > 0 && (
<DashboardEmptyState
tone="warning"
message={`${formatCount(gatewayUnavailableCount)} gateway${gatewayUnavailableCount === 1 ? "" : "s"} unavailable; showing sessions from reachable gateways.`}
/>
)}
{sessions.map((session) => (
<div
key={session.key}
className="overflow-hidden rounded-lg border border-[color:var(--border)] 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">
<p className="truncate text-sm font-medium text-strong">
<span
className={`mr-2 inline-block h-2 w-2 rounded-full ${
session.isMain
? "bg-[color:var(--success)]"
: "bg-[color:var(--border-strong)]"
}`}
/>
{session.title}
</p>
<p className="mt-0.5 truncate text-xs text-muted">{session.subtitle}</p>
</div>
<div className="min-w-0 max-w-[45%] text-right">
<p className="truncate text-xs font-medium text-strong">
{session.usage === dash ? "Usage unavailable" : session.usage}
</p>
<p className="text-[11px] text-muted">
{session.lastSeenAt ? formatRelative(session.lastSeenAt) : "Activity unavailable"}
</p>
</div>
</div>
</div>
))}
</>
) : gatewayUnavailableCount === gatewayTargetsCount ? (
<DashboardEmptyState
tone="danger"
message="Session data is unavailable for all configured gateways."
/>
) : (
<DashboardEmptyState message="No active sessions detected." />
)}
</div>
</DashboardSection>
);
}

View File

@ -0,0 +1,8 @@
export { DashboardSection } from "./DashboardSection";
export { DashboardMetricCard } from "./DashboardMetricCard";
export { DashboardInfoBlock, type InfoRow } from "./DashboardInfoBlock";
export { DashboardEmptyState } from "./DashboardEmptyState";
export { PendingApprovalsSection } from "./PendingApprovalsSection";
export { SessionsSection } from "./SessionsSection";
export { RecentActivitySection } from "./RecentActivitySection";
export { toneText, toneBanner, toneBadge, toneIcon, type Tone, type BadgeTone, type MetricToneKey } from "./tokens";

View File

@ -0,0 +1,43 @@
/** Central tone → className map. Use this instead of ternary chains throughout dashboard components. */
export type Tone = "default" | "success" | "warning" | "danger";
export type BadgeTone = "online" | "offline" | "neutral";
export type MetricToneKey = "accent" | "success" | "warning" | "danger";
/** Inline text color for a data value. */
export const toneText: Record<Tone, string> = {
default: "text-strong",
success: "text-[color:var(--success)]",
warning: "text-[color:var(--warning)]",
danger: "text-[color:var(--danger)]",
};
/** Soft background + text for banners and empty-state callouts. */
export const toneBanner: Record<Tone, string> = {
default:
"border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted",
success:
"border-[color:rgba(52,211,153,0.35)] bg-[color:rgba(52,211,153,0.08)] text-[color:var(--success)]",
warning:
"border-[color:rgba(251,191,36,0.35)] bg-[color:rgba(251,191,36,0.08)] text-[color:var(--warning)]",
danger:
"border-[color:rgba(248,113,113,0.35)] bg-[color:rgba(248,113,113,0.08)] text-[color:var(--danger)]",
};
/** Small pill / badge background + text. */
export const toneBadge: Record<BadgeTone, string> = {
online:
"bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
offline:
"bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
neutral:
"bg-[color:var(--surface-strong)] text-muted",
};
/** Icon container background + icon color for metric cards. */
export const toneIcon: Record<MetricToneKey, string> = {
accent: "bg-[color:var(--accent-soft)] text-[color:var(--accent)]",
success: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
warning: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
danger: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
};