botstatus

This commit is contained in:
null 2026-05-26 16:38:08 -05:00
parent ce19c7cd35
commit 80c352a5ab
2 changed files with 203 additions and 48 deletions

View File

@ -64,6 +64,7 @@ import {
import { GatewayHealthPanel } from "@/components/dashboard/GatewayHealthPanel";
import { GatewayCronPanel } from "@/components/dashboard/GatewayCronPanel";
import { BotStatusSection } from "@/components/dashboard/BotStatusSection";
import { getLatestBotReport } from "@/lib/api/bot";
import {
type listAgentsApiV1AgentsGetResponse,
useListAgentsApiV1AgentsGet,
@ -440,6 +441,7 @@ export default function DashboardPage() {
const { isSignedIn } = useAuth();
const [selectedForgejoRepositoryId, setSelectedForgejoRepositoryId] =
useState(ALL_FORGEJO_REPOSITORIES);
const [ripleyAutoSelected, setRipleyAutoSelected] = useState(false);
const [isRefreshingForgejoSync, setIsRefreshingForgejoSync] = useState(false);
const [createForgejoIssueOpen, setCreateForgejoIssueOpen] = useState(false);
@ -503,6 +505,13 @@ export default function DashboardPage() {
},
);
const botReportQuery = useQuery({
queryKey: ["bot-report-latest"],
queryFn: getLatestBotReport,
enabled: Boolean(isSignedIn),
refetchInterval: 30_000,
});
const forgejoRepositoriesQuery = useQuery<ForgejoRepository[], Error>({
queryKey: ["dashboard", "forgejo", "repositories"],
enabled: Boolean(isSignedIn),
@ -556,6 +565,26 @@ export default function DashboardPage() {
}
}, [forgejoRepositories, selectedForgejoRepositoryId]);
// Auto-select the Forgejo repo that matches Ripley's reported project.
// Only fires once on initial load while the user hasn't manually chosen.
useEffect(() => {
if (ripleyAutoSelected) return;
const project = botReportQuery.data?.project;
if (!project || forgejoRepositories.length === 0) return;
if (selectedForgejoRepositoryId !== ALL_FORGEJO_REPOSITORIES) return;
const needle = project.toLowerCase().trim();
const match = forgejoRepositories.find((repo) => {
const repoName = repo.repo.toLowerCase().trim();
const displayName = repo.display_name.toLowerCase().trim();
return repoName === needle || displayName === needle ||
repoName.includes(needle) || needle.includes(repoName);
});
if (match) {
setSelectedForgejoRepositoryId(match.id);
setRipleyAutoSelected(true);
}
}, [botReportQuery.data, forgejoRepositories, ripleyAutoSelected, selectedForgejoRepositoryId]);
const forgejoMetricsQuery = useQuery<ForgejoIssueMetrics | null, Error>({
queryKey: [
"dashboard",
@ -1376,7 +1405,10 @@ export default function DashboardPage() {
repositories={forgejoRepositories}
metricRepositories={scopedForgejoRepositories}
selectedRepositoryId={selectedForgejoRepositoryId}
onSelectedRepositoryChange={setSelectedForgejoRepositoryId}
onSelectedRepositoryChange={(id) => {
if (id === ALL_FORGEJO_REPOSITORIES) setRipleyAutoSelected(false);
setSelectedForgejoRepositoryId(id);
}}
onRefreshLastSync={handleRefreshForgejoLastSync}
onCreateIssue={() => setCreateForgejoIssueOpen(true)}
isRefreshingLastSync={isRefreshingForgejoSync}

View File

@ -1,61 +1,193 @@
"use client";
import { Bot, Clock } from "lucide-react";
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 { DashboardSection } from "./DashboardSection";
import { cn } from "@/lib/utils";
import {
sectionRail,
sectionTone,
toneIcon,
type MetricToneKey,
type SectionToneKey,
} from "./tokens";
function timeAgo(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
// 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`;
}
function StatusBadge({ status }: { status: string | null }) {
if (!status) return null;
const styles: Record<string, string> = {
busy: "bg-[color:rgba(251,191,36,0.15)] text-[color:var(--warning)]",
idle: "bg-[color:rgba(52,211,153,0.15)] text-[color:var(--success)]",
error: "bg-[color:rgba(248,113,113,0.12)] text-[color:var(--danger)]",
type BotStatusTone = {
label: string;
tone: SectionToneKey;
iconTone: MetricToneKey | "neutral";
badgeClassName: string;
dotClassName: string;
message: string;
};
const style = styles[status.toLowerCase()] ?? "bg-[color:var(--surface-strong)] text-muted";
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={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${style}`}>
{status}
<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 ReportContent({ report }: { report: BotReportRead }) {
return (
<div className="space-y-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-2xl font-semibold text-strong truncate">
{report.project ?? "—"}
</p>
{report.task && (
<p className="mt-1 text-sm text-muted truncate">{report.task}</p>
)}
</div>
<StatusBadge status={report.status} />
</div>
<div className="flex items-center gap-1.5 text-xs text-muted">
<Clock className="h-3 w-3" />
{timeAgo(report.reported_at)}
</div>
</div>
);
}
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.";
function EmptyState() {
return (
<div className="flex items-center gap-3 rounded-lg border border-[color:var(--border)] bg-[color:var(--surface-muted)] px-4 py-3 text-sm text-muted">
<Bot className="h-4 w-4 shrink-0" />
Ripley hasn&apos;t reported in yet.
<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>
);
}
@ -67,14 +199,5 @@ export function BotStatusSection() {
refetchInterval: 30_000,
});
return (
<DashboardSection
title="Ripley"
tone="warning"
action={{ label: "Manage keys", href: "/settings/bot-api-keys" }}
infoText="Current project reported by the OpenClaw agent"
>
{report ? <ReportContent report={report} /> : <EmptyState />}
</DashboardSection>
);
return <Banner report={report ?? null} />;
}