Spaces:
Sleeping
Sleeping
| /** | |
| * DeployPanel.tsx — S744 + S-DEPLOY-UNIFY + S-AUTO | |
| * | |
| * S744: Trigger workflow_dispatch su cloudflare-deploy.yml, polling stato, | |
| * progress step-by-step, URL live, storico deploy. | |
| * S-DEPLOY-UNIFY: Griglia "Stato target" — CF Pages, Railway, HF Space. | |
| * S-AUTO: Auto-riparazione — diagnostica all'apertura + pulsante "Ripara automaticamente". | |
| * POST /api/deploy/auto gestisce tutte le situazioni senza intervento manuale. | |
| */ | |
| import { useState, useEffect, useCallback, useRef, memo } from "react"; | |
| import type * as React from "react"; | |
| import { | |
| X, Rocket, CheckCircle, XCircle, Clock, RefreshCw, | |
| ExternalLink, AlertCircle, Play, Server, Globe, Database, Wrench, Zap, | |
| } from "lucide-react"; | |
| import { Z_INDEX } from "@/lib/zindex"; | |
| import { getGHToken, getRepoConfig, ghTriggerWorkflow, ghGetWorkflowRuns } from "@/lib/github"; | |
| import { BACKEND_BASE, _BACKEND_TOKEN } from "@/lib/agentLoop/toolExecutor"; | |
| import type { GHWorkflowRun } from "@/lib/github"; | |
| import { useTaskStore } from "@/store/taskStore"; // GAP-2: auto-task su build failure | |
| interface DeployPanelProps { onClose: () => void; } | |
| const LIVE_URL = "https://agente-ai.pages.dev/"; | |
| const POLL_MS = 6_000; | |
| const STATUS_REFRESH_MS = 60_000; | |
| // ─── Tipi /api/deploy/status ────────────────────────────────────────────────── | |
| interface CFStatus { ok: boolean; status: string; url: string; last_deploy: string | null; deployment_id: string | null; error: string | null; } | |
| interface RailwayStatus { ok: boolean; status: string; url: string; latency_ms: number | null; error: string | null; } | |
| interface HFStatus { ok: boolean; status: string; url: string; sdk: string | null; error: string | null; } | |
| interface DeployTargets { cloudflare: CFStatus; railway: RailwayStatus; huggingface: HFStatus; } | |
| interface DeployStatus { targets: DeployTargets; checked_at: string; } | |
| // ─── Tipi /api/deploy/auto ──────────────────────────────────────────────────── | |
| interface AutoResult { | |
| ok: boolean; | |
| fixed: string[]; | |
| broken: string[]; | |
| actions: string[]; | |
| dry_run: boolean; | |
| message: string; | |
| elapsed: number; | |
| } | |
| // ─── Helpers ────────────────────────────────────────────────────────────────── | |
| function runIcon(run: GHWorkflowRun): string { | |
| if (run.status === "queued") return "🟡"; | |
| if (run.status === "in_progress") return "🔵"; | |
| if (run.conclusion === "success") return "✅"; | |
| if (run.conclusion === "failure") return "❌"; | |
| if (run.conclusion === "cancelled") return "⚪"; | |
| return "⏳"; | |
| } | |
| function runLabel(run: GHWorkflowRun): string { | |
| if (run.status === "queued") return "In coda…"; | |
| if (run.status === "in_progress") return "In esecuzione…"; | |
| if (run.conclusion === "success") return "Completato"; | |
| if (run.conclusion === "failure") return "Fallito"; | |
| if (run.conclusion === "cancelled") return "Annullato"; | |
| return run.status; | |
| } | |
| function timeAgo(iso: string): string { | |
| const ms = Date.now() - new Date(iso).getTime(); | |
| if (ms < 60_000) return `${Math.round(ms / 1000)}s fa`; | |
| if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m fa`; | |
| if (ms < 86_400_000) return `${Math.round(ms / 3_600_000)}h fa`; | |
| return `${Math.floor(ms / 86_400_000)}g fa`; | |
| } | |
| function backendFetch<T>(path: string, opts?: RequestInit): Promise<T | null> { | |
| if (!BACKEND_BASE) return Promise.resolve(null); | |
| return fetch(BACKEND_BASE + path, { | |
| ...opts, | |
| headers: { | |
| "Content-Type": "application/json", | |
| ...(_BACKEND_TOKEN ? { "X-Internal-Token": _BACKEND_TOKEN } : {}), | |
| ...(opts?.headers ?? {}), | |
| }, | |
| }).then(r => r.ok ? r.json() as Promise<T> : null).catch(() => null); | |
| } | |
| // ─── Stili ──────────────────────────────────────────────────────────────────── | |
| const actionBtn = (variant: "primary" | "secondary" | "success" | "danger" = "primary", disabled = false): React.CSSProperties => ({ | |
| all: "unset" as const, cursor: disabled ? "not-allowed" : "pointer", | |
| padding: "9px 18px", borderRadius: 10, fontSize: "0.83rem", fontWeight: 700, | |
| display: "flex", alignItems: "center", gap: 6, justifyContent: "center", | |
| opacity: disabled ? 0.4 : 1, | |
| background: | |
| variant === "primary" ? "rgba(99,102,241,0.22)" : | |
| variant === "success" ? "rgba(16,185,129,0.18)" : | |
| variant === "danger" ? "rgba(239,68,68,0.15)" : | |
| "rgba(255,255,255,0.06)", | |
| color: | |
| variant === "primary" ? "#818cf8" : | |
| variant === "success" ? "#10b981" : | |
| variant === "danger" ? "#f87171" : | |
| "#a0a0c0", | |
| border: `1px solid ${ | |
| variant === "primary" ? "rgba(99,102,241,0.38)" : | |
| variant === "success" ? "rgba(16,185,129,0.35)" : | |
| variant === "danger" ? "rgba(239,68,68,0.30)" : | |
| "rgba(255,255,255,0.10)" | |
| }`, | |
| touchAction: "manipulation", | |
| transition: "background 0.15s", | |
| }); | |
| const DEPLOY_STEPS = [ | |
| "Quality gate", "Check imports", "Check console.log", | |
| "Check circular imports", "Type check", "Unit tests", | |
| "Build", "Benchmark (≥95%)", "Deploy to Cloudflare", | |
| ]; | |
| function StatusDot({ ok, loading }: { ok: boolean | null; loading?: boolean }) { | |
| if (loading) return <div style={{ width: 8, height: 8, borderRadius: "50%", background: "#6366f1", animation: "mem-tab-pulse 1.2s ease-in-out infinite" }} />; | |
| return <div style={{ width: 8, height: 8, borderRadius: "50%", background: ok === null ? "#4a4a78" : ok ? "#10b981" : "#f87171", flexShrink: 0 }} />; | |
| } | |
| // ─── Componente principale ───────────────────────────────────────────────────── | |
| const DeployPanel = memo(function DeployPanel({ onClose }: DeployPanelProps) { | |
| const cfg = getRepoConfig(); | |
| const owner = cfg.owner || "Baida98"; | |
| const repo = cfg.repo || "AI"; | |
| const [runs, setRuns] = useState<GHWorkflowRun[]>([]); | |
| const [loading, setLoading] = useState(false); | |
| const [triggering, setTriggering] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [success, setSuccess] = useState<string | null>(null); | |
| const [lastLoad, setLastLoad] = useState(0); | |
| const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| const statusTimerRef = useRef<ReturnType<typeof setInterval> | null>(null); | |
| const repairTaskSentRef = useRef<string | null>(null); // GAP-2: evita task duplicati per lo stesso run | |
| // ── Stato target ───────────────────────────────────────────────────────────── | |
| const [deployStatus, setDeployStatus] = useState<DeployStatus | null>(null); | |
| const [statusLoading, setStatusLoading] = useState(false); | |
| // ── Auto-repair ─────────────────────────────────────────────────────────────── | |
| const [autoRunning, setAutoRunning] = useState(false); | |
| const [autoResult, setAutoResult] = useState<AutoResult | null>(null); | |
| const [autoActions, setAutoActions] = useState<string[]>([]); | |
| // ── Carica stato target ─────────────────────────────────────────────────────── | |
| const loadDeployStatus = useCallback(async () => { | |
| setStatusLoading(true); | |
| const res = await backendFetch<DeployStatus>("/api/deploy/status"); | |
| if (res) setDeployStatus(res); | |
| setStatusLoading(false); | |
| }, []); | |
| // ── Carica workflow runs ────────────────────────────────────────────────────── | |
| const loadRuns = useCallback(async (silent = false) => { | |
| if (!getGHToken()) return; | |
| if (!silent) setLoading(true); | |
| try { | |
| const r = await ghGetWorkflowRuns(owner, repo, "cloudflare-deploy.yml", 8); | |
| setRuns(r); setLastLoad(Date.now()); | |
| } catch (e) { if (!silent) setError((e as Error).message); } | |
| finally { if (!silent) setLoading(false); } | |
| }, [owner, repo]); | |
| // ── Boot ────────────────────────────────────────────────────────────────────── | |
| useEffect(() => { | |
| void loadRuns(); | |
| void loadDeployStatus(); | |
| statusTimerRef.current = setInterval(() => void loadDeployStatus(), STATUS_REFRESH_MS); | |
| return () => { | |
| if (pollRef.current) clearInterval(pollRef.current); | |
| if (statusTimerRef.current) clearInterval(statusTimerRef.current); | |
| }; | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []); | |
| // latestRun dichiarato qui per essere disponibile nel useEffect GAP-2 | |
| const latestRun = runs[0] ?? null; | |
| // ── GAP-2: auto-task quando deploy fallisce — chiude il loop di autonomia ────── | |
| useEffect(() => { | |
| if (!latestRun) return; | |
| const isFailed = latestRun.status !== 'queued' && latestRun.status !== 'in_progress' && latestRun.conclusion === 'failure'; | |
| if (!isFailed) return; | |
| if (repairTaskSentRef.current === String(latestRun.id)) return; // già inviato | |
| repairTaskSentRef.current = String(latestRun.id); | |
| useTaskStore.getState().startTask( | |
| `🔧 Deploy #${latestRun.id} fallito su GitHub Actions. Analizza i log CF Pages con cf_pages_status, individua gli errori TypeScript/build, applica i fix nei file coinvolti, fai git_push e monitora con deploy_and_verify. Logs: ${latestRun.html_url}` | |
| ); | |
| }, [latestRun]); | |
| // ── Polling auto durante run attivo ────────────────────────────────────────── | |
| useEffect(() => { | |
| const hasActive = runs.some(r => r.status === "queued" || r.status === "in_progress"); | |
| if (hasActive) { | |
| if (!pollRef.current) pollRef.current = setInterval(() => void loadRuns(true), POLL_MS); | |
| } else { | |
| if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } | |
| } | |
| }, [runs, loadRuns]); | |
| // ── Auto-riparazione ────────────────────────────────────────────────────────── | |
| const runAutoRepair = useCallback(async () => { | |
| setAutoRunning(true); | |
| setAutoResult(null); | |
| setAutoActions(["⏳ Diagnosi in corso…"]); | |
| setError(null); | |
| setSuccess(null); | |
| const result = await backendFetch<AutoResult>("/api/deploy/auto", { | |
| method: "POST", | |
| body: JSON.stringify({ targets: ["cloudflare", "railway", "huggingface"], dry_run: false }), | |
| }); | |
| if (result) { | |
| setAutoResult(result); | |
| setAutoActions(result.actions); | |
| if (result.ok) setSuccess(`Auto-riparazione completata: ${result.message} (${result.elapsed}s)`); | |
| else setError(`Alcuni target non risolti: ${result.broken.join(", ")}`); | |
| // Aggiorna lo stato target dopo la riparazione | |
| setTimeout(() => void loadDeployStatus(), 3_000); | |
| } else { | |
| setAutoActions(["❌ Backend non raggiungibile — verifica Railway"]); | |
| setError("Backend non raggiungibile. Assicurati che Railway sia attivo."); | |
| } | |
| setAutoRunning(false); | |
| }, [loadDeployStatus]); | |
| // ── Trigger deploy manuale ──────────────────────────────────────────────────── | |
| const triggerDeploy = useCallback(async () => { | |
| if (!getGHToken()) { setError("Configura il GitHub token (Impostazioni → GitHub)."); return; } | |
| setTriggering(true); setError(null); setSuccess(null); setAutoResult(null); | |
| try { | |
| const ok = await ghTriggerWorkflow(owner, repo, "cloudflare-deploy.yml", "main"); | |
| if (ok) { setSuccess("Deploy avviato! Polling ogni 6s…"); setTimeout(() => void loadRuns(), 3_000); } | |
| else setError("Workflow dispatch fallito. Verifica i permessi del token (scope: workflow)."); | |
| } catch (e) { setError(`Errore: ${(e as Error).message}`); } | |
| finally { setTriggering(false); } | |
| }, [owner, repo, loadRuns]); | |
| const triggerSmokeTest = useCallback(async () => { | |
| if (!getGHToken()) { setError("Configura il GitHub token."); return; } | |
| setTriggering(true); setError(null); | |
| try { | |
| await ghTriggerWorkflow(owner, repo, "smoke-test.yml", "main"); | |
| setSuccess("Smoke test avviato su GitHub Actions."); | |
| } catch (e) { setError(`Errore: ${(e as Error).message}`); } | |
| finally { setTriggering(false); } | |
| }, [owner, repo]); | |
| // ── Dati derivati ───────────────────────────────────────────────────────────── | |
| const isActive = latestRun && (latestRun.status === "queued" || latestRun.status === "in_progress"); | |
| const t = deployStatus?.targets; | |
| const checkedAgo = deployStatus ? timeAgo(deployStatus.checked_at) : null; | |
| const anyBroken = t ? (!t.cloudflare.ok || !t.railway.ok || !t.huggingface.ok) : false; | |
| const allGreen = t ? (t.cloudflare.ok && t.railway.ok && t.huggingface.ok) : false; | |
| return ( | |
| <div role="dialog" aria-modal="true" aria-label="Deploy" | |
| style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.MODAL, display: "flex", justifyContent: "flex-end" }}> | |
| <div onClick={onClose} style={{ flex: 1, background: "rgba(0,0,0,0.45)" }} /> | |
| <div style={{ | |
| width: "min(100vw, 420px)", height: "100%", overflowY: "auto", | |
| background: "rgba(6,6,18,0.97)", borderLeft: "1px solid rgba(99,102,241,0.18)", | |
| display: "flex", flexDirection: "column", | |
| paddingBottom: "env(safe-area-inset-bottom, 0px)", | |
| }}> | |
| {/* ── Header ──────────────────────────────────────────────────────────── */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 10, padding: "14px 16px", borderBottom: "1px solid rgba(99,102,241,0.12)", flexShrink: 0 }}> | |
| <Rocket size={18} color="#6366f1" /> | |
| <span style={{ flex: 1, fontWeight: 700, fontSize: "0.95rem", color: "#d0d0f0" }}>Deploy</span> | |
| <a href={LIVE_URL} target="_blank" rel="noopener noreferrer" | |
| style={{ display: "flex", alignItems: "center", gap: 5, fontSize: "0.72rem", color: "#6366f1", textDecoration: "none", padding: "5px 10px", background: "rgba(99,102,241,0.09)", borderRadius: 8, border: "1px solid rgba(99,102,241,0.20)" }}> | |
| <ExternalLink size={12} />Live | |
| </a> | |
| <button onClick={onClose} style={{ all: "unset", cursor: "pointer", color: "#4a4a78", padding: 6, touchAction: "manipulation" }}><X size={18} /></button> | |
| </div> | |
| <div style={{ flex: 1, overflowY: "auto", padding: "14px 16px", display: "flex", flexDirection: "column", gap: 12 }}> | |
| {/* ── Stato target ────────────────────────────────────────────────────── */} | |
| <div style={{ padding: "12px 14px", background: "rgba(255,255,255,0.02)", border: `1px solid ${allGreen ? "rgba(16,185,129,0.18)" : anyBroken ? "rgba(239,68,68,0.18)" : "rgba(99,102,241,0.12)"}`, borderRadius: 10 }}> | |
| <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| {allGreen | |
| ? <CheckCircle size={13} color="#10b981" /> | |
| : anyBroken | |
| ? <AlertCircle size={13} color="#f87171" /> | |
| : <Clock size={13} color="#6366f1" />} | |
| <span style={{ fontSize: "0.72rem", color: allGreen ? "#10b981" : anyBroken ? "#f87171" : "#4a4a78", fontWeight: 700, letterSpacing: "0.04em" }}> | |
| {allGreen ? "TUTTI I SISTEMI OK" : anyBroken ? "PROBLEMI RILEVATI" : "STATO TARGET"} | |
| </span> | |
| </div> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| {checkedAgo && <span style={{ fontSize: "0.63rem", color: "#3a3a6a" }}>{checkedAgo}</span>} | |
| <button onClick={() => void loadDeployStatus()} disabled={statusLoading} | |
| style={{ all: "unset", cursor: statusLoading ? "not-allowed" : "pointer", color: "#6366f1", padding: 4, opacity: statusLoading ? 0.5 : 1, touchAction: "manipulation" }}> | |
| <RefreshCw size={11} style={{ animation: statusLoading ? "spin 1s linear infinite" : "none" }} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* Griglia 3 target */} | |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 6, marginBottom: anyBroken ? 10 : 0 }}> | |
| {/* CF Pages */} | |
| <a href="https://agente-ai.pages.dev" target="_blank" rel="noopener noreferrer" | |
| style={{ display: "flex", flexDirection: "column", gap: 4, padding: "9px 10px", background: "rgba(0,0,0,0.15)", border: `1px solid ${t?.cloudflare.ok ? "rgba(16,185,129,0.22)" : t ? "rgba(239,68,68,0.22)" : "rgba(99,102,241,0.10)"}`, borderRadius: 8, textDecoration: "none" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 5 }}> | |
| <Globe size={11} color="#6366f1" /> | |
| <StatusDot ok={t ? t.cloudflare.ok : null} loading={statusLoading && !t} /> | |
| </div> | |
| <span style={{ fontSize: "0.68rem", color: "#8080b0", fontWeight: 700 }}>CF Pages</span> | |
| <span style={{ fontSize: "0.62rem", color: t?.cloudflare.ok ? "#10b981" : t?.cloudflare.error ? "#f87171" : "#4a4a78", lineHeight: 1.3, wordBreak: "break-word" }}> | |
| {statusLoading && !t ? "…" : t ? (t.cloudflare.ok ? "live ✓" : t.cloudflare.error ?? t.cloudflare.status) : "—"} | |
| </span> | |
| {t?.cloudflare.last_deploy && <span style={{ fontSize: "0.60rem", color: "#3a3a6a" }}>{timeAgo(t.cloudflare.last_deploy)}</span>} | |
| </a> | |
| {/* Railway */} | |
| <a href="https://ai-production-4c06.up.railway.app" target="_blank" rel="noopener noreferrer" | |
| style={{ display: "flex", flexDirection: "column", gap: 4, padding: "9px 10px", background: "rgba(0,0,0,0.15)", border: `1px solid ${t?.railway.ok ? "rgba(16,185,129,0.22)" : t ? "rgba(239,68,68,0.22)" : "rgba(99,102,241,0.10)"}`, borderRadius: 8, textDecoration: "none" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 5 }}> | |
| <Server size={11} color="#6366f1" /> | |
| <StatusDot ok={t ? t.railway.ok : null} loading={statusLoading && !t} /> | |
| </div> | |
| <span style={{ fontSize: "0.68rem", color: "#8080b0", fontWeight: 700 }}>Railway</span> | |
| <span style={{ fontSize: "0.62rem", color: t?.railway.ok ? "#10b981" : t?.railway.error ? "#f87171" : "#4a4a78", lineHeight: 1.3 }}> | |
| {statusLoading && !t ? "…" : t ? (t.railway.ok ? `up ✓${t.railway.latency_ms != null ? ` · ${t.railway.latency_ms}ms` : ""}` : t.railway.error ?? t.railway.status) : "—"} | |
| </span> | |
| </a> | |
| {/* HF Space */} | |
| <a href="https://huggingface.co/spaces/Arjanit98/Terminal" target="_blank" rel="noopener noreferrer" | |
| style={{ display: "flex", flexDirection: "column", gap: 4, padding: "9px 10px", background: "rgba(0,0,0,0.15)", border: `1px solid ${t?.huggingface.ok ? "rgba(16,185,129,0.22)" : t ? "rgba(239,68,68,0.22)" : "rgba(99,102,241,0.10)"}`, borderRadius: 8, textDecoration: "none" }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 5 }}> | |
| <Database size={11} color="#6366f1" /> | |
| <StatusDot ok={t ? t.huggingface.ok : null} loading={statusLoading && !t} /> | |
| </div> | |
| <span style={{ fontSize: "0.68rem", color: "#8080b0", fontWeight: 700 }}>HF Space</span> | |
| <span style={{ fontSize: "0.62rem", color: t?.huggingface.ok ? "#10b981" : t?.huggingface.error ? "#f87171" : "#4a4a78", lineHeight: 1.3 }}> | |
| {statusLoading && !t ? "…" : t ? (t.huggingface.ok ? "running ✓" : t.huggingface.error ?? t.huggingface.status) : "—"} | |
| </span> | |
| </a> | |
| </div> | |
| {/* Pulsante auto-riparazione — compare solo se ci sono problemi */} | |
| {anyBroken && !autoRunning && !autoResult && ( | |
| <button onClick={() => void runAutoRepair()} style={actionBtn("danger", false)}> | |
| <Wrench size={14} />Ripara automaticamente | |
| </button> | |
| )} | |
| {allGreen && !autoRunning && ( | |
| <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "7px 10px", background: "rgba(16,185,129,0.08)", borderRadius: 8, border: "1px solid rgba(16,185,129,0.18)" }}> | |
| <Zap size={12} color="#10b981" /> | |
| <span style={{ fontSize: "0.72rem", color: "#10b981" }}>Tutti i sistemi operativi — nessuna azione necessaria</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* ── Risultato auto-riparazione ──────────────────────────────────────── */} | |
| {(autoRunning || autoActions.length > 0) && ( | |
| <div style={{ padding: "12px 14px", background: "rgba(99,102,241,0.06)", border: `1px solid ${autoResult?.ok === false ? "rgba(239,68,68,0.22)" : "rgba(99,102,241,0.20)"}`, borderRadius: 10 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}> | |
| <Wrench size={13} color={autoResult?.ok === false ? "#f87171" : "#6366f1"} style={{ animation: autoRunning ? "spin 1.5s linear infinite" : "none" }} /> | |
| <span style={{ fontSize: "0.75rem", fontWeight: 700, color: autoResult?.ok === false ? "#f87171" : "#818cf8" }}> | |
| {autoRunning ? "Riparazione in corso…" : autoResult?.ok ? "Riparazione completata ✓" : "Riparazione parziale"} | |
| </span> | |
| {autoResult && ( | |
| <span style={{ fontSize: "0.65rem", color: "#4a4a78", marginLeft: "auto" }}>{autoResult.elapsed}s</span> | |
| )} | |
| </div> | |
| <div style={{ display: "flex", flexDirection: "column", gap: 4 }}> | |
| {autoActions.map((action, i) => ( | |
| <div key={i} style={{ display: "flex", gap: 7, alignItems: "flex-start" }}> | |
| <span style={{ fontSize: "0.7rem", color: action.startsWith("✅") ? "#10b981" : action.startsWith("❌") ? "#f87171" : action.startsWith("⚠") ? "#fbbf24" : "#6366f1", flexShrink: 0, marginTop: 1 }}> | |
| {action.startsWith("✅") || action.startsWith("❌") || action.startsWith("⚠") || action.startsWith("ℹ") ? "" : "›"} | |
| </span> | |
| <span style={{ fontSize: "0.7rem", color: action.startsWith("✅") ? "#10b981" : action.startsWith("❌") ? "#f87171" : action.startsWith("⚠") ? "#fbbf24" : "#a0a0c0", lineHeight: 1.4 }}>{action}</span> | |
| </div> | |
| ))} | |
| </div> | |
| {autoResult && !autoRunning && ( | |
| <button onClick={() => { setAutoResult(null); setAutoActions([]); }} style={{ ...actionBtn("secondary"), marginTop: 10, fontSize: "0.7rem", padding: "6px 12px" }}> | |
| Chiudi | |
| </button> | |
| )} | |
| </div> | |
| )} | |
| {/* ── Error / Success ──────────────────────────────────────────────────── */} | |
| {error && ( | |
| <div style={{ padding: "9px 13px", background: "rgba(239,68,68,0.10)", border: "1px solid rgba(239,68,68,0.28)", borderRadius: 8, display: "flex", gap: 8, alignItems: "flex-start" }}> | |
| <AlertCircle size={15} color="#f87171" style={{ flexShrink: 0, marginTop: 1 }} /> | |
| <span style={{ fontSize: "0.78rem", color: "#f87171", flex: 1, lineHeight: 1.5 }}>{error}</span> | |
| <button onClick={() => setError(null)} style={{ all: "unset", cursor: "pointer", color: "#f87171" }}><X size={13} /></button> | |
| </div> | |
| )} | |
| {success && !error && ( | |
| <div style={{ padding: "9px 13px", background: "rgba(16,185,129,0.10)", border: "1px solid rgba(16,185,129,0.25)", borderRadius: 8, display: "flex", gap: 8, alignItems: "center" }}> | |
| <CheckCircle size={14} color="#10b981" /> | |
| <span style={{ fontSize: "0.78rem", color: "#10b981", flex: 1 }}>{success}</span> | |
| <button onClick={() => setSuccess(null)} style={{ all: "unset", cursor: "pointer", color: "#10b981" }}><X size={13} /></button> | |
| </div> | |
| )} | |
| {/* ── Run attivo ───────────────────────────────────────────────────────── */} | |
| {latestRun && isActive && ( | |
| <div style={{ padding: "14px 16px", background: "rgba(99,102,241,0.08)", border: "1px solid rgba(99,102,241,0.25)", borderRadius: 12 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 10 }}> | |
| <span style={{ fontSize: "1.1em" }}>{runIcon(latestRun)}</span> | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontSize: "0.85rem", fontWeight: 700, color: "#d0d0f0" }}>Deploy in corso</div> | |
| <div style={{ fontSize: "0.7rem", color: "#6366f1" }}>{runLabel(latestRun)} — {timeAgo(latestRun.created_at)}</div> | |
| </div> | |
| <div style={{ width: 8, height: 8, borderRadius: "50%", background: "#6366f1", animation: "mem-tab-pulse 1.2s ease-in-out infinite" }} /> | |
| </div> | |
| <div style={{ display: "flex", flexDirection: "column", gap: 4 }}> | |
| {DEPLOY_STEPS.map((step, i) => ( | |
| <div key={step} style={{ display: "flex", alignItems: "center", gap: 7, opacity: i === 0 ? 1 : 0.45 }}> | |
| <div style={{ width: 5, height: 5, borderRadius: "50%", background: i === 0 ? "#6366f1" : "rgba(99,102,241,0.2)", flexShrink: 0 }} /> | |
| <span style={{ fontSize: "0.71rem", color: i === 0 ? "#818cf8" : "#3a3a6a" }}>{step}</span> | |
| </div> | |
| ))} | |
| </div> | |
| <a href={latestRun.html_url} target="_blank" rel="noopener noreferrer" style={{ display: "block", marginTop: 10, fontSize: "0.71rem", color: "#6366f1", textDecoration: "none" }}>Vedi su GitHub Actions →</a> | |
| </div> | |
| )} | |
| {/* ── Ultimo deploy ────────────────────────────────────────────────────── */} | |
| {latestRun && !isActive && ( | |
| <div style={{ padding: "11px 14px", background: latestRun.conclusion === "success" ? "rgba(16,185,129,0.07)" : "rgba(239,68,68,0.07)", border: `1px solid ${latestRun.conclusion === "success" ? "rgba(16,185,129,0.22)" : "rgba(239,68,68,0.22)"}`, borderRadius: 10 }}> | |
| <div style={{ display: "flex", alignItems: "center", gap: 8 }}> | |
| {latestRun.conclusion === "success" ? <CheckCircle size={15} color="#10b981" /> : <XCircle size={15} color="#f87171" />} | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontSize: "0.83rem", fontWeight: 700, color: latestRun.conclusion === "success" ? "#10b981" : "#f87171" }}> | |
| {latestRun.conclusion === "success" ? "Ultimo deploy OK" : "Ultimo deploy fallito"} | |
| </div> | |
| <div style={{ fontSize: "0.7rem", color: "#4a4a78" }}>{timeAgo(latestRun.updated_at)}</div> | |
| </div> | |
| {latestRun.conclusion === "success" && ( | |
| <a href={LIVE_URL} target="_blank" rel="noopener noreferrer" style={{ fontSize: "0.71rem", color: "#10b981", textDecoration: "none", padding: "4px 10px", background: "rgba(16,185,129,0.12)", borderRadius: 8, border: "1px solid rgba(16,185,129,0.25)" }}>Vai al sito</a> | |
| )} | |
| </div> | |
| {latestRun.conclusion !== "success" && ( | |
| <a href={latestRun.html_url} target="_blank" rel="noopener noreferrer" style={{ display: "block", marginTop: 7, fontSize: "0.71rem", color: "#f87171", textDecoration: "none" }}>Vedi logs su GitHub Actions →</a> | |
| )} | |
| </div> | |
| )} | |
| {/* ── Azioni manuali ───────────────────────────────────────────────────── */} | |
| <div style={{ display: "flex", flexDirection: "column", gap: 8 }}> | |
| <button onClick={() => void runAutoRepair()} disabled={autoRunning} style={actionBtn("primary", autoRunning)}> | |
| {autoRunning ? <><RefreshCw size={14} style={{ animation: "spin 1s linear infinite" }} />Riparazione in corso…</> : <><Zap size={14} />Controlla e ripara tutto</>} | |
| </button> | |
| <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}> | |
| <button onClick={() => void triggerDeploy()} disabled={triggering || !!isActive} style={actionBtn("secondary", triggering || !!isActive)}> | |
| <Rocket size={13} />{triggering ? "Avvio…" : "Deploy CF"} | |
| </button> | |
| <button onClick={() => void triggerSmokeTest()} disabled={triggering} style={actionBtn("secondary", triggering)}> | |
| <Play size={13} />Smoke Test | |
| </button> | |
| </div> | |
| </div> | |
| {/* ── Info pipeline ────────────────────────────────────────────────────── */} | |
| <div style={{ padding: "10px 14px", background: "rgba(255,255,255,0.02)", border: "1px solid rgba(99,102,241,0.08)", borderRadius: 9 }}> | |
| <p style={{ margin: "0 0 6px", fontSize: "0.70rem", color: "#3a3a6a", fontWeight: 700, letterSpacing: "0.06em" }}>PIPELINE CF PAGES</p> | |
| {DEPLOY_STEPS.map(s => ( | |
| <div key={s} style={{ display: "flex", alignItems: "center", gap: 5, marginBottom: 3 }}> | |
| <div style={{ width: 3, height: 3, borderRadius: "50%", background: "rgba(99,102,241,0.3)", flexShrink: 0 }} /> | |
| <span style={{ fontSize: "0.69rem", color: "#3a3a6a" }}>{s}</span> | |
| </div> | |
| ))} | |
| </div> | |
| {/* ── Storico runs ──────────────────────────────────────────────────────── */} | |
| <div> | |
| <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}> | |
| <p style={{ margin: 0, fontSize: "0.70rem", color: "#3a3a6a", fontWeight: 700, letterSpacing: "0.06em" }}>STORICO DEPLOY</p> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| {lastLoad > 0 && <span style={{ fontSize: "0.63rem", color: "#3a3a6a" }}>{timeAgo(new Date(lastLoad).toISOString())}</span>} | |
| <button onClick={() => void loadRuns()} disabled={loading} | |
| style={{ all: "unset", cursor: loading ? "not-allowed" : "pointer", color: "#6366f1", padding: 4, opacity: loading ? 0.5 : 1, touchAction: "manipulation" }}> | |
| <RefreshCw size={12} style={{ animation: loading ? "spin 1s linear infinite" : "none" }} /> | |
| </button> | |
| </div> | |
| </div> | |
| {!getGHToken() && <p style={{ color: "#3a3a6a", fontSize: "0.78rem", textAlign: "center", padding: "12px 0" }}>Configura il GitHub token per vedere lo storico.</p>} | |
| {getGHToken() && runs.length === 0 && !loading && <p style={{ color: "#3a3a6a", fontSize: "0.78rem", textAlign: "center", padding: "12px 0" }}>Nessun deploy trovato.</p>} | |
| <div style={{ display: "flex", flexDirection: "column", gap: 5 }}> | |
| {runs.map(r => ( | |
| <a key={r.id} href={r.html_url} target="_blank" rel="noopener noreferrer" | |
| style={{ display: "flex", alignItems: "center", gap: 8, padding: "9px 12px", background: "rgba(255,255,255,0.02)", border: "1px solid rgba(99,102,241,0.09)", borderRadius: 8, textDecoration: "none" }}> | |
| <span style={{ fontSize: "0.88em", flexShrink: 0 }}>{runIcon(r)}</span> | |
| <div style={{ flex: 1, minWidth: 0 }}> | |
| <div style={{ fontSize: "0.74rem", color: "#c0c0e0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{runLabel(r)} — #{r.id}</div> | |
| <div style={{ fontSize: "0.66rem", color: "#3a3a6a", marginTop: 1 }}>{timeAgo(r.created_at)}</div> | |
| </div> | |
| <ExternalLink size={11} color="#4a4a78" /> | |
| </a> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }); | |
| export default DeployPanel; | |