AUDIT / src /components /AdminTasksPanel.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
12.7 kB
/**
* AdminTasksPanel.tsx — S370: pannello admin task agent
*
* Polling automatico GET /api/agent/tasks ogni 3s.
* Mostra status badge colorato, isLive indicator, goal troncato, età.
* Pulsante cancel per task attivi. Filtro per status. Shift+A per toggle.
*/
import { useCallback, useEffect, useRef, useState, memo } from "react";
import type * as React from "react"; // FIX11: React namespace types
import { X, RefreshCw, Circle, Loader2, CheckCircle2, XCircle, AlertCircle, Ban, Clock } from "lucide-react";
import { backend, type AgentTaskRow } from "@/lib/backendClient";
import { Z_INDEX } from "@/lib/zindex";
import { makeTimedSignal } from "@/lib/agentLoop/networkConstants"; // Loop-12: iOS-safe
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtAge(ms: number): string {
if (ms < 0) return "–";
if (ms < 60_000) return `${Math.round(ms / 1000)}s fa`;
if (ms < 3600_000) return `${Math.round(ms / 60_000)}m fa`;
return `${Math.round(ms / 3600_000)}h fa`;
}
type StatusKey = "QUEUED" | "RUNNING" | "SUCCESS" | "ERROR" | "CANCELLED" | "TIMEOUT" | string;
const STATUS_CFG: Record<StatusKey, { bg: string; fg: string; label: string }> = {
QUEUED: { bg: "#1a2a4a", fg: "#6ab3ff", label: "in coda" },
RUNNING: { bg: "#1a3a2a", fg: "#4ade80", label: "running" },
SUCCESS: { bg: "#1a3a2a", fg: "#4ade80", label: "ok" },
ERROR: { bg: "#3a1a1a", fg: "#f87171", label: "errore" },
CANCELLED: { bg: "#2a2a2a", fg: "#a0a0b0", label: "annullato" },
TIMEOUT: { bg: "#3a2a1a", fg: "#fb923c", label: "timeout" },
UNKNOWN: { bg: "#2a2a2a", fg: "#888", label: "unknown" },
};
function StatusBadge({ status }: { status: string }) {
const cfg = STATUS_CFG[status] ?? STATUS_CFG.UNKNOWN;
return (
<span style={{
fontSize: "0.6rem", fontWeight: 700, padding: "2px 6px",
borderRadius: 8, background: cfg.bg, color: cfg.fg,
textTransform: "uppercase", letterSpacing: "0.06em", whiteSpace: "nowrap",
}}>
{cfg.label}
</span>
);
}
function StatusIcon({ status, isLive }: { status: string; isLive: boolean }) {
const s: React.CSSProperties = { flexShrink: 0 };
if (isLive || status === "RUNNING") return <Loader2 size={13} style={{ ...s, color: "#4ade80", animation: "spin 1s linear infinite" }} />;
if (status === "SUCCESS") return <CheckCircle2 size={13} style={{ ...s, color: "#4ade80" }} />;
if (status === "ERROR") return <XCircle size={13} style={{ ...s, color: "#f87171" }} />;
if (status === "CANCELLED")return <Ban size={13} style={{ ...s, color: "#a0a0b0" }} />;
if (status === "TIMEOUT") return <AlertCircle size={13} style={{ ...s, color: "#fb923c" }} />;
if (status === "QUEUED") return <Clock size={13} style={{ ...s, color: "#6ab3ff" }} />;
return <Circle size={13} style={{ ...s, color: "#555" }} />;
}
// ─── Main component ───────────────────────────────────────────────────────────
interface AdminTasksPanelProps {
onClose: () => void;
}
const AdminTasksPanel = memo(function AdminTasksPanel({ onClose }: AdminTasksPanelProps) {
const [tasks, setTasks] = useState<AgentTaskRow[]>([]);
const [meta, setMeta] = useState({ count: 0, memory: 0, supabase: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState<string>("");
const [lastRefresh,setLastRefresh]= useState<number>(0);
const cancellingRef = useRef<Set<string>>(new Set());
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchTasks = useCallback(async () => {
if (!backend.isAvailable()) {
setError("Backend non configurato");
setLoading(false);
return;
}
try {
const res = await (backend as unknown as {
listAgentTasks: (limit?: number, status?: string) => Promise<{ count: number; memory: number; supabase: number; tasks: AgentTaskRow[] }>;
}).listAgentTasks(100, filter || undefined);
setTasks(res.tasks ?? []);
setMeta({ count: res.count ?? 0, memory: res.memory ?? 0, supabase: res.supabase ?? 0 });
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : "Errore fetch task");
} finally {
setLoading(false);
setLastRefresh(Date.now());
}
}, [filter]);
useEffect(() => {
fetchTasks();
intervalRef.current = setInterval(fetchTasks, 3_000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
}, [fetchTasks]);
const handleCancel = useCallback(async (taskId: string) => {
if (cancellingRef.current.has(taskId)) return;
cancellingRef.current.add(taskId);
try {
await fetch(`${import.meta.env.VITE_BACKEND_URL ?? ""}/api/agent/tasks/${taskId}`, {
method: "DELETE",
headers: { "Content-Type": "application/json", ...((import.meta.env.VITE_INTERNAL_TOKEN ?? "") ? { "X-Internal-Token": import.meta.env.VITE_INTERNAL_TOKEN } : {}) },
signal: makeTimedSignal(8_000),
});
await fetchTasks();
} catch { /* silent */ }
finally { cancellingRef.current.delete(taskId); }
}, [fetchTasks]);
const displayed = filter
? tasks.filter(t => t.status === filter.toUpperCase())
: tasks;
const liveCount = tasks.filter(t => t.isLive || t.status === "RUNNING").length;
return (
<div style={{
position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL_OVERLAY,
background: "rgba(0,0,0,0.75)",
display: "flex", alignItems: "flex-start", justifyContent: "flex-end",
padding: "48px 12px 12px",
}} onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
<div style={{
width: "100%", maxWidth: 560, maxHeight: "calc(var(--vh, 100dvh) - 64px)", // S761-GAP5: dvh esclude barra Safari
display: "flex", flexDirection: "column",
background: "#0f0f1e", border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 14, overflow: "hidden",
boxShadow: "0 24px 60px rgba(0,0,0,0.7)",
}}>
{/* Header */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "12px 14px", borderBottom: "1px solid rgba(255,255,255,0.07)",
flexShrink: 0,
}}>
<span style={{ fontWeight: 700, fontSize: "0.85rem", color: "#c0c0e0", flex: 1 }}>
Admin Tasks
</span>
{liveCount > 0 && (
<span style={{
fontSize: "0.6rem", fontWeight: 700, padding: "2px 7px",
borderRadius: 20, background: "rgba(74,222,128,0.18)",
color: "#4ade80", border: "1px solid rgba(74,222,128,0.3)",
animation: "pulse 2s ease-in-out infinite",
}}>
{liveCount} LIVE
</span>
)}
<span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace" }}>
mem:{meta.memory} sb:{meta.supabase}
</span>
<button onClick={fetchTasks} title="Aggiorna ora" style={{
background: "none", border: "none", cursor: "pointer",
color: "#555", padding: 4, borderRadius: 8, display: "flex",
}}>
<RefreshCw size={13} />
</button>
<button onClick={onClose} style={{
background: "none", border: "none", cursor: "pointer",
color: "#555", padding: 4, borderRadius: 8, display: "flex",
}}>
<X size={15} />
</button>
</div>
{/* Filter bar */}
<div style={{
display: "flex", gap: 6, padding: "8px 14px",
borderBottom: "1px solid rgba(255,255,255,0.05)",
flexShrink: 0, overflowX: "auto",
}}>
{["", "RUNNING", "QUEUED", "SUCCESS", "ERROR", "CANCELLED"].map(f => (
<button key={f} onClick={() => setFilter(f)} style={{
fontSize: "0.65rem", fontWeight: 600,
padding: "2px 8px", borderRadius: 20,
border: `1px solid ${filter === f ? "rgba(99,102,241,0.6)" : "rgba(255,255,255,0.08)"}`,
background: filter === f ? "rgba(99,102,241,0.18)" : "transparent",
color: filter === f ? "#a5b4fc" : "#666",
cursor: "pointer", whiteSpace: "nowrap",
}}>
{f || "Tutti"}
</button>
))}
</div>
{/* Body */}
<div style={{ flex: 1, overflowY: "auto", padding: "6px 0" }}>
{loading && (
<div style={{ padding: "20px 14px", color: "#555", fontSize: "0.8rem", textAlign: "center" }}>
Caricamento…
</div>
)}
{!loading && error && (
<div style={{ padding: "14px", color: "#f87171", fontSize: "0.78rem" }}>
⚠ {error}
</div>
)}
{!loading && !error && displayed.length === 0 && (
<div style={{ padding: "24px 14px", color: "#444", fontSize: "0.8rem", textAlign: "center" }}>
Nessun task{filter ? ` con status ${filter}` : " trovato"}
</div>
)}
{displayed.map(task => (
<div key={task.taskId} style={{
display: "flex", alignItems: "flex-start", gap: 10,
padding: "9px 14px",
borderBottom: "1px solid rgba(255,255,255,0.04)",
transition: "background 0.12s",
}}
onMouseEnter={e => { (e.currentTarget as HTMLDivElement).style.background = "rgba(255,255,255,0.03)"; }}
onMouseLeave={e => { (e.currentTarget as HTMLDivElement).style.background = "transparent"; }}
>
<StatusIcon status={task.status} isLive={task.isLive} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: "0.78rem", color: "#c0c0e0",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
lineHeight: 1.35,
}}>
{task.goal || "(no goal)"}
</div>
<div style={{ display: "flex", gap: 6, marginTop: 3, alignItems: "center", flexWrap: "wrap" }}>
<StatusBadge status={task.status} />
<span style={{ fontSize: "0.62rem", color: "#444" }}>
{fmtAge(task.ageMs)}
</span>
<span style={{
fontSize: "0.6rem", color: "#333", fontFamily: "monospace",
overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", maxWidth: 120,
}}>
{task.taskId.slice(0, 12)}…
</span>
{task.source === "supabase" && (
<span style={{ fontSize: "0.58rem", color: "#2d5a2d" }}>sb</span>
)}
</div>
</div>
{(task.isLive || task.status === "RUNNING" || task.status === "QUEUED") && (
<button onClick={() => handleCancel(task.taskId)} title="Cancella task" style={{
background: "rgba(239,68,68,0.12)", border: "1px solid rgba(239,68,68,0.25)",
color: "#f87171", borderRadius: 8, cursor: "pointer",
fontSize: "0.65rem", padding: "2px 7px", whiteSpace: "nowrap", flexShrink: 0,
}}>
Stop
</button>
)}
</div>
))}
</div>
{/* Footer */}
<div style={{
padding: "7px 14px", borderTop: "1px solid rgba(255,255,255,0.05)",
display: "flex", alignItems: "center", justifyContent: "space-between",
flexShrink: 0,
}}>
<span style={{ fontSize: "0.65rem", color: "#333" }}>
{lastRefresh > 0 ? `agg. ${fmtAge(Date.now() - lastRefresh)}` : ""}
</span>
<span style={{ fontSize: "0.65rem", color: "#333" }}>
Shift+A per toggle · polling 3s
</span>
</div>
</div>
{/* spin keyframe */}
<style>{`
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.55; } }
`}</style>
</div>
);
});
export default AdminTasksPanel;