Spaces:
Sleeping
Sleeping
| /** | |
| * 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; | |