fix: (dashboard) total ui rewrite
This commit is contained in:
parent
edb92047a6
commit
c88dfc1762
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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)]",
|
||||
};
|
||||
Loading…
Reference in New Issue