Pipeline/frontend/src/components/dashboard/BotStatusSection.tsx

204 lines
6.8 KiB
TypeScript

"use client";
import { ArrowUpRight, Bot, Clock, KeyRound } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { getLatestBotReport, type BotReportRead } from "@/lib/api/bot";
import { cn } from "@/lib/utils";
import {
sectionRail,
sectionTone,
toneIcon,
type MetricToneKey,
type SectionToneKey,
} from "./tokens";
function timeAgo(iso: string): string {
// Backend returns naive UTC datetimes without 'Z'; append it so the browser
// parses correctly instead of treating the string as local time.
const utc = /Z|[+-]\d{2}:?\d{2}$/.test(iso) ? iso : `${iso}Z`;
const diff = Math.floor((Date.now() - new Date(utc).getTime()) / 1000);
if (diff < 5) return "just now";
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
type BotStatusTone = {
label: string;
tone: SectionToneKey;
iconTone: MetricToneKey | "neutral";
badgeClassName: string;
dotClassName: string;
message: string;
};
function getStatusTone(report: BotReportRead | null): BotStatusTone {
const status = report?.status?.toLowerCase();
if (status === "idle") {
return {
label: "Idle",
tone: "success",
iconTone: "success",
badgeClassName:
"border-[color:rgba(52,211,153,0.28)] bg-[color:rgba(52,211,153,0.14)] text-[color:var(--success)]",
dotClassName:
"bg-[color:var(--success)] shadow-[0_0_0_3px_rgba(52,211,153,0.14)]",
message: "Ready for work",
};
}
if (status === "busy") {
return {
label: "Busy",
tone: "warning",
iconTone: "warning",
badgeClassName:
"border-[color:rgba(251,191,36,0.3)] bg-[color:rgba(251,191,36,0.14)] text-[color:var(--warning)]",
dotClassName:
"bg-[color:var(--warning)] shadow-[0_0_0_3px_rgba(251,191,36,0.14)]",
message: "Working now",
};
}
if (status === "error") {
return {
label: "Error",
tone: "danger",
iconTone: "danger",
badgeClassName:
"border-[color:rgba(248,113,113,0.3)] bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
dotClassName:
"bg-[color:var(--danger)] shadow-[0_0_0_3px_rgba(248,113,113,0.14)]",
message: "Needs attention",
};
}
if (report?.status) {
return {
label: report.status,
tone: "accent",
iconTone: "accent",
badgeClassName:
"border-[color:rgba(96,165,250,0.28)] bg-[color:rgba(96,165,250,0.13)] text-[color:var(--accent-strong)]",
dotClassName:
"bg-[color:var(--accent-strong)] shadow-[0_0_0_3px_rgba(96,165,250,0.14)]",
message: "Status reported",
};
}
return {
label: "Offline",
tone: "neutral",
iconTone: "neutral",
badgeClassName:
"border-[color:var(--border)] bg-[color:var(--surface-strong)] text-muted",
dotClassName:
"bg-[color:var(--text-muted)] shadow-[0_0_0_3px_rgba(148,163,184,0.12)]",
message: "Awaiting first report",
};
}
function StatusBadge({ statusTone }: { statusTone: BotStatusTone }) {
return (
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] font-semibold",
statusTone.badgeClassName,
)}
>
<span
className={cn("h-1.5 w-1.5 rounded-full", statusTone.dotClassName)}
aria-hidden="true"
/>
{statusTone.label}
</span>
);
}
function Banner({ report }: { report: BotReportRead | null }) {
const statusTone = getStatusTone(report);
const target = report
? [report.project ?? "No project", report.task].filter(Boolean).join(" / ")
: "Ripley has not reported in yet.";
return (
<div
className={cn(
"group relative overflow-hidden rounded-xl border p-3 shadow-lush transition hover:-translate-y-0.5 hover:shadow-md md:p-4",
sectionTone[statusTone.tone],
)}
>
<span
className={cn(
"pointer-events-none absolute inset-x-4 top-0 h-px",
sectionRail[statusTone.tone],
)}
/>
<div className="relative flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-start gap-3">
<div
className={cn(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-lg",
statusTone.iconTone === "neutral"
? "border border-[color:var(--border)] bg-[color:var(--surface-muted)] text-muted"
: toneIcon[statusTone.iconTone],
)}
>
<Bot className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
<p className="truncate font-heading text-base font-semibold text-strong">
Ripley
</p>
<span className="hidden h-1 w-1 shrink-0 rounded-full bg-[color:var(--border)] sm:block" />
</div>
<StatusBadge statusTone={statusTone} />
</div>
<div className="mt-1 flex min-w-0 flex-col gap-0.5 sm:flex-row sm:items-center sm:gap-2">
<p className="text-xs font-medium text-muted">
{statusTone.message}
</p>
<span className="hidden h-1 w-1 shrink-0 rounded-full bg-[color:var(--border)] sm:block" />
<p className="min-w-0 truncate text-sm text-muted">{target}</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 pl-14 lg:shrink-0 lg:pl-0">
{report ? (
<span className="inline-flex items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-2.5 py-1.5 text-xs text-muted">
<Clock className="h-3.5 w-3.5" />
{timeAgo(report.reported_at)}
</span>
) : null}
<Link
href="/settings/bot-api-keys"
className="inline-flex items-center gap-1.5 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface)] px-2.5 py-1.5 text-xs font-medium text-muted transition hover:border-[color:var(--accent)] hover:text-[color:var(--accent)]"
>
<KeyRound className="h-3.5 w-3.5" />
Manage keys
<ArrowUpRight className="h-3 w-3 opacity-70 transition group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
</Link>
</div>
</div>
</div>
);
}
export function BotStatusSection() {
const { data: report } = useQuery({
queryKey: ["bot-report-latest"],
queryFn: getLatestBotReport,
refetchInterval: 30_000,
});
return <Banner report={report ?? null} />;
}