import type { PiWebPlugin, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api"; import type { Job, JobDetail, JobView, Overview, Run, RunSummary } from "./protocol.js"; type Tab = "jobs" | "history"; interface Editor { mode: "create" | "update"; text: string; error?: string } interface Rescheduler { id: string; kind: "cron" | "at" | "every"; value: string; tz: string; preview: string[]; error?: string } interface PanelState { tab: Tab; scopeAll: boolean; overview?: Overview; detail?: JobDetail; run?: Run; editor?: Editor; resched?: Rescheduler; busy: boolean; status: string; error?: string; channel?: { close(reason?: string): void }; loaded: boolean; refreshTimer?: ReturnType; } const STATUS_COLORS: Record = { succeeded: "#2e7d32", failed: "#c62828", running: "#1565c0", unmet: "#ef6c00", skipped: "#757575", cancelled: "#6d4c41", interrupted: "#8e24aa", }; const TEMPLATE = { name: "Esempio - controllo VM", description: "Verifica CPU VM alle 23:30 e analizza solo se in sofferenza", schedule: { cron: "30 23 * * *", tz: "Europe/Rome" }, misfire: "skip", timeout: "20m", steps: [ { id: "metrics", type: "command", argv: ["az", "monitor", "metrics", "list", "--resource", "", "--metric", "Percentage CPU", "--interval", "PT5M", "--offset", "1h", "-o", "json"], parseJson: true }, { id: "gate", type: "condition", any: [{ path: "steps.metrics.json.value[*].timeseries[*].data[*].average", aggregate: "max", op: ">", value: 85 }] }, { id: "analyze", type: "agent", when: "gate", prompt: "La VM è in sofferenza. Dati: {{steps.metrics.stdout}}. Analizza cause e proponi azioni.", tools: ["read", "bash"] }, { id: "alert", type: "notify", when: "gate", severity: "warning", message: "VM in sofferenza: {{steps.analyze.text}}" }, ], }; function fmt(iso?: string | null): string { if (!iso) return "—"; const d = new Date(iso); return Number.isNaN(d.getTime()) ? iso : d.toLocaleString("it-IT", { dateStyle: "short", timeStyle: "medium" }); } function duration(a: string, b?: string): string { if (!b) return "…"; const ms = Date.parse(b) - Date.parse(a); return ms < 1000 ? `${ms}ms` : ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 60_000)}m`; } function errorText(e: unknown): string { return e instanceof Error ? e.message : String(e); } const plugin: PiWebPlugin = { apiVersion: 4, name: "Scheduler", activate({ html, svg, runtimePluginId }) { const states = new Map(); let disposed = false; const key = (c: WorkspacePanelContext) => JSON.stringify([c.machine.id, c.workspace.projectId, c.workspace.id]); const stateFor = (c: WorkspacePanelContext): PanelState => { let s = states.get(key(c)); if (!s) { s = { tab: "jobs", scopeAll: false, busy: false, status: "", loaded: false }; states.set(key(c), s); } return s; }; const render = (c: WorkspacePanelContext) => { if (!disposed) c.host.requestRender(); }; async function call(c: WorkspacePanelContext, op: string, input: unknown): Promise { if (!c.peer?.request) throw new Error("Backend Scheduler non disponibile su questa macchina (plugin server attivo? restart del session daemon?)"); return (await c.peer.request(op, (input ?? null) as never)) as T; } async function refresh(c: WorkspacePanelContext, quiet = false): Promise { const s = stateFor(c); if (!quiet) { s.busy = true; render(c); } try { s.overview = await call(c, "overview", { scope: s.scopeAll ? "all" : "workspace" }); if (s.detail) { const d = await call(c, "job.get", { id: s.detail.view.job.id }).catch(() => undefined); if (d) s.detail = d; else delete s.detail; } delete s.error; s.loaded = true; } catch (e) { s.error = errorText(e); } finally { s.busy = false; render(c); } ensureChannel(c); } function scheduleRefresh(c: WorkspacePanelContext): void { const s = stateFor(c); if (s.refreshTimer) return; s.refreshTimer = setTimeout(() => { delete s.refreshTimer; void refresh(c, true); }, 400); } function ensureChannel(c: WorkspacePanelContext): void { const s = stateFor(c); if (s.channel || !c.peer?.openChannel) return; const placeholder = { close() { /* opening */ } }; s.channel = placeholder; c.peer.openChannel("events", null, { onData: () => scheduleRefresh(c) }).then((ch) => { if (disposed) { ch.close(); return; } s.channel = ch; void ch.closed.then(() => { if (s.channel === ch) delete s.channel; }); }, () => { if (s.channel === placeholder) delete s.channel; }); } async function act(c: WorkspacePanelContext, label: string, fn: () => Promise): Promise { const s = stateFor(c); s.busy = true; s.status = `${label}…`; render(c); try { await fn(); s.status = `${label}: ok`; delete s.error; } catch (e) { s.error = `${label}: ${errorText(e)}`; s.status = ""; } finally { s.busy = false; } await refresh(c, true); } async function openDetail(c: WorkspacePanelContext, id: string): Promise { const s = stateFor(c); delete s.run; await act(c, "Dettaglio", async () => { s.detail = await call(c, "job.get", { id }); }); } async function openRun(c: WorkspacePanelContext, r: RunSummary): Promise { const s = stateFor(c); await act(c, "Run", async () => { s.run = await call(c, "run.get", { jobId: r.jobId, runId: r.id }); }); } function startEdit(c: WorkspacePanelContext, job?: Job): void { const s = stateFor(c); if (job) { const { state: _state, version: _v, createdAt: _c, updatedAt: _u, createdBy: _b, ...rest } = job; const schedule = job.schedule.kind === "cron" ? { cron: job.schedule.expr, tz: job.schedule.tz } : job.schedule.kind === "at" ? { at: job.schedule.at } : { every: job.schedule.everyMs, anchor: job.schedule.anchor }; s.editor = { mode: "update", text: JSON.stringify({ ...rest, schedule }, null, 2) }; } else { s.editor = { mode: "create", text: JSON.stringify(TEMPLATE, null, 2) }; } render(c); } async function saveEdit(c: WorkspacePanelContext): Promise { const s = stateFor(c); const ed = s.editor; if (!ed) return; let job: unknown; try { job = JSON.parse(ed.text); } catch (e) { ed.error = `JSON non valido: ${errorText(e)}`; render(c); return; } try { const saved = await call(c, "job.upsert", { mode: ed.mode, job }); delete s.editor; s.status = `Job '${saved.id}' salvato`; await openDetail(c, saved.id); } catch (e) { ed.error = errorText(e); render(c); } } function startResched(c: WorkspacePanelContext, job: Job): void { const s = stateFor(c); const sch = job.schedule; s.resched = { id: job.id, kind: sch.kind, value: sch.kind === "cron" ? sch.expr : sch.kind === "at" ? sch.at : `${Math.round(sch.everyMs / 60_000)}m`, tz: sch.kind === "cron" ? (sch.tz ?? "Europe/Rome") : "Europe/Rome", preview: [], }; void previewResched(c); } function reschedInput(r: Rescheduler): unknown { return r.kind === "cron" ? { cron: r.value, tz: r.tz } : r.kind === "at" ? { at: r.value } : { every: r.value }; } async function previewResched(c: WorkspacePanelContext): Promise { const r = stateFor(c).resched; if (!r) return; try { r.preview = await call(c, "schedule.preview", { schedule: reschedInput(r) }); delete r.error; } catch (e) { r.preview = []; r.error = errorText(e); } render(c); } // ---------------------------------------------------------------- views const badge = (status?: string) => status ? html`${status}` : html`—`; const btn = (label: string, onClick: () => void, opts: { disabled?: boolean; title?: string; danger?: boolean } = {}) => html``; function jobRow(c: WorkspacePanelContext, s: PanelState, v: JobView) { const j = v.job; const running = v.running.length > 0; return html` { e.preventDefault(); void openDetail(c, j.id); }}>${j.name}
${j.id}${j.tags?.length ? ` · ${j.tags.join(", ")}` : ""} ${v.scheduleText} ${j.enabled ? fmt(j.state.retry?.at ?? j.state.nextRunAt) : "in pausa"}${j.state.retry ? html`
retry #${j.state.retry.attempt}` : ""}
${running ? badge("running") : badge(j.state.lastStatus)}
${fmt(j.state.lastRunAt)} ${btn("▶", () => void act(c, "Esegui ora", () => call(c, "job.runNow", { id: j.id })), { disabled: s.busy || running, title: "Esegui ora" })} ${btn(j.enabled ? "⏸" : "⏵", () => void act(c, j.enabled ? "Pausa" : "Riprendi", () => call(c, "job.setEnabled", { id: j.id, enabled: !j.enabled })), { disabled: s.busy, title: j.enabled ? "Metti in pausa" : "Riprendi" })} ${btn("🕑", () => startResched(c, j), { disabled: s.busy, title: "Riprogramma" })} ${btn("✎", () => startEdit(c, j), { disabled: s.busy, title: "Modifica" })} ${j.state.pendingMisfire ? html` ⚠ Run mancato del ${fmt(j.state.pendingMisfire.dueAt)} (misfire=ask). ${btn("Esegui ora", () => void act(c, "Misfire", () => call(c, "job.misfire", { id: j.id, decision: "run" })))} ${btn("Salta", () => void act(c, "Misfire", () => call(c, "job.misfire", { id: j.id, decision: "skip" })))} ` : ""}`; } function runsTable(c: WorkspacePanelContext, s: PanelState, runs: RunSummary[], showJob: boolean) { if (runs.length === 0) return html`

Nessuna esecuzione.

`; return html`${showJob ? html`` : ""}${runs.map((r) => html` ${showJob ? html`` : ""} `)}
JobAvvioEsitoTriggerDurata
${r.jobId}${fmt(r.startedAt)} ${badge(r.status)}${r.error ? html`
${r.error.slice(0, 80)}` : ""}
${r.trigger}${r.attempt > 1 ? ` #${r.attempt}` : ""} ${duration(r.startedAt, r.endedAt)} ${btn("Dettagli", () => void openRun(c, r))} ${r.status === "running" ? btn("Annulla", () => void act(c, "Annulla run", () => call(c, "run.cancel", { runId: r.id })), { danger: true }) : ""}
`; } function runView(c: WorkspacePanelContext, s: PanelState, run: Run) { const job = s.overview?.jobs.find((v) => v.job.id === run.jobId)?.job ?? s.detail?.view.job; const sameWorkspace = job?.cwd === c.workspace.path; return html`
Run ${run.id} ${badge(run.status)} ${btn("✕", () => { delete s.run; render(c); }, { title: "Chiudi" })}
job ${run.jobId} · ${run.trigger} · tentativo ${run.attempt} · previsto ${fmt(run.scheduledFor)} · host ${run.host} ${run.error ? html`

${run.error}

` : ""} ${run.steps.map((st) => html`
${st.id} (${st.type}) ${badge(st.status)} ${duration(st.startedAt, st.endedAt)} ${st.value !== undefined ? html` → ${String(st.value)}` : ""} ${st.exitCode !== undefined ? html` exit ${String(st.exitCode)}` : ""} ${st.httpStatus !== undefined ? html` HTTP ${st.httpStatus}` : ""} ${st.sessionId ? html`

Sessione: ${st.sessionId} ${sameWorkspace ? btn("Apri sessione", () => void c.navigate({ machineId: c.machine.id, projectId: c.workspace.projectId, workspaceId: c.workspace.id, sessionId: st.sessionId as string, view: "chat" })) : ""}

` : ""} ${st.error ? html`
${st.error}
` : ""} ${st.text ? html`
${st.text}
` : ""} ${st.stdout ? html`
stdout
${st.stdout}
` : ""} ${st.stderr ? html`
stderr
${st.stderr}
` : ""}
`)}
`; } function detailView(c: WorkspacePanelContext, s: PanelState, d: JobDetail) { const j = d.view.job; return html`
${j.name} ${btn("Duplica", () => { startEdit(c, { ...j, id: `${j.id}-copy`, name: `${j.name} (copia)` }); const ed = stateFor(c).editor; if (ed) ed.mode = "create"; })} ${btn("Elimina", () => { if (confirm(`Eliminare il job '${j.id}'? Lo storico run resta su disco.`)) void act(c, "Elimina", async () => { await call(c, "job.delete", { id: j.id }); delete s.detail; delete s.run; }); }, { danger: true })} ${btn("✕", () => { delete s.detail; delete s.run; render(c); }, { title: "Chiudi" })}
${j.description ? html`

${j.description}

` : ""} cwd ${j.cwd} · misfire ${j.misfire} · ${j.retry ? `retry ${j.retry.maxAttempts}x` : "no retry"} · ${d.view.bound ? "workspace PI WEB associato" : "headless (workspace non associato)"}

Prossime esecuzioni: ${d.preview.length ? d.preview.map((p) => fmt(p)).join(" · ") : "nessuna"}

Pipeline: ${j.steps.map((st, i) => html`${i ? " → " : ""}${st.id}:${st.type}${st.when ? `?${st.when}` : ""}`)}

Storico

${runsTable(c, s, d.runs, false)}
`; } function reschedView(c: WorkspacePanelContext, s: PanelState, r: Rescheduler) { return html`
Riprogramma ${r.id}
{ r.value = (e.target as HTMLInputElement).value; void previewResched(c); }}> ${r.kind === "cron" ? html` { r.tz = (e.target as HTMLInputElement).value; void previewResched(c); }}>` : ""} ${btn("Salva", () => void act(c, "Riprogramma", async () => { await call(c, "job.reschedule", { id: r.id, schedule: reschedInput(r) }); delete s.resched; }), { disabled: !!r.error || s.busy })} ${btn("Annulla", () => { delete s.resched; render(c); })}
${r.error ? html`

${r.error}

` : html`Prossime: ${r.preview.map((p) => fmt(p)).join(" · ") || "nessuna"}`}
`; } function editorView(c: WorkspacePanelContext, s: PanelState, ed: Editor) { return html`
${ed.mode === "create" ? "Nuovo job" : "Modifica job"} (cwd di default: ${c.workspace.path}) ${ed.error ? html`
${ed.error}
` : ""} ${btn("Salva", () => void saveEdit(c), { disabled: s.busy })} ${btn("Annulla", () => { delete s.editor; render(c); })}
`; } return { contributions: { actions: [{ id: "scheduler.open", title: "Open Scheduler", description: "Apri la console dei job pianificati", group: "Workspace", enabled: (context) => context.state.selectedWorkspace !== undefined, run: (context) => { context.selectWorkspaceTool(`${runtimePluginId}:scheduler`); }, }], workspacePanels: [{ id: "scheduler", title: "Scheduler", order: 45, icon: svg``, badge: (c) => { const o = stateFor(c).overview; if (!o) return undefined; const attention = o.jobs.filter((v) => v.job.state.pendingMisfire || v.job.state.lastStatus === "failed").length; return attention || undefined; }, onInvalidate: (c) => { void refresh(c, true); }, render(c) { const s = stateFor(c); if (!s.loaded && !s.busy && !s.error) void refresh(c); const o = s.overview; return html`
${btn("Job", () => { s.tab = "jobs"; render(c); }, { disabled: s.tab === "jobs" })} ${btn("Storico", () => { s.tab = "history"; render(c); }, { disabled: s.tab === "history" })} ${btn("+ Nuovo", () => startEdit(c), { disabled: s.busy })} ${btn("⟳", () => void refresh(c), { disabled: s.busy, title: "Aggiorna" })}
${s.error ? html`

${s.error}

` : ""} ${s.status ? html`

${s.status}

` : ""} ${s.editor ? editorView(c, s, s.editor) : ""} ${s.resched ? reschedView(c, s, s.resched) : ""} ${!o ? html`

${s.busy ? "Caricamento…" : ""}

` : s.tab === "jobs" ? html` ${o.jobs.length === 0 ? html`

Nessun job${s.scopeAll ? "" : " per questo workspace"}. Crea un job con "+ Nuovo" o chiedi all'agente (tool scheduler_create_job).

` : html` ${o.jobs.map((v) => jobRow(c, s, v))}
JobScheduleProssimaUltimo
`} ${s.detail ? detailView(c, s, s.detail) : ""}` : html`
${runsTable(c, s, o.recentRuns, true)}
`} ${s.run ? runView(c, s, s.run) : ""} ${o ? html`

host ${o.host} · store ${o.storeRoot} · aggiornato ${fmt(o.now)}

` : ""}
`; }, }], }, dispose() { disposed = true; for (const s of states.values()) { s.channel?.close("dispose"); if (s.refreshTimer) clearTimeout(s.refreshTimer); } states.clear(); }, }; }, }; export default plugin;