|
1 | 1 | import { useEffect, useState } from "react"; |
2 | | -import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction } from "../api.ts"; |
| 2 | +import { api, type PolicyDoc, type CedarPolicyEntry, type OPAPolicyDoc, type PIIAction, type SrsHealth } from "../api.ts"; |
3 | 3 | import { useAuth } from "../context/AuthContext.tsx"; |
4 | 4 |
|
5 | 5 | /** |
@@ -49,6 +49,7 @@ export function PoliciesPage() { |
49 | 49 | <div> |
50 | 50 | <div className="text-base font-semibold">Policies</div> |
51 | 51 | <div className="text-[11px] text-gray-500">SRS-managed · Cedar + OPA</div> |
| 52 | + <SrsStatus /> |
52 | 53 | </div> |
53 | 54 | {canWrite && ( |
54 | 55 | <button |
@@ -79,7 +80,9 @@ export function PoliciesPage() { |
79 | 80 | > |
80 | 81 | <div className="flex items-center gap-2"> |
81 | 82 | <span className="text-sm font-medium truncate">{p.name}</span> |
82 | | - <span className="ml-auto text-[10px] text-gray-600 font-mono">{p._id.slice(-6)}</span> |
| 83 | + <span className="ml-auto" onClick={(e) => e.stopPropagation()}> |
| 84 | + <CopyableId id={p._id} short /> |
| 85 | + </span> |
83 | 86 | </div> |
84 | 87 | <div className="text-[11px] text-gray-500 mt-0.5 truncate">{p.description || <em>no description</em>}</div> |
85 | 88 | <div className="text-[10px] text-accent-soft mt-1 flex gap-2"> |
@@ -260,9 +263,7 @@ function PolicyEditor({ |
260 | 263 | <div className="p-6 max-w-3xl"> |
261 | 264 | <div className="flex items-center justify-between mb-5"> |
262 | 265 | <h2 className="text-lg font-semibold">{initial ? "Edit policy" : "Create policy"}</h2> |
263 | | - {initial && ( |
264 | | - <div className="text-[11px] text-gray-500 font-mono">{initial._id}</div> |
265 | | - )} |
| 266 | + {initial && <CopyableId id={initial._id} />} |
266 | 267 | </div> |
267 | 268 | {err && <div className="mb-3 text-sm text-red-400">{err}</div>} |
268 | 269 |
|
@@ -645,6 +646,92 @@ function RegoPoliciesModal({ |
645 | 646 | ); |
646 | 647 | } |
647 | 648 |
|
| 649 | +/** |
| 650 | + * SRS connectivity pill. Polls `/srs/health` on mount, on every refresh, and |
| 651 | + * every 30s — both signals are measured from agentos-server (the browser has |
| 652 | + * no route to SRS): raw reachability + the authenticated proxy path the |
| 653 | + * Policies UI itself uses. Click to re-check immediately. |
| 654 | + */ |
| 655 | +function SrsStatus() { |
| 656 | + const [health, setHealth] = useState<SrsHealth | null>(null); |
| 657 | + const [agentosUp, setAgentosUp] = useState<boolean | null>(null); // the fetch itself = SPA → agentos |
| 658 | + const [checking, setChecking] = useState(false); |
| 659 | + |
| 660 | + const check = () => { |
| 661 | + setChecking(true); |
| 662 | + api.srsHealth() |
| 663 | + .then((h) => { setHealth(h); setAgentosUp(true); }) |
| 664 | + .catch(() => { setHealth(null); setAgentosUp(false); }) |
| 665 | + .finally(() => setChecking(false)); |
| 666 | + }; |
| 667 | + useEffect(() => { |
| 668 | + check(); |
| 669 | + const t = setInterval(check, 30_000); |
| 670 | + return () => clearInterval(t); |
| 671 | + }, []); |
| 672 | + |
| 673 | + const probeState = (p: { configured: boolean; reachable: boolean; authenticated: boolean; latencyMs: number | null; error?: string } | null | undefined, fallback: string) => { |
| 674 | + if (!p) return { dot: "bg-gray-500", text: fallback }; |
| 675 | + if (!p.configured) return { dot: "bg-gray-500", text: "not configured" }; |
| 676 | + if (p.authenticated) return { dot: "bg-emerald-500", text: `connected · ${p.latencyMs}ms` }; |
| 677 | + if (p.reachable) return { dot: "bg-amber-500", text: "reachable, auth failing" }; |
| 678 | + return { dot: "bg-red-500", text: "unreachable" }; |
| 679 | + }; |
| 680 | + |
| 681 | + const rows: Array<{ label: string; dot: string; text: string; title?: string }> = [ |
| 682 | + agentosUp === false |
| 683 | + ? { label: "AgentOS", dot: "bg-red-500", text: "unreachable" } |
| 684 | + : { label: "AgentOS", dot: agentosUp ? "bg-emerald-500" : "bg-gray-500", text: agentosUp ? "connected" : "checking…" }, |
| 685 | + { label: "AgentOS → SRS", ...probeState(health?.agentos, "checking…"), title: health?.agentos?.error }, |
| 686 | + health && !health.cas.reachable |
| 687 | + ? { label: "CAS → SRS", dot: "bg-red-500", text: "CAS unreachable", title: health.cas.error } |
| 688 | + : { label: "CAS → SRS", ...probeState(health?.cas.srs, health ? "no /srs/health (old CAS?)" : "checking…"), title: health?.cas.error ?? health?.cas.srs?.error }, |
| 689 | + ]; |
| 690 | + |
| 691 | + return ( |
| 692 | + <button |
| 693 | + type="button" |
| 694 | + onClick={check} |
| 695 | + title="Click to re-check connectivity" |
| 696 | + className={`mt-1.5 block text-left space-y-0.5 ${checking ? "opacity-60" : ""}`} |
| 697 | + > |
| 698 | + {rows.map((r) => ( |
| 699 | + <span key={r.label} title={r.title} className="flex items-center gap-1.5 text-[10px] text-gray-500 hover:text-gray-300"> |
| 700 | + <span className={`h-1.5 w-1.5 rounded-full ${r.dot}`} /> |
| 701 | + <span className="w-24 text-left">{r.label}</span> |
| 702 | + <span>{r.text}</span> |
| 703 | + </span> |
| 704 | + ))} |
| 705 | + </button> |
| 706 | + ); |
| 707 | +} |
| 708 | + |
| 709 | +/** |
| 710 | + * Click-to-copy policy id. Shown wherever a policy surfaces so users can paste |
| 711 | + * the id straight into the SDK (`ComputerAgent(policy_id="…")`). `short` shows |
| 712 | + * the id's tail in tight rows; the copied value is always the full id. |
| 713 | + */ |
| 714 | +export function CopyableId({ id, short = false }: { id: string; short?: boolean }) { |
| 715 | + const [copied, setCopied] = useState(false); |
| 716 | + const copy = async () => { |
| 717 | + try { |
| 718 | + await navigator.clipboard.writeText(id); |
| 719 | + setCopied(true); |
| 720 | + setTimeout(() => setCopied(false), 1200); |
| 721 | + } catch { /* clipboard unavailable (http) — ignore */ } |
| 722 | + }; |
| 723 | + return ( |
| 724 | + <button |
| 725 | + type="button" |
| 726 | + onClick={copy} |
| 727 | + title={`Copy policy id: ${id}`} |
| 728 | + className="text-[10px] text-gray-500 hover:text-accent font-mono inline-flex items-center gap-1" |
| 729 | + > |
| 730 | + {copied ? "copied ✓" : short ? `…${id.slice(-6)} ⧉` : `${id} ⧉`} |
| 731 | + </button> |
| 732 | + ); |
| 733 | +} |
| 734 | + |
648 | 735 | function Section({ title, children, right }: { title: string; children: React.ReactNode; right?: React.ReactNode }) { |
649 | 736 | return ( |
650 | 737 | <div className="mb-5 rounded-lg border border-ink-600 bg-ink-800 p-4"> |
|
0 commit comments