diff --git a/frontend/src/app/dashboard/page.tsx b/frontend/src/app/dashboard/page.tsx index 7c0552c..cd04a9e 100644 --- a/frontend/src/app/dashboard/page.tsx +++ b/frontend/src/app/dashboard/page.tsx @@ -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({ 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({ 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} diff --git a/frontend/src/components/dashboard/BotStatusSection.tsx b/frontend/src/components/dashboard/BotStatusSection.tsx index 7e0c89e..b13e1da 100644 --- a/frontend/src/components/dashboard/BotStatusSection.tsx +++ b/frontend/src/components/dashboard/BotStatusSection.tsx @@ -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 = { - 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; +}; + +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", }; - const style = styles[status.toLowerCase()] ?? "bg-[color:var(--surface-strong)] text-muted"; +} + +function StatusBadge({ statusTone }: { statusTone: BotStatusTone }) { return ( - - {status} + + ); } -function ReportContent({ report }: { report: BotReportRead }) { - return ( -
-
-
-

- {report.project ?? "—"} -

- {report.task && ( -

{report.task}

- )} -
- -
-
- - {timeAgo(report.reported_at)} -
-
- ); -} +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 ( -
- - Ripley hasn't reported in yet. +
+ +
+
+
+ +
+ +
+
+
+

+ Ripley +

+ +
+ +
+
+

+ {statusTone.message} +

+ +

{target}

+
+
+
+ +
+ {report ? ( + + + {timeAgo(report.reported_at)} + + ) : null} + + + Manage keys + + +
+
); } @@ -67,14 +199,5 @@ export function BotStatusSection() { refetchInterval: 30_000, }); - return ( - - {report ? : } - - ); + return ; }