fix: (dashboard) total ui rewrite
This commit is contained in:
parent
edb92047a6
commit
c88dfc1762
|
|
@ -3,26 +3,24 @@
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
import { type KeyboardEvent, type MouseEvent, useMemo } from "react";
|
import { type KeyboardEvent, type MouseEvent, useMemo } from "react";
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
import { SignedIn, SignedOut, useAuth } from "@/auth/clerk";
|
||||||
import {
|
import { Activity, Bot, LayoutGrid, Timer } from "lucide-react";
|
||||||
Activity,
|
|
||||||
ArrowUpRight,
|
|
||||||
Bot,
|
|
||||||
Info,
|
|
||||||
LayoutGrid,
|
|
||||||
Shield,
|
|
||||||
Timer,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
||||||
import { DashboardShell } from "@/components/templates/DashboardShell";
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
||||||
import { Markdown } from "@/components/atoms/Markdown";
|
|
||||||
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
import { SignedOutPanel } from "@/components/auth/SignedOutPanel";
|
||||||
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
|
import { ForgejoIssueMetricCards } from "@/components/git/ForgejoIssueMetricCards";
|
||||||
|
import {
|
||||||
|
DashboardMetricCard,
|
||||||
|
DashboardInfoBlock,
|
||||||
|
DashboardEmptyState,
|
||||||
|
PendingApprovalsSection,
|
||||||
|
SessionsSection,
|
||||||
|
RecentActivitySection,
|
||||||
|
} from "@/components/dashboard";
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
type dashboardMetricsApiV1MetricsDashboardGetResponse,
|
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() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
@ -1031,34 +906,34 @@ export default function DashboardPage() {
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<TopMetricCard
|
<DashboardMetricCard
|
||||||
title="Online Agents"
|
title="Online Agents"
|
||||||
value={formatCount(activeAgentsMetric)}
|
value={formatCount(activeAgentsMetric)}
|
||||||
secondary={`${formatCount(agents.length)} total`}
|
secondary={`${formatCount(agents.length)} total`}
|
||||||
icon={<Bot className="h-4 w-4" />}
|
icon={<Bot className="h-4 w-4" />}
|
||||||
accent="blue"
|
tone="accent"
|
||||||
/>
|
/>
|
||||||
<TopMetricCard
|
<DashboardMetricCard
|
||||||
title="Tasks In Progress"
|
title="Tasks In Progress"
|
||||||
value={formatCount(tasksInProgressMetric)}
|
value={formatCount(tasksInProgressMetric)}
|
||||||
secondary={`${formatCount(tasksTotal)} total`}
|
secondary={`${formatCount(tasksTotal)} total`}
|
||||||
icon={<LayoutGrid className="h-4 w-4" />}
|
icon={<LayoutGrid className="h-4 w-4" />}
|
||||||
accent="green"
|
tone="success"
|
||||||
/>
|
/>
|
||||||
<TopMetricCard
|
<DashboardMetricCard
|
||||||
title="Error Rate"
|
title="Error Rate"
|
||||||
value={formatPercent(errorRateMetric)}
|
value={formatPercent(errorRateMetric)}
|
||||||
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" />}
|
||||||
accent="violet"
|
tone="warning"
|
||||||
/>
|
/>
|
||||||
<TopMetricCard
|
<DashboardMetricCard
|
||||||
title="Completion Speed"
|
title="Completion Speed"
|
||||||
value={formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS)}
|
value={formatPerDay(throughputTotal, DASHBOARD_RANGE_DAYS)}
|
||||||
secondary={`${formatCount(throughputTotal)} completed`}
|
secondary={`${formatCount(throughputTotal)} completed`}
|
||||||
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" />}
|
||||||
accent="emerald"
|
tone="success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1072,222 +947,49 @@ export default function DashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<InfoBlock title="Workload" rows={workloadRows} />
|
<DashboardInfoBlock title="Workload" rows={workloadRows} />
|
||||||
<InfoBlock
|
<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}
|
||||||
/>
|
/>
|
||||||
<InfoBlock
|
<DashboardInfoBlock
|
||||||
title="Gateway Health"
|
title="Gateway Health"
|
||||||
badge={{
|
badge={{ text: gatewayStatusLabel, tone: gatewayBadgeTone }}
|
||||||
text: gatewayStatusLabel,
|
|
||||||
tone: gatewayBadgeTone,
|
|
||||||
}}
|
|
||||||
rows={gatewayRows}
|
rows={gatewayRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="mt-4 rounded-xl border border-[color:var(--border)] bg-[color:var(--surface)] p-4 md:p-6 shadow-lush">
|
<PendingApprovalsSection
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
items={pendingApprovalItems}
|
||||||
<h3 className="text-lg font-semibold text-strong">
|
total={pendingApprovalsTotal}
|
||||||
Pending Approvals
|
isLoading={!metrics && metricsQuery.isLoading}
|
||||||
</h3>
|
hasError={!metrics && Boolean(metricsQuery.error)}
|
||||||
<Link
|
formatCount={formatCount}
|
||||||
href="/approvals"
|
formatRelative={formatRelativeTimestamp}
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
<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">
|
<SessionsSection
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
sessions={sessionSummaries}
|
||||||
<h3 className="text-lg font-semibold text-strong">
|
activeSessions={activeSessions}
|
||||||
Sessions
|
hasConfiguredGateways={hasConfiguredGateways}
|
||||||
</h3>
|
isLoading={gatewayStatusesQuery.isLoading}
|
||||||
<span className="text-xs text-muted">
|
gatewayUnavailableCount={gatewayUnavailableCount}
|
||||||
{formatCount(activeSessions)}
|
gatewayTargetsCount={gatewayTargets.length}
|
||||||
</span>
|
formatCount={formatCount}
|
||||||
</div>
|
formatRelative={formatRelativeTimestamp}
|
||||||
<div className="max-h-[310px] space-y-2 overflow-x-hidden overflow-y-auto pr-1">
|
dash={DASH}
|
||||||
{!hasConfiguredGateways ? (
|
/>
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
<RecentActivitySection
|
||||||
No gateways are configured for any board yet.
|
events={recentLogs}
|
||||||
</div>
|
feedHref={activityFeedHref}
|
||||||
) : gatewayStatusesQuery.isLoading ? (
|
onRowClick={handleLogRowClick}
|
||||||
<div className="rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] p-3 text-sm text-muted">
|
onRowKeyDown={handleLogRowKeyDown}
|
||||||
Loading sessions...
|
buildHref={buildActivityEventHref}
|
||||||
</div>
|
formatRelative={formatRelativeTimestamp}
|
||||||
) : sessionSummaries.length > 0 ? (
|
formatTimestamp={formatTimestamp}
|
||||||
<>
|
/>
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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