@cloud-cli/on 1.66.1 → 1.66.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/on.js
CHANGED
|
@@ -104,7 +104,7 @@ async function m() {
|
|
|
104
104
|
let [{ QueueManager: r }, { SecretStore: i }, { WebhookServer: a }, { startWorkers: o }, { WorkflowRepository: s }, { WorkflowScheduler: c }, { OidcUserRepository: l }] = await Promise.all([
|
|
105
105
|
import("./queue-DhXXzxm3.js"),
|
|
106
106
|
import("./secrets-DhkqmawU.js"),
|
|
107
|
-
import("./server-
|
|
107
|
+
import("./server-CQ3FDHUe.js"),
|
|
108
108
|
import("./worker-BwNyfSpp.js"),
|
|
109
109
|
import("./workflows-DQQIVH7c.js"),
|
|
110
110
|
import("./scheduler-2d1UPcxM.js"),
|
|
@@ -12311,7 +12311,7 @@ ${n}
|
|
|
12311
12311
|
}`;
|
|
12312
12312
|
return e.replace("<link rel=\"stylesheet\" href=\"./style.css\" />", `<style>${t}</style>`).replace("<script src=\"./app.js\" defer><\/script>", "").replace("</body>", `<script>${r}<\/script></body>`);
|
|
12313
12313
|
}
|
|
12314
|
-
var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigator, URL, URLSearchParams, Headers, EventSource, Blob */\n\nconst icons = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n};\n\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n icons[name] || icons.runs\n }</svg>`;\nconst escape = (value) =>\n String(value ?? \"\").replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${escape(status)}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst iconForStatus = statusIcon;\nconst badge = (status) =>\n `<span class=\"status ${escape(status)}\">${statusIcon(status)}${escape(labels[status] || status || \"Unknown\")}</span>`;\n\nexport function mountLivePreview() {\n const main = document.querySelector(\"#main\");\n const dialog = document.querySelector(\"#dialog\");\n const state = {\n jobs: [],\n workflows: [],\n workers: [],\n secrets: [],\n hasMore: false,\n loadingJobs: false,\n jobsLoaded: false,\n workflowsLoaded: false,\n workersLoaded: false,\n secretsLoaded: false,\n errors: {},\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n worker: \"all\",\n workflowFilter: \"all\",\n page: 1,\n run: null,\n mutating: false,\n detailTab: \"logs\",\n step: 0,\n logSearch: \"\",\n wrap: false,\n };\n let apiClient;\n let events;\n let pollTimer;\n let previousRoute = \"\";\n let jobsLoadPromise;\n let refreshJobsAfterCurrentLoad = false;\n\n const route = () => {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URLSearchParams(query) };\n };\n\n const client = async () => {\n apiClient ??= import(\"/api-client.mjs\");\n return apiClient;\n };\n\n const apiJson = async (path, options = {}) => {\n const { apiFetch } = await client();\n const headers = new Headers(options.headers || {});\n headers.set(\"accept\", \"application/json\");\n if (options.body && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n const response = await apiFetch(path, { ...options, headers });\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n const error = new Error(payload.error || `Request failed (${response.status})`);\n error.status = response.status;\n throw error;\n }\n return response.status === 204 ? null : response.json();\n };\n\n const apiBlob = async (path) => {\n const { apiFetch } = await client();\n const response = await apiFetch(path);\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n throw new Error(payload.error || `Download failed (${response.status})`);\n }\n return response.blob();\n };\n\n const showToast = (message) => {\n const toast = document.querySelector(\"#toast\");\n toast.textContent = message;\n toast.hidden = false;\n window.clearTimeout(showToast.timer);\n showToast.timer = window.setTimeout(() => {\n toast.hidden = true;\n }, 4500);\n };\n\n const dateValue = (value) => {\n if (!value) {\n return null;\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n return Number.isNaN(date.getTime()) ? null : date;\n };\n\n const formatDate = (value) => {\n const date = dateValue(value);\n return date ? new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date) : \"—\";\n };\n\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) {\n return \"—\";\n }\n const seconds = Math.floor(value / 1000);\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n };\n\n const workflowName = (workflowId) =>\n state.workflows.find((workflow) => workflow.id === workflowId)?.name || workflowId || \"Unknown workflow\";\n const pageHeading = (title, subtitle, action = \"\") =>\n `<div class=\"page-heading\"><div><h1>${escape(title)}</h1>${\n subtitle ? `<p class=\"subtitle\">${escape(subtitle)}</p>` : \"\"\n }</div>${action}</div>`;\n const runWorkflowButton = () =>\n `<button class=\"button primary\" data-action=\"new-run\">${icon(\"plus\")}Run workflow</button>`;\n const permissionMessage = (error, scope) =>\n [401, 403].includes(error?.status)\n ? `Your account needs the ${scope} permission to view this data.`\n : error?.message || \"The Flow API could not be reached.\";\n\n const setNavigation = (page, jobId = \"\") => {\n const currentJob = jobId ? state.jobs.find((job) => String(job.id) === String(jobId)) : null;\n const currentJobId = currentJob?.id ?? jobId;\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, name]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${page === id ? \"active\" : \"\"}\" ${\n page === id ? 'aria-current=\"page\"' : \"\"\n }>${icon(name)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n state.jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n document.querySelector(\"#breadcrumbs\").innerHTML = `${\n jobId\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${escape(\n currentJobId\n )}</strong>`\n : `<strong>${escape(page[0].toUpperCase() + page.slice(1))}</strong>`\n }`;\n document.title = `${jobId ? `Run #${currentJobId}` : page[0].toUpperCase() + page.slice(1)} · Flow`;\n };\n const setBreadcrumbs = (page, jobId = null) => setNavigation(page, jobId);\n\n const loadJobs = async ({ older = false } = {}) => {\n if (jobsLoadPromise) {\n if (!older) {\n refreshJobsAfterCurrentLoad = true;\n }\n await jobsLoadPromise;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n return;\n }\n state.loadingJobs = true;\n const beforeId = older && state.jobs.length ? Math.min(...state.jobs.map((job) => Number(job.id))) : null;\n jobsLoadPromise = (async () => {\n try {\n const query = new URLSearchParams({ limit: older ? \"50\" : \"100\" });\n if (beforeId) {\n query.set(\"beforeId\", String(beforeId));\n }\n if (state.workflow !== \"all\") {\n query.set(\"workflowId\", state.workflow);\n }\n const data = await apiJson(`/api/jobs?${query}`);\n if (older) {\n const merged = new Map(state.jobs.map((job) => [String(job.id), job]));\n for (const job of data.jobs || []) {\n merged.set(String(job.id), job);\n }\n state.jobs = [...merged.values()].sort((a, b) => Number(b.id) - Number(a.id));\n } else {\n state.jobs = data.jobs || [];\n }\n state.hasMore = Boolean(data.hasMore);\n state.errors.jobs = null;\n state.jobsLoaded = true;\n } catch (error) {\n state.errors.jobs = error;\n state.jobsLoaded = true;\n } finally {\n state.loadingJobs = false;\n }\n })();\n try {\n await jobsLoadPromise;\n } finally {\n jobsLoadPromise = undefined;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n }\n };\n\n const loadWorkflows = async ({ retry = false } = {}) => {\n if (state.workflowsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workflows\");\n state.workflows = data.workflows || [];\n state.errors.workflows = null;\n } catch (error) {\n state.errors.workflows = error;\n } finally {\n state.workflowsLoaded = true;\n }\n };\n\n const loadWorkers = async ({ retry = false } = {}) => {\n if (state.workersLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workers\");\n state.workers = data.workers || [];\n state.errors.workers = null;\n } catch (error) {\n state.errors.workers = error;\n } finally {\n state.workersLoaded = true;\n }\n };\n\n const loadSecrets = async ({ retry = false } = {}) => {\n if (state.secretsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/secrets\");\n state.secrets = data.secrets || [];\n state.errors.secrets = null;\n } catch (error) {\n state.errors.secrets = error;\n } finally {\n state.secretsLoaded = true;\n }\n };\n\n const workflowFormSource = () => {\n const source = document.getElementById(\"new-workflow-source\")?.value.trim() || \"\";\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n if (!source || !name) {\n return source;\n }\n const yamlName = `name: ${JSON.stringify(name)}`;\n return /^name\\s*:/m.test(source) ? source.replace(/^name\\s*:[^\\r\\n]*/m, yamlName) : `${yamlName}\\n${source}`;\n };\n\n const filteredJobs = () =>\n state.jobs.filter((job) => {\n const text = [job.id, job.workflowId, workflowName(job.workflowId), job.status, job.workerId]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status)) &&\n (state.workflow === \"all\" || job.workflowId === state.workflow) &&\n (state.worker === \"all\" || (state.worker === \"queued\" ? !job.workerId : job.workerId === state.worker))\n );\n });\n\n const renderRunsTable = () => {\n const filtered = filteredJobs();\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(state.page, pages);\n const rows = filtered.slice((state.page - 1) * 8, state.page * 8);\n const target = document.querySelector(\"#run-results\");\n if (!target) {\n return;\n }\n target.innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n state.jobs.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n rows.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Live Flow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / run</th><th scope=\"col\">Status</th><th scope=\"col\">Worker</th><th scope=\"col\">Updated</th><th scope=\"col\">Created</th></tr></thead><tbody>${rows\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${encodeURIComponent(job.id)}\" aria-label=\"Run ${escape(\n job.id\n )}: ${escape(workflowName(job.workflowId))}, ${escape(\n labels[job.status] || job.status\n )}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${escape(\n workflowName(job.workflowId)\n )}</span><span class=\"run-subtitle\"><span class=\"mono\">#${escape(\n job.id\n )}</span><span class=\"separator\">·</span><span class=\"mono\">${escape(\n job.workflowId\n )}</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"server\"\n )}${escape(job.workerId || \"Queued\")}</span></td><td>${escape(\n formatDate(job.updatedAt)\n )}</td><td>${escape(formatDate(job.createdAt))}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\"search\")}<h3>${\n state.errors.jobs ? \"Runs unavailable\" : \"No runs found\"\n }</h3><p>${escape(state.errors.jobs?.message || \"Try a different search or filter.\")}</p>${\n state.errors.jobs\n ? '<button class=\"button\" data-action=\"retry-runs\">Retry</button>'\n : '<button class=\"button\" data-action=\"clear-filters\">Clear filters</button>'\n }</div>`\n }\n <div class=\"table-footer\"><span role=\"status\">${\n filtered.length\n ? `${(state.page - 1) * 8 + 1}–${Math.min(state.page * 8, filtered.length)} of ${filtered.length} runs`\n : \"0 runs\"\n }</span><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><span>${\n state.page\n } / ${pages}</span><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages && !state.hasMore ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div>${\n state.hasMore ? '<div class=\"table-hint\">Older Flow runs are available.</div>' : \"\"\n }`;\n };\n\n const renderRuns = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `${pageHeading(\"Runs\", \"\", runWorkflowButton())}${\n state.loadingJobs && !state.jobs.length ? '<div class=\"empty-state\"><p>Loading runs…</p></div>' : \"\"\n }<section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Recent runs <span class=\"count\">${\n state.jobs.length\n }</span></h2></div><div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, workflows, or workers…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label><select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${escape(\n workflow.name\n )}</option>`\n )\n .join(\n \"\"\n )}</select><select id=\"worker-filter\" class=\"select-filter\" aria-label=\"Filter by worker\"><option value=\"all\">All workers</option><option value=\"queued\" ${\n state.worker === \"queued\" ? \"selected\" : \"\"\n }>Queued</option>${[...new Set(state.jobs.map((job) => job.workerId).filter(Boolean))]\n .map(\n (worker) =>\n `<option value=\"${escape(worker)}\" ${state.worker === worker ? \"selected\" : \"\"}>${escape(worker)}</option>`\n )\n .join(\"\")}</select></div><div id=\"run-results\"></div></section>`;\n renderRunsTable();\n };\n\n const renderRunDetails = (run) => {\n setBreadcrumbs(\"runs\", run.jobId);\n const inputs = run.inputs || {};\n const failedStep = (run.steps || []).find((step) => step.status === \"failed\");\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\"back\")}All runs</a>\n <div class=\"page-heading run-heading\"><div><h1>${iconForStatus(run.status)}${escape(\n run.workflowName || workflowName(run.workflowId)\n )}</h1><p class=\"subtitle\"><span class=\"mono\">#${escape(run.jobId)}</span><span>·</span><span>${escape(\n inputs.full_name || inputs.repo || run.workflowId || \"Workflow run\"\n )}</span></p></div>\n <div class=\"heading-actions\">${\n run.canViewLogs\n ? `<button class=\"button\" data-action=\"download-logs\">${icon(\"download\")}Download logs</button>`\n : \"\"\n }<button class=\"button ${[\"running\", \"pending\"].includes(run.status) ? \"danger\" : \"primary\"}\" data-action=\"${\n [\"running\", \"pending\"].includes(run.status) ? \"cancel\" : \"rerun\"\n }\" ${state.mutating ? \"disabled\" : \"\"}>${icon([\"running\", \"pending\"].includes(run.status) ? \"stop\" : \"refresh\")}${\n [\"running\", \"pending\"].includes(run.status) ? \"Cancel run\" : \"Re-run workflow\"\n }</button></div></div>\n ${\n failedStep\n ? `<div class=\"failure-banner\">${icon(\"alert\")}<div><strong>${escape(failedStep.name || \"Step\")} failed${\n failedStep.exitCode !== undefined ? ` with exit code ${escape(failedStep.exitCode)}` : \"\"\n }</strong><p>${escape(\n failedStep.error || \"See the failed step output.\"\n )}</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n run.status\n )}</span></div><div><span class=\"meta-label\">BRANCH</span><span class=\"meta-value\">${icon(\"branch\")}${escape(\n inputs.branch || inputs.ref?.replace(/^refs\\/heads\\//, \"\") || \"—\"\n )}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\"commit\")}${escape(\n (inputs.commit_sha || inputs.commit || \"—\").slice(0, 8)\n )}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${formatDuration(\n run.durationMs\n )}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\"server\")}${escape(\n run.workerId || \"Queued\"\n )}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n void renderRunTab(run);\n };\n\n const liveRunPage = async (id) => {\n main.innerHTML = `<div class=\"empty-state\">${icon(\"clock\")}<p>Loading run #${escape(id)}…</p></div>`;\n try {\n const run = await apiJson(`/api/runs/${encodeURIComponent(id)}`);\n state.errors.run = null;\n state.run = run;\n const importantStep = run.steps?.findIndex((step) => [\"failed\", \"running\"].includes(step.status)) ?? -1;\n if (importantStep >= 0) {\n state.step = importantStep;\n }\n renderRunDetails(run);\n } catch (error) {\n state.errors.run = error;\n main.innerHTML = `<div class=\"empty-state\">${icon(\"alert\")}<h1>Run unavailable</h1><p>${escape(\n error.message\n )}</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n };\n\n const renderRunTab = async (run) => {\n const content = document.querySelector(\"#detail-content\");\n if (!content) {\n return;\n }\n if (state.detailTab === \"inputs\") {\n let source = run.workflowSourceYaml || \"\";\n if (!source && run.workflowId) {\n try {\n const workflow = await apiJson(\n `/api/workflows/${encodeURIComponent(run.workflowId)}?revision=${encodeURIComponent(\n run.workflowRevision || \"\"\n )}`\n );\n source = workflow.sourceYaml || \"\";\n } catch {\n /* Workflow source has separate permission checks. */\n }\n }\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs</h3><pre>${escape(\n JSON.stringify(run.inputs || {}, null, 2)\n )}</pre></section>${\n source\n ? `<section class=\"source-view\" style=\"margin-top:16px\"><h3>Workflow source · revision ${escape(\n run.workflowRevision || \"—\"\n )}</h3><pre>${escape(source)}</pre></section>`\n : '<p class=\"settings-note\" style=\"margin-top:14px\">Workflow source is unavailable for this run.</p>'\n }`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n const artifacts = run.artifacts || [];\n content.innerHTML = artifacts.length\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">${\n artifacts.length\n }</span></h2>${artifacts\n .map(\n (path, index) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${escape(\n path.split(\"/\").pop() || path\n )}</strong><p>${escape(\n path\n )}</p></div><button class=\"button\" data-action=\"download-artifact\" data-value=\"${index}\">${icon(\n \"download\"\n )}Download</button></div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No artifacts</h3><p>This run did not publish any artifacts.</p></div>';\n return;\n }\n if (state.detailTab === \"history\") {\n const history = await loadRunHistory(run);\n content.innerHTML =\n history.length > 1\n ? `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${encodeURIComponent(\n entry.jobId\n )}\">Run #${escape(entry.jobId)}</a></strong><p>${escape(\n entry.workflowName || entry.workflowId || \"Workflow\"\n )} · ${formatDate(entry.startedAt)}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No previous attempts</h3><p>This run has no parent run linked to it.</p></div>';\n return;\n }\n const steps = run.steps || [];\n if (!steps.length) {\n content.innerHTML =\n '<div class=\"empty-state\"><h3>No execution steps yet</h3><p>Step details will appear when Flow records them.</p></div>';\n return;\n }\n state.step = Math.max(0, Math.min(state.step, steps.length - 1));\n const selected = steps[state.step];\n const stepButtons = steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\");\n if (!run.canViewLogs) {\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${stepButtons}</aside><div class=\"log-panel\"><div class=\"empty-state\">${icon(\n \"lock\"\n )}<h3>Logs require permission</h3><p>Your account needs <span class=\"mono\">logs:read</span> to view step output.</p></div></div></section>`;\n return;\n }\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\")}</aside><div class=\"log-panel\"><div class=\"log-heading\"><h3>${escape(selected.name)}<small>${\n selected.exitCode !== undefined\n ? `exit code ${escape(selected.exitCode)}`\n : escape(labels[selected.status] || selected.status)\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\">${icon(\"wrap\")}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logSearch\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Step failed\"\n : selected.status === \"running\"\n ? \"Step is running\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>`;\n renderLog(selected.logContent || \"\");\n };\n\n const loadRunHistory = async (run) => {\n const entries = [run];\n const visited = new Set([String(run.jobId)]);\n let parent = run.parentId;\n while (parent && !visited.has(String(parent)) && entries.length < 25) {\n visited.add(String(parent));\n try {\n const entry = await apiJson(`/api/runs/${encodeURIComponent(parent)}`);\n entries.push(entry);\n parent = entry.parentId;\n } catch {\n break;\n }\n }\n return entries;\n };\n\n const renderLog = (text) => {\n const log = document.querySelector(\"#log-lines\");\n if (!log) {\n return;\n }\n const query = state.logSearch.toLowerCase();\n let matches = 0;\n log.innerHTML = String(text)\n .split(\"\\n\")\n .map((line, index) => {\n let content = escape(line) || \" \";\n const start = query ? line.toLowerCase().indexOf(query) : -1;\n if (start >= 0) {\n matches++;\n content = `${escape(line.slice(0, start))}<mark>${escape(\n line.slice(start, start + query.length)\n )}</mark>${escape(line.slice(start + query.length))}`;\n }\n const type = /\\b(error|failed|fatal)\\b/i.test(line)\n ? \"error\"\n : /\\b(success|passed|done)\\b/i.test(line)\n ? \"pass\"\n : \"\";\n return `<div class=\"log-line ${type}\"><span class=\"line-number\">${\n index + 1\n }</span><span class=\"line-text\">${content}</span></div>`;\n })\n .join(\"\");\n const result = document.querySelector(\"#log-matches\");\n if (result) {\n result.textContent = query ? `${matches} matching lines` : \"\";\n }\n };\n const renderLiveLog = renderLog;\n\n const downloadBlob = (blob, filename) => {\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const renderRevisionDiff = (previousSource, currentSource) => {\n const before = previousSource === null ? [] : previousSource.split(\"\\n\");\n const after = String(currentSource ?? \"\").split(\"\\n\");\n let prefixLength = 0;\n while (\n prefixLength < before.length &&\n prefixLength < after.length &&\n before[prefixLength] === after[prefixLength]\n ) {\n prefixLength++;\n }\n let suffixLength = 0;\n while (\n suffixLength < before.length - prefixLength &&\n suffixLength < after.length - prefixLength &&\n before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]\n ) {\n suffixLength++;\n }\n const renderLines = (lines, className, prefix) =>\n lines\n .filter((line, index) => line || index < lines.length - 1)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escape(line)}</span>`)\n .join(\"\");\n return [\n renderLines(before.slice(0, prefixLength), \"text-gray-300\", \" \"),\n renderLines(before.slice(prefixLength, before.length - suffixLength), \"text-rose-300 bg-rose-500/10\", \"- \"),\n renderLines(after.slice(prefixLength, after.length - suffixLength), \"text-emerald-300 bg-emerald-500/10\", \"+ \"),\n renderLines(before.slice(before.length - suffixLength), \"text-gray-300\", \" \"),\n ].join(\"\");\n };\n\n const loadRevisionDiff = async (workflowId, revision) => {\n try {\n const current = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision}`);\n const previous =\n revision > 1\n ? await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision - 1}`)\n : null;\n const sourceDiff = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n openDialog(\n `Workflow ${current.name || current.id}`,\n `<p class=\"dialog-description\">${escape(current.name || current.id)} — Revision ${escape(\n current.revision\n )}</p><div class=\"source-view\"><pre>${sourceDiff}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n } catch (error) {\n showToast(`Failed to load revision diff: ${error.message}`);\n }\n };\n\n const liveWorkflowsPage = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n setBreadcrumbs(\"workflows\");\n const tabs = [\n { id: \"all\", label: \"All\", filter: () => true },\n { id: \"published\", label: \"Published\", filter: (workflow) => workflow.status === \"published\" },\n { id: \"draft\", label: \"Drafts\", filter: (workflow) => workflow.status === \"draft\" },\n { id: \"archived\", label: \"Archived\", filter: (workflow) => workflow.status === \"archived\" },\n ];\n const selectedTab = tabs.find((tab) => tab.id === state.workflowFilter) || tabs[0];\n const visibleWorkflows = state.workflows.filter(selectedTab.filter);\n const tabButtons = tabs\n .map((tab) => {\n const count = state.workflows.filter(tab.filter).length;\n return `<button class=\"status-tab ${\n state.workflowFilter === tab.id ? \"active\" : \"\"\n }\" data-action=\"workflow-status-tab\" data-value=\"${tab.id}\" aria-pressed=\"${state.workflowFilter === tab.id}\">${\n tab.label\n }<span class=\"count\">${count}</span></button>`;\n })\n .join(\"\");\n const workflowRows = visibleWorkflows\n .map((workflow) => {\n const index = state.workflows.indexOf(workflow);\n return `<div class=\"workflow-item\"><div class=\"workflow-header\"><span class=\"workflow-icon\">${icon(\n \"workflow\"\n )}</span><span class=\"workflow-name\">${escape(\n workflow.name || workflow.id\n )}</span><span class=\"workflow-status ${\n workflow.status === \"published\" ? \"success\" : workflow.status === \"archived\" ? \"disabled\" : \"enabled\"\n }\">${escape(workflow.status)}</span></div><div class=\"workflow-meta\"><span>Revision ${escape(\n workflow.revision || \"—\"\n )}</span><span class=\"workflow-id mono\">${escape(\n workflow.id\n )}</span></div><div class=\"workflow-actions\"><a class=\"text-button\" href=\"#/runs?workflow=${encodeURIComponent(\n workflow.id\n )}\">View runs ${icon(\"arrow\")}</a><a class=\"button\" href=\"/settings/workflows/${encodeURIComponent(\n workflow.id\n )}\" target=\"_top\">Edit ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}Source</button>${\n workflow.revision > 1\n ? `<button class=\"button small\" data-action=\"workflow-revision\" data-value=\"${index}\" data-dir=\"-1\">Previous revision</button>`\n : \"\"\n }${\n workflow.status !== \"published\"\n ? `<button class=\"button small success\" data-action=\"workflow-publish\" data-value=\"${index}\">Publish ${icon(\n \"rocket\"\n )}</button>`\n : \"\"\n }</div></div>`;\n })\n .join(\"\");\n const content =\n workflowRows ||\n `<div class=\"empty-state\"><h3>No ${escape(selectedTab.label.toLowerCase())} workflows</h3><p>${\n state.workflows.length\n ? \"Choose another status tab or create a workflow.\"\n : \"Flow has no workflow definitions to show.\"\n }</p></div>`;\n main.innerHTML = `${pageHeading(\"Workflows\", \"Workflow definitions\", runWorkflowButton())}${\n state.errors.workflows\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Workflows unavailable</h3><p>${escape(\n permissionMessage(state.errors.workflows, \"workflows:read\")\n )}</p><button class=\"button\" data-action=\"retry-workflows\">Retry</button></div>`\n : `<div class=\"status-tabs\" role=\"group\" aria-label=\"Filter workflows by status\">${tabButtons}</div><div class=\"workflow-list\">${content}</div>`\n }`;\n };\n\n const liveWorkersPage = async () => {\n if (!state.workersLoaded) {\n await loadWorkers();\n }\n setBreadcrumbs(\"workers\");\n main.innerHTML = `${pageHeading(\"Workers\", \"Live runner presence from Flow.\")}${\n state.errors.workers\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Worker status unavailable</h3><p>${escape(\n permissionMessage(state.errors.workers, \"workers:read\")\n )}</p><button class=\"button\" data-action=\"retry-workers\">Retry</button></div>`\n : `<div class=\"cards\">${\n state.workers\n .map(\n (worker) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\"server\")}<h2>${escape(\n worker.workerId || \"Unknown worker\"\n )}</h2><span class=\"status ${worker.online ? \"success\" : \"cancelled\"}\">${\n worker.online ? \"Online\" : \"Offline\"\n }</span></div><div class=\"card-details\">${\n (Array.isArray(worker.tags) ? worker.tags : [])\n .map((tag) => `<span class=\"tag mono\">${escape(tag)}</span>`)\n .join(\"\") || '<span class=\"settings-note\">No labels</span>'\n }</div><div class=\"worker-stats\"><div><span class=\"meta-label\">Active jobs</span><strong>${escape(\n worker.activeJobs ?? 0\n )}</strong></div><div><span class=\"meta-label\">Concurrency</span><strong>${escape(\n worker.concurrency ?? \"—\"\n )}</strong></div></div><div class=\"card-footer\"><span class=\"settings-note\">${\n worker.version ? `Runner ${escape(worker.version)}` : \"Runner version unavailable\"\n }</span><span class=\"settings-note\">Seen ${escape(\n formatDate(worker.lastSeen)\n )}</span></div></article>`\n )\n .join(\"\") ||\n '<div class=\"empty-state\"><h3>No workers reported</h3><p>Flow has no worker-presence records.</p></div>'\n }</div>`\n }`;\n };\n\n const liveSettingsPage = async () => {\n if (!state.secretsLoaded) {\n await loadSecrets();\n }\n setBreadcrumbs(\"settings\");\n main.innerHTML = `${pageHeading(\n \"Settings\",\n \"Preferences and secret names from Flow.\"\n )}<section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n localStorage.getItem(\"flow-concept-compact\") === \"true\" ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secret names</h2><p class=\"settings-note\">Values are never requested or displayed in this preview.</p>${\n state.errors.secrets\n ? `<p class=\"settings-note\">${escape(\n permissionMessage(state.errors.secrets, \"secrets:read\")\n )}</p><button class=\"button\" data-action=\"retry-secrets\">Retry</button>`\n : state.secrets\n .map(\n (name) =>\n `<div class=\"setting-row\"><strong class=\"mono\">${escape(\n name\n )}</strong><span class=\"secret-value\" aria-label=\"Secret value hidden\">••••••••••••</span><button class=\"button tiny danger\" data-action=\"remove-secret\" data-value=\"${name}\">Remove</button></div>`\n )\n .join(\"\") || '<p class=\"settings-note\">No secret names are configured.</p>'\n }${\n !state.errors.secrets && state.secrets.length < 5\n ? '<div class=\"setting-row\"><button class=\"button tiny\" data-action=\"add-secret\">Add secret</button></div>'\n : \"\"\n }</section><section class=\"settings-section\"><h2>Workflow editing</h2><p class=\"settings-note\">Create and edit workflow definitions.</p><form id=\"new-workflow-form\"><label class=\"form-field\"><span>Workflow name</span><input id=\"new-workflow-name\" type=\"text\" placeholder=\"e.g. build-docker-image\" required/></label><label class=\"form-field\"><span>Workflow ID</span><input id=\"new-workflow-id\" type=\"text\" placeholder=\"derived-from-name\" required/></label><label class=\"form-field\"><span>Enabled</span><input type=\"checkbox\" id=\"new-workflow-enabled\" checked/></label><div class=\"form-field\"><span>YAML source</span><textarea id=\"new-workflow-source\" rows=\"8\" placeholder=\"name: build-docker-image\\nruns-on: [main-server, docker]\\n\\nsteps:\\n - name: Checkout repository\\n run: git checkout '$COMMIT'\\n - name: Install dependencies\\n run: pnpm install --frozen-lockfile\\n - name: Run tests\\n run: pnpm test\\n - name: Build and push\\n run: pnpm publish\"></textarea></label></div><div class=\"form-footer\"><button type=\"button\" class=\"button\" data-action=\"validate-workflow\">Validate workflow</button><button type=\"button\" class=\"button\" data-action=\"run-now\">Run now</button><button type=\"submit\" class=\"button primary\">${icon(\n \"plus\"\n )}Create workflow</button><button type=\"button\" class=\"button\" data-action=\"close-dialog\">Cancel</button></div></form><a class=\"button secondary\" href=\"/settings/workflows\" target=\"_top\">Open full workflow settings ${icon(\n \"arrow\"\n )}</a></section>`;\n };\n\n const showNotFound = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `<div class=\"empty-state\"><h1>404 — Page not found</h1><p>This preview route does not exist.</p><a class=\"button\" href=\"#/runs\">Go to runs</a></div>`;\n };\n\n const openDialog = (title, content) => {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${escape(\n title\n )}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${content}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n };\n\n const openDispatchDialog = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n if (state.errors.workflows) {\n showToast(permissionMessage(state.errors.workflows, \"workflows:read\"));\n return;\n }\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">This will enqueue a real Flow job.</p><label class=\"form-field\"><span>Workflow</span><select id=\"dispatch-workflow\">${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${workflow.enabled === false ? \"disabled\" : \"\"}>${escape(\n workflow.name || workflow.id\n )}${workflow.enabled === false ? \" (disabled)\" : \"\"}</option>`\n )\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Inputs (JSON object)</span><textarea id=\"dispatch-inputs\" rows=\"6\">{}</textarea></label><p class=\"dialog-note\">Running a workflow requires write permission and creates real job(s).</p><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" data-action=\"dispatch-confirm\">${icon(\n \"runs\"\n )}Review and run</button></div>`\n );\n };\n\n const dispatchWorkflow = async () => {\n if (state.mutating) {\n return;\n }\n const workflowId = document.querySelector(\"#dispatch-workflow\")?.value;\n if (!workflowId) {\n showToast(\"Select a workflow first.\");\n return;\n }\n let inputs;\n try {\n inputs = JSON.parse(document.querySelector(\"#dispatch-inputs\")?.value || \"{}\");\n if (!inputs || typeof inputs !== \"object\" || Array.isArray(inputs)) {\n throw new Error(\"Inputs must be a JSON object.\");\n }\n } catch (error) {\n showToast(error.message);\n return;\n }\n const workflow = state.workflows.find((item) => item.id === workflowId);\n if (workflow?.enabled === false) {\n showToast(\"This workflow is disabled and cannot be run.\");\n return;\n }\n state.mutating = true;\n const dispatchButton = document.querySelector('[data-action=\"dispatch-confirm\"]');\n if (dispatchButton) {\n dispatchButton.disabled = true;\n }\n if (!window.confirm(`Run ${workflow?.name || workflowId} now? This creates real job(s).`)) {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n return;\n }\n try {\n const result = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {\n method: \"POST\",\n body: JSON.stringify({ inputs }),\n });\n dialog.close();\n state.jobs = [];\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Queued ${result.jobs?.length || 0} real job(s).`);\n if (result.jobs?.length) {\n window.location.hash = `/runs/${result.jobs[0]}`;\n }\n } catch (error) {\n showToast(`Could not run workflow: ${error.message}`);\n } finally {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n }\n };\n\n const render = async () => {\n const { path, query } = route();\n const [section, id] = path.split(\"/\").slice(1);\n const page = section || \"runs\";\n const changed = previousRoute !== window.location.hash;\n if (changed) {\n state.step = 0;\n state.detailTab = \"logs\";\n state.logSearch = \"\";\n if (section !== \"runs\" || !id) {\n state.run = null;\n }\n }\n previousRoute = window.location.hash;\n setNavigation(page === \"runs\" ? \"runs\" : [\"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\", id);\n if (path === \"/runs\" || path === \"\") {\n const workflowFilter = query.get(\"workflow\") || \"all\";\n const searchFilter = query.get(\"search\") || \"\";\n if (workflowFilter !== state.workflow || searchFilter !== state.search) {\n state.jobs = [];\n state.jobsLoaded = false;\n state.hasMore = false;\n }\n state.workflow = workflowFilter;\n state.search = searchFilter;\n const statusFilter = query.get(\"status\");\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(statusFilter)) {\n state.status = statusFilter;\n }\n if (!state.jobsLoaded) {\n await loadJobs();\n }\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n renderRuns();\n if (changed) {\n window.scrollTo(0, 0);\n }\n return;\n }\n if (section === \"runs\" && id) {\n await liveRunPage(id);\n return;\n }\n if (path === \"/workflows\") {\n await liveWorkflowsPage();\n return;\n }\n if (path === \"/workers\") {\n await liveWorkersPage();\n return;\n }\n if (path === \"/settings\") {\n await liveSettingsPage();\n return;\n }\n showNotFound();\n };\n\n const action = async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return false;\n }\n const { action: name, value } = button.dataset;\n if (name === \"new-run\") {\n await openDispatchDialog();\n return true;\n }\n if (name === \"close-dialog\") {\n dialog.close();\n return true;\n }\n if (name === \"dispatch-confirm\") {\n await dispatchWorkflow();\n return true;\n }\n if (name === \"retry-runs\") {\n state.jobsLoaded = false;\n state.errors.jobs = null;\n await loadJobs();\n renderRuns();\n return true;\n }\n if (name === \"retry-workflows\") {\n state.workflowsLoaded = false;\n state.errors.workflows = null;\n await liveWorkflowsPage();\n return true;\n }\n if (name === \"workflow-status-tab\") {\n state.workflowFilter = value;\n render();\n return true;\n }\n if (name === \"retry-workers\") {\n state.workersLoaded = false;\n state.errors.workers = null;\n await liveWorkersPage();\n return true;\n }\n if (name === \"retry-secrets\") {\n state.secretsLoaded = false;\n state.errors.secrets = null;\n await liveSettingsPage();\n return true;\n }\n if (name === \"add-secret\") {\n const name = window.prompt(\"Enter secret name (uppercase letters, numbers, underscores):\");\n if (!name) {\n return true;\n }\n if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {\n showToast(\"Secret name must start with a letter and contain only letters, numbers, and underscores.\");\n return true;\n }\n const secretValue = window.prompt(`Enter the value for ${name}:`);\n if (secretValue === null || secretValue.length === 0) {\n showToast(\"Secret value cannot be empty.\");\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify({ value: secretValue, encoding: \"utf8\" }),\n });\n showToast(`Secret ${name} added.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to add secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"remove-secret\") {\n const name = value;\n if (!window.confirm(`Delete secret ${name}?`)) {\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, { method: \"DELETE\" });\n showToast(`Secret ${name} removed.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to remove secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"status-filter\") {\n state.status = value;\n state.page = 1;\n renderRunsTable();\n button.focus({ preventScroll: true });\n return true;\n }\n if (name === \"clear-filters\") {\n state.search = \"\";\n state.status = \"all\";\n state.workflow = \"all\";\n state.worker = \"all\";\n state.page = 1;\n renderRuns();\n document.querySelector(\"#run-search\")?.focus();\n return true;\n }\n if (name === \"prev-page\" || name === \"next-page\") {\n const pages = Math.max(1, Math.ceil(filteredJobs().length / 8));\n if (name === \"next-page\" && state.page === pages && state.hasMore) {\n await loadJobs({ older: true });\n }\n state.page = Math.max(1, state.page + (name === \"next-page\" ? 1 : -1));\n renderRunsTable();\n return true;\n }\n if (name === \"detail-tab\" && state.run) {\n state.detailTab = value;\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"step\" && state.run) {\n state.step = Number(value);\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"jump-error\" && state.run) {\n const failed = state.run.steps?.findIndex((step) => step.status === \"failed\") ?? -1;\n if (failed >= 0) {\n state.step = failed;\n await renderRunTab(state.run);\n }\n return true;\n }\n if (name === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\")?.classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n return true;\n }\n if (name === \"copy-log\" && state.run) {\n try {\n await navigator.clipboard.writeText(state.run.steps?.[state.step]?.logContent || \"\");\n showToast(\"Step log copied.\");\n } catch {\n showToast(\"Clipboard unavailable.\");\n }\n return true;\n }\n if (name === \"download-logs\" && state.run) {\n const text = (state.run.steps || []).map((step) => `=== ${step.name} ===\\n${step.logContent || \"\"}`).join(\"\\n\\n\");\n downloadBlob(new Blob([text], { type: \"text/plain;charset=utf-8\" }), `flow-run-${state.run.jobId}.log`);\n return true;\n }\n if (name === \"download-artifact\" && state.run) {\n const artifact = state.run.artifacts?.[Number(value)];\n if (!artifact) {\n return true;\n }\n const encoded = String(artifact).split(\"/\").map(encodeURIComponent).join(\"/\");\n try {\n const blob = await apiBlob(`/api/runs/${encodeURIComponent(state.run.jobId)}/artifacts/${encoded}`);\n downloadBlob(blob, String(artifact).split(\"/\").pop() || \"artifact\");\n } catch (error) {\n showToast(`Download failed: ${error.message}`);\n }\n return true;\n }\n if (name === \"workflow-source\") {\n const workflow = state.workflows[Number(value)];\n if (workflow) {\n openDialog(\n workflow.name || workflow.id,\n `<p class=\"dialog-description\">Revision ${escape(workflow.revision || \"—\")} · ${escape(\n workflow.status || \"\"\n )}</p><div class=\"source-view\"><pre>${escape(\n workflow.sourceYaml || \"Workflow source unavailable.\"\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n return true;\n }\n if (name === \"validate-workflow\") {\n const source = workflowFormSource();\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n apiJson(`/api/workflows/validate`, {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source }),\n })\n .then((result) => {\n const status = result.valid ? \"passed\" : \"failed\";\n showToast(`Validation ${status}: ${result.error || \"\"}`);\n })\n .catch((error) => {\n showToast(`Validation failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"run-now\") {\n const id = document.getElementById(\"new-workflow-id\")?.value.trim();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const source = workflowFormSource();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n if (!id || !name) {\n showToast(\"Please enter a workflow name and ID first.\");\n return true;\n }\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return true;\n }\n if (!enabled) {\n showToast(\"Enable the workflow before running it.\");\n return true;\n }\n void (async () => {\n try {\n await apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n });\n const result = await apiJson(`/api/workflows/${encodeURIComponent(id)}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showToast(\n `Queued ${result.jobs?.length || 0} job${result.jobs?.length === 1 ? \"\" : \"s\"} from revision ${\n result.revision\n }.`\n );\n } catch (error) {\n showToast(`Failed to run workflow: ${error.message}`);\n }\n })();\n return true;\n }\n if (name === \"workflow-revision\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n const dir = Number(button.dataset.dir);\n const targetRev = (workflow.revision || 1) + dir;\n if (targetRev < 1) {\n return true;\n }\n loadRevisionDiff(workflow.id, targetRev);\n return true;\n }\n if (name === \"workflow-publish\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n if (!window.confirm(`Publish workflow ${workflow.name || workflow.id}? This will make it available for runs.`)) {\n return true;\n }\n apiJson(`/api/workflows/${workflow.id}/publish`, { method: \"POST\" })\n .then((result) => {\n showToast(`Published ${result.id || workflow.id} revision ${result.revision}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Publish failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"rerun\" && state.run) {\n if (state.mutating) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n if (\n !window.confirm(\n `Re-run ${\n state.run.workflowName || state.run.workflowId || \"this workflow\"\n }? This creates a new job from the active workflow revision.`\n )\n ) {\n state.mutating = false;\n button.disabled = false;\n return true;\n }\n try {\n const result = await apiJson(`/restart/${encodeURIComponent(state.run.jobId)}`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Re-run queued: #${result.id}`);\n window.location.hash = `/runs/${result.id}`;\n } catch (error) {\n showToast(`Re-run failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n if (name === \"cancel\" && state.run) {\n if (state.mutating) {\n return true;\n }\n if (\n ![\"pending\", \"running\"].includes(state.run.status) ||\n !window.confirm(`Cancel live run #${state.run.jobId}?`)\n ) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n try {\n await apiJson(`/api/jobs/${encodeURIComponent(state.run.jobId)}/cancel`, { method: \"POST\" });\n showToast(`Run #${state.run.jobId} cancelled.`);\n await liveRunPage(state.run.jobId);\n state.jobsLoaded = false;\n await loadJobs();\n } catch (error) {\n showToast(`Cancel failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n return false;\n };\n\n const setupEvents = () => {\n if (events) {\n return;\n }\n events = new EventSource(\"/api/events\");\n events.addEventListener(\"jobs.available\", () => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n });\n events.addEventListener(\"jobs.changed\", (event) => {\n let data = {};\n try {\n data = JSON.parse(event.data || \"{}\");\n } catch {\n /* Refresh the list even if an event payload is malformed. */\n }\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n if (state.run && (!data.jobId || String(data.jobId) === String(state.run.jobId))) {\n void liveRunPage(state.run.jobId);\n }\n });\n events.onerror = () => {\n /* EventSource retries transient disconnects automatically. */\n };\n pollTimer = window.setInterval(() => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n }, 60_000);\n };\n\n document.addEventListener(\"click\", async (event) => {\n const handled = await action(event);\n if (handled) {\n event.preventDefault();\n }\n });\n document.addEventListener(\"submit\", (event) => {\n if (event.target?.id === \"new-workflow-form\") {\n event.preventDefault();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const id = document.getElementById(\"new-workflow-id\")?.value.trim().toLowerCase();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n const source = workflowFormSource();\n if (!name || !id || !source) {\n showToast(\"Please fill in all fields (name, id, and source).\");\n return;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return;\n }\n apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n })\n .then(() => {\n showToast(`Created workflow ${id}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Failed to create workflow: ${error.message}`);\n });\n }\n });\n document.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"log-search\") {\n state.logSearch = event.target.value;\n if (state.run) {\n renderLiveLog(state.run.steps?.[state.step]?.logContent || \"\");\n }\n }\n });\n document.addEventListener(\"change\", (event) => {\n if (event.target.id === \"workflow-filter\") {\n state.workflow = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"worker-filter\") {\n state.worker = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"compact-setting\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n localStorage.setItem(\"flow-concept-compact\", String(event.target.checked));\n }\n if (event.target.id === \"wrap-setting\") {\n state.wrap = event.target.checked;\n localStorage.setItem(\"flow-concept-wrap\", String(event.target.checked));\n }\n });\n window.addEventListener(\"hashchange\", () => {\n state.run = null;\n void render();\n });\n window.addEventListener(\"beforeunload\", () => {\n events?.close();\n window.clearInterval(pollTimer);\n });\n document.querySelectorAll(\"[data-icon]\").forEach((node) => {\n node.innerHTML = icon(node.dataset.icon);\n });\n document.body.classList.toggle(\"compact\", localStorage.getItem(\"flow-concept-compact\") === \"true\");\n state.wrap = localStorage.getItem(\"flow-concept-wrap\") === \"true\";\n const initialQuery = route().query;\n state.workflow = initialQuery.get(\"workflow\") || \"all\";\n state.search = initialQuery.get(\"search\") || \"\";\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(initialQuery.get(\"status\"))) {\n state.status = initialQuery.get(\"status\");\n }\n main.innerHTML = `${pageHeading(\"Runs\", \"Runs\")}<div class=\"empty-state\">${icon(\"clock\")}<p>Loading runs…</p></div>`;\n setupEvents();\n void Promise.all([loadWorkflows(), loadJobs()]).then(() => {\n state.jobsLoaded = true;\n void render();\n });\n}\n", gg = 50, _g = 500, vg = class e {
|
|
12314
|
+
var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigator, URL, URLSearchParams, Headers, EventSource, Blob */\n\nconst icons = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n};\n\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n icons[name] || icons.runs\n }</svg>`;\nconst escape = (value) =>\n String(value ?? \"\").replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${escape(status)}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst iconForStatus = statusIcon;\nconst badge = (status) =>\n `<span class=\"status ${escape(status)}\">${statusIcon(status)}${escape(labels[status] || status || \"Unknown\")}</span>`;\n\nexport function mountLivePreview() {\n const main = document.querySelector(\"#main\");\n const dialog = document.querySelector(\"#dialog\");\n const state = {\n jobs: [],\n workflows: [],\n workers: [],\n secrets: [],\n hasMore: false,\n loadingJobs: false,\n jobsLoaded: false,\n workflowsLoaded: false,\n workersLoaded: false,\n secretsLoaded: false,\n errors: {},\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n worker: \"all\",\n workflowFilter: \"all\",\n page: 1,\n run: null,\n mutating: false,\n detailTab: \"logs\",\n step: 0,\n logSearch: \"\",\n wrap: false,\n };\n let apiClient;\n let events;\n let previousRoute = \"\";\n let jobsLoadPromise;\n let refreshJobsAfterCurrentLoad = false;\n\n const route = () => {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URLSearchParams(query) };\n };\n\n const client = async () => {\n apiClient ??= import(\"/api-client.mjs\");\n return apiClient;\n };\n\n const apiJson = async (path, options = {}) => {\n const { apiFetch } = await client();\n const headers = new Headers(options.headers || {});\n headers.set(\"accept\", \"application/json\");\n if (options.body && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n const response = await apiFetch(path, { ...options, headers });\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n const error = new Error(payload.error || `Request failed (${response.status})`);\n error.status = response.status;\n throw error;\n }\n return response.status === 204 ? null : response.json();\n };\n\n const apiBlob = async (path) => {\n const { apiFetch } = await client();\n const response = await apiFetch(path);\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n throw new Error(payload.error || `Download failed (${response.status})`);\n }\n return response.blob();\n };\n\n const showToast = (message) => {\n const toast = document.querySelector(\"#toast\");\n toast.textContent = message;\n toast.hidden = false;\n window.clearTimeout(showToast.timer);\n showToast.timer = window.setTimeout(() => {\n toast.hidden = true;\n }, 4500);\n };\n\n const dateValue = (value) => {\n if (!value) {\n return null;\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n return Number.isNaN(date.getTime()) ? null : date;\n };\n\n const formatDate = (value) => {\n const date = dateValue(value);\n return date ? new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date) : \"—\";\n };\n\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) {\n return \"—\";\n }\n const seconds = Math.floor(value / 1000);\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n };\n\n const workflowName = (workflowId) =>\n state.workflows.find((workflow) => workflow.id === workflowId)?.name || workflowId || \"Unknown workflow\";\n const pageHeading = (title, subtitle, action = \"\") =>\n `<div class=\"page-heading\"><div><h1>${escape(title)}</h1>${\n subtitle ? `<p class=\"subtitle\">${escape(subtitle)}</p>` : \"\"\n }</div>${action}</div>`;\n const runWorkflowButton = () =>\n `<button class=\"button primary\" data-action=\"new-run\">${icon(\"plus\")}Run workflow</button>`;\n const permissionMessage = (error, scope) =>\n [401, 403].includes(error?.status)\n ? `Your account needs the ${scope} permission to view this data.`\n : error?.message || \"The Flow API could not be reached.\";\n\n const setNavigation = (page, jobId = \"\") => {\n const currentJob = jobId ? state.jobs.find((job) => String(job.id) === String(jobId)) : null;\n const currentJobId = currentJob?.id ?? jobId;\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, name]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${page === id ? \"active\" : \"\"}\" ${\n page === id ? 'aria-current=\"page\"' : \"\"\n }>${icon(name)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n state.jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n document.querySelector(\"#breadcrumbs\").innerHTML = `${\n jobId\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${escape(\n currentJobId\n )}</strong>`\n : `<strong>${escape(page[0].toUpperCase() + page.slice(1))}</strong>`\n }`;\n document.title = `${jobId ? `Run #${currentJobId}` : page[0].toUpperCase() + page.slice(1)} · Flow`;\n };\n const setBreadcrumbs = (page, jobId = null) => setNavigation(page, jobId);\n\n const loadJobs = async ({ older = false } = {}) => {\n if (jobsLoadPromise) {\n if (!older) {\n refreshJobsAfterCurrentLoad = true;\n }\n await jobsLoadPromise;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n return;\n }\n state.loadingJobs = true;\n const beforeId = older && state.jobs.length ? Math.min(...state.jobs.map((job) => Number(job.id))) : null;\n jobsLoadPromise = (async () => {\n try {\n const query = new URLSearchParams({ limit: older ? \"50\" : \"100\" });\n if (beforeId) {\n query.set(\"beforeId\", String(beforeId));\n }\n if (state.workflow !== \"all\") {\n query.set(\"workflowId\", state.workflow);\n }\n const data = await apiJson(`/api/jobs?${query}`);\n if (older) {\n const merged = new Map(state.jobs.map((job) => [String(job.id), job]));\n for (const job of data.jobs || []) {\n merged.set(String(job.id), job);\n }\n state.jobs = [...merged.values()].sort((a, b) => Number(b.id) - Number(a.id));\n } else {\n state.jobs = data.jobs || [];\n }\n state.hasMore = Boolean(data.hasMore);\n state.errors.jobs = null;\n state.jobsLoaded = true;\n } catch (error) {\n state.errors.jobs = error;\n state.jobsLoaded = true;\n } finally {\n state.loadingJobs = false;\n }\n })();\n try {\n await jobsLoadPromise;\n } finally {\n jobsLoadPromise = undefined;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n }\n };\n\n const loadWorkflows = async ({ retry = false } = {}) => {\n if (state.workflowsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workflows\");\n state.workflows = data.workflows || [];\n state.errors.workflows = null;\n } catch (error) {\n state.errors.workflows = error;\n } finally {\n state.workflowsLoaded = true;\n }\n };\n\n const loadWorkers = async ({ retry = false } = {}) => {\n if (state.workersLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workers\");\n state.workers = data.workers || [];\n state.errors.workers = null;\n } catch (error) {\n state.errors.workers = error;\n } finally {\n state.workersLoaded = true;\n }\n };\n\n const loadSecrets = async ({ retry = false } = {}) => {\n if (state.secretsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/secrets\");\n state.secrets = data.secrets || [];\n state.errors.secrets = null;\n } catch (error) {\n state.errors.secrets = error;\n } finally {\n state.secretsLoaded = true;\n }\n };\n\n const workflowFormSource = () => {\n const source = document.getElementById(\"new-workflow-source\")?.value.trim() || \"\";\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n if (!source || !name) {\n return source;\n }\n const yamlName = `name: ${JSON.stringify(name)}`;\n return /^name\\s*:/m.test(source) ? source.replace(/^name\\s*:[^\\r\\n]*/m, yamlName) : `${yamlName}\\n${source}`;\n };\n\n const filteredJobs = () =>\n state.jobs.filter((job) => {\n const text = [job.id, job.workflowId, workflowName(job.workflowId), job.status, job.workerId]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status)) &&\n (state.workflow === \"all\" || job.workflowId === state.workflow) &&\n (state.worker === \"all\" || (state.worker === \"queued\" ? !job.workerId : job.workerId === state.worker))\n );\n });\n\n const renderRunsTable = () => {\n const filtered = filteredJobs();\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(state.page, pages);\n const rows = filtered.slice((state.page - 1) * 8, state.page * 8);\n const target = document.querySelector(\"#run-results\");\n if (!target) {\n return;\n }\n target.innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n state.jobs.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n rows.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Live Flow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / run</th><th scope=\"col\">Status</th><th scope=\"col\">Worker</th><th scope=\"col\">Updated</th><th scope=\"col\">Created</th></tr></thead><tbody>${rows\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${encodeURIComponent(job.id)}\" aria-label=\"Run ${escape(\n job.id\n )}: ${escape(workflowName(job.workflowId))}, ${escape(\n labels[job.status] || job.status\n )}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${escape(\n workflowName(job.workflowId)\n )}</span><span class=\"run-subtitle\"><span class=\"mono\">#${escape(\n job.id\n )}</span><span class=\"separator\">·</span><span class=\"mono\">${escape(\n job.workflowId\n )}</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"server\"\n )}${escape(job.workerId || \"Queued\")}</span></td><td>${escape(\n formatDate(job.updatedAt)\n )}</td><td>${escape(formatDate(job.createdAt))}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\"search\")}<h3>${\n state.errors.jobs ? \"Runs unavailable\" : \"No runs found\"\n }</h3><p>${escape(state.errors.jobs?.message || \"Try a different search or filter.\")}</p>${\n state.errors.jobs\n ? '<button class=\"button\" data-action=\"retry-runs\">Retry</button>'\n : '<button class=\"button\" data-action=\"clear-filters\">Clear filters</button>'\n }</div>`\n }\n <div class=\"table-footer\"><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages && !state.hasMore ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div>${\n state.hasMore ? '<div class=\"table-hint\">Older Flow runs are available.</div>' : \"\"\n }`;\n };\n\n const renderRuns = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `${pageHeading(\"Runs\", \"\", runWorkflowButton())}${\n state.loadingJobs && !state.jobs.length ? '<div class=\"empty-state\"><p>Loading runs…</p></div>' : \"\"\n }<section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Recent runs</h2></div><div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, workflows, or workers…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label><select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${escape(\n workflow.name\n )}</option>`\n )\n .join(\n \"\"\n )}</select><select id=\"worker-filter\" class=\"select-filter\" aria-label=\"Filter by worker\"><option value=\"all\">All workers</option><option value=\"queued\" ${\n state.worker === \"queued\" ? \"selected\" : \"\"\n }>Queued</option>${[...new Set(state.jobs.map((job) => job.workerId).filter(Boolean))]\n .map(\n (worker) =>\n `<option value=\"${escape(worker)}\" ${state.worker === worker ? \"selected\" : \"\"}>${escape(worker)}</option>`\n )\n .join(\"\")}</select></div><div id=\"run-results\"></div></section>`;\n renderRunsTable();\n };\n\n const renderRunDetails = (run) => {\n setBreadcrumbs(\"runs\", run.jobId);\n const inputs = run.inputs || {};\n const failedStep = (run.steps || []).find((step) => step.status === \"failed\");\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\"back\")}All runs</a>\n <div class=\"page-heading run-heading\"><div><h1>${iconForStatus(run.status)}${escape(\n run.workflowName || workflowName(run.workflowId)\n )}</h1><p class=\"subtitle\"><span class=\"mono\">#${escape(run.jobId)}</span><span>·</span><span>${escape(\n inputs.full_name || inputs.repo || run.workflowId || \"Workflow run\"\n )}</span></p></div>\n <div class=\"heading-actions\">${\n run.canViewLogs\n ? `<button class=\"button\" data-action=\"download-logs\">${icon(\"download\")}Download logs</button>`\n : \"\"\n }<button class=\"button ${[\"running\", \"pending\"].includes(run.status) ? \"danger\" : \"primary\"}\" data-action=\"${\n [\"running\", \"pending\"].includes(run.status) ? \"cancel\" : \"rerun\"\n }\" ${state.mutating ? \"disabled\" : \"\"}>${icon([\"running\", \"pending\"].includes(run.status) ? \"stop\" : \"refresh\")}${\n [\"running\", \"pending\"].includes(run.status) ? \"Cancel run\" : \"Re-run workflow\"\n }</button></div></div>\n ${\n failedStep\n ? `<div class=\"failure-banner\">${icon(\"alert\")}<div><strong>${escape(failedStep.name || \"Step\")} failed${\n failedStep.exitCode !== undefined ? ` with exit code ${escape(failedStep.exitCode)}` : \"\"\n }</strong><p>${escape(\n failedStep.error || \"See the failed step output.\"\n )}</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n run.status\n )}</span></div><div><span class=\"meta-label\">BRANCH</span><span class=\"meta-value\">${icon(\"branch\")}${escape(\n inputs.branch || inputs.ref?.replace(/^refs\\/heads\\//, \"\") || \"—\"\n )}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\"commit\")}${escape(\n (inputs.commit_sha || inputs.commit || \"—\").slice(0, 8)\n )}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${formatDuration(\n run.durationMs\n )}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\"server\")}${escape(\n run.workerId || \"Queued\"\n )}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n void renderRunTab(run);\n };\n\n const liveRunPage = async (id) => {\n main.innerHTML = `<div class=\"empty-state\">${icon(\"clock\")}<p>Loading run #${escape(id)}…</p></div>`;\n try {\n const run = await apiJson(`/api/runs/${encodeURIComponent(id)}`);\n state.errors.run = null;\n state.run = run;\n const importantStep = run.steps?.findIndex((step) => [\"failed\", \"running\"].includes(step.status)) ?? -1;\n if (importantStep >= 0) {\n state.step = importantStep;\n }\n renderRunDetails(run);\n } catch (error) {\n state.errors.run = error;\n main.innerHTML = `<div class=\"empty-state\">${icon(\"alert\")}<h1>Run unavailable</h1><p>${escape(\n error.message\n )}</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n };\n\n const renderRunTab = async (run) => {\n const content = document.querySelector(\"#detail-content\");\n if (!content) {\n return;\n }\n if (state.detailTab === \"inputs\") {\n let source = run.workflowSourceYaml || \"\";\n if (!source && run.workflowId) {\n try {\n const workflow = await apiJson(\n `/api/workflows/${encodeURIComponent(run.workflowId)}?revision=${encodeURIComponent(\n run.workflowRevision || \"\"\n )}`\n );\n source = workflow.sourceYaml || \"\";\n } catch {\n /* Workflow source has separate permission checks. */\n }\n }\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs</h3><pre>${escape(\n JSON.stringify(run.inputs || {}, null, 2)\n )}</pre></section>${\n source\n ? `<section class=\"source-view\" style=\"margin-top:16px\"><h3>Workflow source · revision ${escape(\n run.workflowRevision || \"—\"\n )}</h3><pre>${escape(source)}</pre></section>`\n : '<p class=\"settings-note\" style=\"margin-top:14px\">Workflow source is unavailable for this run.</p>'\n }`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n const artifacts = run.artifacts || [];\n content.innerHTML = artifacts.length\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">${\n artifacts.length\n }</span></h2>${artifacts\n .map(\n (path, index) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${escape(\n path.split(\"/\").pop() || path\n )}</strong><p>${escape(\n path\n )}</p></div><button class=\"button\" data-action=\"download-artifact\" data-value=\"${index}\">${icon(\n \"download\"\n )}Download</button></div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No artifacts</h3><p>This run did not publish any artifacts.</p></div>';\n return;\n }\n if (state.detailTab === \"history\") {\n const history = await loadRunHistory(run);\n content.innerHTML =\n history.length > 1\n ? `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${encodeURIComponent(\n entry.jobId\n )}\">Run #${escape(entry.jobId)}</a></strong><p>${escape(\n entry.workflowName || entry.workflowId || \"Workflow\"\n )} · ${formatDate(entry.startedAt)}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No previous attempts</h3><p>This run has no parent run linked to it.</p></div>';\n return;\n }\n const steps = run.steps || [];\n if (!steps.length) {\n content.innerHTML =\n '<div class=\"empty-state\"><h3>No execution steps yet</h3><p>Step details will appear when Flow records them.</p></div>';\n return;\n }\n state.step = Math.max(0, Math.min(state.step, steps.length - 1));\n const selected = steps[state.step];\n const stepButtons = steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\");\n if (!run.canViewLogs) {\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${stepButtons}</aside><div class=\"log-panel\"><div class=\"empty-state\">${icon(\n \"lock\"\n )}<h3>Logs require permission</h3><p>Your account needs <span class=\"mono\">logs:read</span> to view step output.</p></div></div></section>`;\n return;\n }\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\")}</aside><div class=\"log-panel\"><div class=\"log-heading\"><h3>${escape(selected.name)}<small>${\n selected.exitCode !== undefined\n ? `exit code ${escape(selected.exitCode)}`\n : escape(labels[selected.status] || selected.status)\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\">${icon(\"wrap\")}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logSearch\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Step failed\"\n : selected.status === \"running\"\n ? \"Step is running\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>`;\n renderLog(selected.logContent || \"\");\n };\n\n const loadRunHistory = async (run) => {\n const entries = [run];\n const visited = new Set([String(run.jobId)]);\n let parent = run.parentId;\n while (parent && !visited.has(String(parent)) && entries.length < 25) {\n visited.add(String(parent));\n try {\n const entry = await apiJson(`/api/runs/${encodeURIComponent(parent)}`);\n entries.push(entry);\n parent = entry.parentId;\n } catch {\n break;\n }\n }\n return entries;\n };\n\n const renderLog = (text) => {\n const log = document.querySelector(\"#log-lines\");\n if (!log) {\n return;\n }\n const query = state.logSearch.toLowerCase();\n let matches = 0;\n log.innerHTML = String(text)\n .split(\"\\n\")\n .map((line, index) => {\n let content = escape(line) || \" \";\n const start = query ? line.toLowerCase().indexOf(query) : -1;\n if (start >= 0) {\n matches++;\n content = `${escape(line.slice(0, start))}<mark>${escape(\n line.slice(start, start + query.length)\n )}</mark>${escape(line.slice(start + query.length))}`;\n }\n const type = /\\b(error|failed|fatal)\\b/i.test(line)\n ? \"error\"\n : /\\b(success|passed|done)\\b/i.test(line)\n ? \"pass\"\n : \"\";\n return `<div class=\"log-line ${type}\"><span class=\"line-number\">${\n index + 1\n }</span><span class=\"line-text\">${content}</span></div>`;\n })\n .join(\"\");\n const result = document.querySelector(\"#log-matches\");\n if (result) {\n result.textContent = query ? `${matches} matching lines` : \"\";\n }\n };\n const renderLiveLog = renderLog;\n\n const downloadBlob = (blob, filename) => {\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const renderRevisionDiff = (previousSource, currentSource) => {\n const before = previousSource === null ? [] : previousSource.split(\"\\n\");\n const after = String(currentSource ?? \"\").split(\"\\n\");\n let prefixLength = 0;\n while (\n prefixLength < before.length &&\n prefixLength < after.length &&\n before[prefixLength] === after[prefixLength]\n ) {\n prefixLength++;\n }\n let suffixLength = 0;\n while (\n suffixLength < before.length - prefixLength &&\n suffixLength < after.length - prefixLength &&\n before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]\n ) {\n suffixLength++;\n }\n const renderLines = (lines, className, prefix) =>\n lines\n .filter((line, index) => line || index < lines.length - 1)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escape(line)}</span>`)\n .join(\"\");\n return [\n renderLines(before.slice(0, prefixLength), \"text-gray-300\", \" \"),\n renderLines(before.slice(prefixLength, before.length - suffixLength), \"text-rose-300 bg-rose-500/10\", \"- \"),\n renderLines(after.slice(prefixLength, after.length - suffixLength), \"text-emerald-300 bg-emerald-500/10\", \"+ \"),\n renderLines(before.slice(before.length - suffixLength), \"text-gray-300\", \" \"),\n ].join(\"\");\n };\n\n const loadRevisionDiff = async (workflowId, revision) => {\n try {\n const current = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision}`);\n const previous =\n revision > 1\n ? await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision - 1}`)\n : null;\n const sourceDiff = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n openDialog(\n `Workflow ${current.name || current.id}`,\n `<p class=\"dialog-description\">${escape(current.name || current.id)} — Revision ${escape(\n current.revision\n )}</p><div class=\"source-view\"><pre>${sourceDiff}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n } catch (error) {\n showToast(`Failed to load revision diff: ${error.message}`);\n }\n };\n\n const liveWorkflowsPage = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n setBreadcrumbs(\"workflows\");\n const tabs = [\n { id: \"all\", label: \"All\", filter: () => true },\n { id: \"published\", label: \"Published\", filter: (workflow) => workflow.status === \"published\" },\n { id: \"draft\", label: \"Drafts\", filter: (workflow) => workflow.status === \"draft\" },\n { id: \"archived\", label: \"Archived\", filter: (workflow) => workflow.status === \"archived\" },\n ];\n const selectedTab = tabs.find((tab) => tab.id === state.workflowFilter) || tabs[0];\n const visibleWorkflows = state.workflows.filter(selectedTab.filter);\n const tabButtons = tabs\n .map((tab) => {\n const count = state.workflows.filter(tab.filter).length;\n return `<button class=\"status-tab ${\n state.workflowFilter === tab.id ? \"active\" : \"\"\n }\" data-action=\"workflow-status-tab\" data-value=\"${tab.id}\" aria-pressed=\"${state.workflowFilter === tab.id}\">${\n tab.label\n }<span class=\"count\">${count}</span></button>`;\n })\n .join(\"\");\n const workflowRows = visibleWorkflows\n .map((workflow) => {\n const index = state.workflows.indexOf(workflow);\n return `<div class=\"workflow-item\"><div class=\"workflow-header\"><span class=\"workflow-icon\">${icon(\n \"workflow\"\n )}</span><span class=\"workflow-name\">${escape(\n workflow.name || workflow.id\n )}</span><span class=\"workflow-status ${\n workflow.status === \"published\" ? \"success\" : workflow.status === \"archived\" ? \"disabled\" : \"enabled\"\n }\">${escape(workflow.status)}</span></div><div class=\"workflow-meta\"><span>Revision ${escape(\n workflow.revision || \"—\"\n )}</span><span class=\"workflow-id mono\">${escape(\n workflow.id\n )}</span></div><div class=\"workflow-actions\"><a class=\"text-button\" href=\"#/runs?workflow=${encodeURIComponent(\n workflow.id\n )}\">View runs ${icon(\"arrow\")}</a><a class=\"button\" href=\"/settings/workflows/${encodeURIComponent(\n workflow.id\n )}\" target=\"_top\">Edit ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}Source</button>${\n workflow.revision > 1\n ? `<button class=\"button small\" data-action=\"workflow-revision\" data-value=\"${index}\" data-dir=\"-1\">Previous revision</button>`\n : \"\"\n }${\n workflow.status !== \"published\"\n ? `<button class=\"button small success\" data-action=\"workflow-publish\" data-value=\"${index}\">Publish ${icon(\n \"rocket\"\n )}</button>`\n : \"\"\n }</div></div>`;\n })\n .join(\"\");\n const content =\n workflowRows ||\n `<div class=\"empty-state\"><h3>No ${escape(selectedTab.label.toLowerCase())} workflows</h3><p>${\n state.workflows.length\n ? \"Choose another status tab or create a workflow.\"\n : \"Flow has no workflow definitions to show.\"\n }</p></div>`;\n main.innerHTML = `${pageHeading(\"Workflows\", \"Workflow definitions\", runWorkflowButton())}${\n state.errors.workflows\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Workflows unavailable</h3><p>${escape(\n permissionMessage(state.errors.workflows, \"workflows:read\")\n )}</p><button class=\"button\" data-action=\"retry-workflows\">Retry</button></div>`\n : `<div class=\"status-tabs\" role=\"group\" aria-label=\"Filter workflows by status\">${tabButtons}</div><div class=\"workflow-list\">${content}</div>`\n }`;\n };\n\n const liveWorkersPage = async () => {\n if (!state.workersLoaded) {\n await loadWorkers();\n }\n setBreadcrumbs(\"workers\");\n main.innerHTML = `${pageHeading(\"Workers\", \"Live runner presence from Flow.\")}${\n state.errors.workers\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Worker status unavailable</h3><p>${escape(\n permissionMessage(state.errors.workers, \"workers:read\")\n )}</p><button class=\"button\" data-action=\"retry-workers\">Retry</button></div>`\n : `<div class=\"cards\">${\n state.workers\n .map(\n (worker) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\"server\")}<h2>${escape(\n worker.workerId || \"Unknown worker\"\n )}</h2><span class=\"status ${worker.online ? \"success\" : \"cancelled\"}\">${\n worker.online ? \"Online\" : \"Offline\"\n }</span></div><div class=\"card-details\">${\n (Array.isArray(worker.tags) ? worker.tags : [])\n .map((tag) => `<span class=\"tag mono\">${escape(tag)}</span>`)\n .join(\"\") || '<span class=\"settings-note\">No labels</span>'\n }</div><div class=\"worker-stats\"><div><span class=\"meta-label\">Active jobs</span><strong>${escape(\n worker.activeJobs ?? 0\n )}</strong></div><div><span class=\"meta-label\">Concurrency</span><strong>${escape(\n worker.concurrency ?? \"—\"\n )}</strong></div></div><div class=\"card-footer\"><span class=\"settings-note\">${\n worker.version ? `Runner ${escape(worker.version)}` : \"Runner version unavailable\"\n }</span><span class=\"settings-note\">Seen ${escape(\n formatDate(worker.lastSeen)\n )}</span></div></article>`\n )\n .join(\"\") ||\n '<div class=\"empty-state\"><h3>No workers reported</h3><p>Flow has no worker-presence records.</p></div>'\n }</div>`\n }`;\n };\n\n const liveSettingsPage = async () => {\n if (!state.secretsLoaded) {\n await loadSecrets();\n }\n setBreadcrumbs(\"settings\");\n main.innerHTML = `${pageHeading(\n \"Settings\",\n \"Preferences and secret names from Flow.\"\n )}<section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n localStorage.getItem(\"flow-concept-compact\") === \"true\" ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secret names</h2><p class=\"settings-note\">Values are never requested or displayed in this preview.</p>${\n state.errors.secrets\n ? `<p class=\"settings-note\">${escape(\n permissionMessage(state.errors.secrets, \"secrets:read\")\n )}</p><button class=\"button\" data-action=\"retry-secrets\">Retry</button>`\n : state.secrets\n .map(\n (name) =>\n `<div class=\"setting-row\"><strong class=\"mono\">${escape(\n name\n )}</strong><span class=\"secret-value\" aria-label=\"Secret value hidden\">••••••••••••</span><button class=\"button tiny danger\" data-action=\"remove-secret\" data-value=\"${name}\">Remove</button></div>`\n )\n .join(\"\") || '<p class=\"settings-note\">No secret names are configured.</p>'\n }${\n !state.errors.secrets && state.secrets.length < 5\n ? '<div class=\"setting-row\"><button class=\"button tiny\" data-action=\"add-secret\">Add secret</button></div>'\n : \"\"\n }</section><section class=\"settings-section\"><h2>Workflow editing</h2><p class=\"settings-note\">Create and edit workflow definitions.</p><form id=\"new-workflow-form\"><label class=\"form-field\"><span>Workflow name</span><input id=\"new-workflow-name\" type=\"text\" placeholder=\"e.g. build-docker-image\" required/></label><label class=\"form-field\"><span>Workflow ID</span><input id=\"new-workflow-id\" type=\"text\" placeholder=\"derived-from-name\" required/></label><label class=\"form-field\"><span>Enabled</span><input type=\"checkbox\" id=\"new-workflow-enabled\" checked/></label><div class=\"form-field\"><span>YAML source</span><textarea id=\"new-workflow-source\" rows=\"8\" placeholder=\"name: build-docker-image\\nruns-on: [main-server, docker]\\n\\nsteps:\\n - name: Checkout repository\\n run: git checkout '$COMMIT'\\n - name: Install dependencies\\n run: pnpm install --frozen-lockfile\\n - name: Run tests\\n run: pnpm test\\n - name: Build and push\\n run: pnpm publish\"></textarea></label></div><div class=\"form-footer\"><button type=\"button\" class=\"button\" data-action=\"validate-workflow\">Validate workflow</button><button type=\"button\" class=\"button\" data-action=\"run-now\">Run now</button><button type=\"submit\" class=\"button primary\">${icon(\n \"plus\"\n )}Create workflow</button><button type=\"button\" class=\"button\" data-action=\"close-dialog\">Cancel</button></div></form><a class=\"button secondary\" href=\"/settings/workflows\" target=\"_top\">Open full workflow settings ${icon(\n \"arrow\"\n )}</a></section>`;\n };\n\n const showNotFound = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `<div class=\"empty-state\"><h1>404 — Page not found</h1><p>This preview route does not exist.</p><a class=\"button\" href=\"#/runs\">Go to runs</a></div>`;\n };\n\n const openDialog = (title, content) => {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${escape(\n title\n )}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${content}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n };\n\n const openDispatchDialog = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n if (state.errors.workflows) {\n showToast(permissionMessage(state.errors.workflows, \"workflows:read\"));\n return;\n }\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">This will enqueue a real Flow job.</p><label class=\"form-field\"><span>Workflow</span><select id=\"dispatch-workflow\">${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${workflow.enabled === false ? \"disabled\" : \"\"}>${escape(\n workflow.name || workflow.id\n )}${workflow.enabled === false ? \" (disabled)\" : \"\"}</option>`\n )\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Inputs (JSON object)</span><textarea id=\"dispatch-inputs\" rows=\"6\">{}</textarea></label><p class=\"dialog-note\">Running a workflow requires write permission and creates real job(s).</p><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" data-action=\"dispatch-confirm\">${icon(\n \"runs\"\n )}Review and run</button></div>`\n );\n };\n\n const dispatchWorkflow = async () => {\n if (state.mutating) {\n return;\n }\n const workflowId = document.querySelector(\"#dispatch-workflow\")?.value;\n if (!workflowId) {\n showToast(\"Select a workflow first.\");\n return;\n }\n let inputs;\n try {\n inputs = JSON.parse(document.querySelector(\"#dispatch-inputs\")?.value || \"{}\");\n if (!inputs || typeof inputs !== \"object\" || Array.isArray(inputs)) {\n throw new Error(\"Inputs must be a JSON object.\");\n }\n } catch (error) {\n showToast(error.message);\n return;\n }\n const workflow = state.workflows.find((item) => item.id === workflowId);\n if (workflow?.enabled === false) {\n showToast(\"This workflow is disabled and cannot be run.\");\n return;\n }\n state.mutating = true;\n const dispatchButton = document.querySelector('[data-action=\"dispatch-confirm\"]');\n if (dispatchButton) {\n dispatchButton.disabled = true;\n }\n if (!window.confirm(`Run ${workflow?.name || workflowId} now? This creates real job(s).`)) {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n return;\n }\n try {\n const result = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {\n method: \"POST\",\n body: JSON.stringify({ inputs }),\n });\n dialog.close();\n state.jobs = [];\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Queued ${result.jobs?.length || 0} real job(s).`);\n if (result.jobs?.length) {\n window.location.hash = `/runs/${result.jobs[0]}`;\n }\n } catch (error) {\n showToast(`Could not run workflow: ${error.message}`);\n } finally {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n }\n };\n\n const render = async () => {\n const { path, query } = route();\n const [section, id] = path.split(\"/\").slice(1);\n const page = section || \"runs\";\n const changed = previousRoute !== window.location.hash;\n if (changed) {\n state.step = 0;\n state.detailTab = \"logs\";\n state.logSearch = \"\";\n if (section !== \"runs\" || !id) {\n state.run = null;\n }\n }\n previousRoute = window.location.hash;\n setNavigation(page === \"runs\" ? \"runs\" : [\"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\", id);\n if (path === \"/runs\" || path === \"\") {\n const workflowFilter = query.get(\"workflow\") || \"all\";\n const searchFilter = query.get(\"search\") || \"\";\n if (workflowFilter !== state.workflow || searchFilter !== state.search) {\n state.jobs = [];\n state.jobsLoaded = false;\n state.hasMore = false;\n }\n state.workflow = workflowFilter;\n state.search = searchFilter;\n const statusFilter = query.get(\"status\");\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(statusFilter)) {\n state.status = statusFilter;\n }\n if (!state.jobsLoaded) {\n await loadJobs();\n }\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n renderRuns();\n if (changed) {\n window.scrollTo(0, 0);\n }\n return;\n }\n if (section === \"runs\" && id) {\n await liveRunPage(id);\n return;\n }\n if (path === \"/workflows\") {\n await liveWorkflowsPage();\n return;\n }\n if (path === \"/workers\") {\n await liveWorkersPage();\n return;\n }\n if (path === \"/settings\") {\n await liveSettingsPage();\n return;\n }\n showNotFound();\n };\n\n const action = async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return false;\n }\n const { action: name, value } = button.dataset;\n if (name === \"new-run\") {\n await openDispatchDialog();\n return true;\n }\n if (name === \"close-dialog\") {\n dialog.close();\n return true;\n }\n if (name === \"dispatch-confirm\") {\n await dispatchWorkflow();\n return true;\n }\n if (name === \"retry-runs\") {\n state.jobsLoaded = false;\n state.errors.jobs = null;\n await loadJobs();\n renderRuns();\n return true;\n }\n if (name === \"retry-workflows\") {\n state.workflowsLoaded = false;\n state.errors.workflows = null;\n await liveWorkflowsPage();\n return true;\n }\n if (name === \"workflow-status-tab\") {\n state.workflowFilter = value;\n render();\n return true;\n }\n if (name === \"retry-workers\") {\n state.workersLoaded = false;\n state.errors.workers = null;\n await liveWorkersPage();\n return true;\n }\n if (name === \"retry-secrets\") {\n state.secretsLoaded = false;\n state.errors.secrets = null;\n await liveSettingsPage();\n return true;\n }\n if (name === \"add-secret\") {\n const name = window.prompt(\"Enter secret name (uppercase letters, numbers, underscores):\");\n if (!name) {\n return true;\n }\n if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {\n showToast(\"Secret name must start with a letter and contain only letters, numbers, and underscores.\");\n return true;\n }\n const secretValue = window.prompt(`Enter the value for ${name}:`);\n if (secretValue === null || secretValue.length === 0) {\n showToast(\"Secret value cannot be empty.\");\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify({ value: secretValue, encoding: \"utf8\" }),\n });\n showToast(`Secret ${name} added.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to add secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"remove-secret\") {\n const name = value;\n if (!window.confirm(`Delete secret ${name}?`)) {\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, { method: \"DELETE\" });\n showToast(`Secret ${name} removed.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to remove secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"status-filter\") {\n state.status = value;\n state.page = 1;\n renderRunsTable();\n button.focus({ preventScroll: true });\n return true;\n }\n if (name === \"clear-filters\") {\n state.search = \"\";\n state.status = \"all\";\n state.workflow = \"all\";\n state.worker = \"all\";\n state.page = 1;\n renderRuns();\n document.querySelector(\"#run-search\")?.focus();\n return true;\n }\n if (name === \"prev-page\" || name === \"next-page\") {\n const pages = Math.max(1, Math.ceil(filteredJobs().length / 8));\n if (name === \"next-page\" && state.page === pages && state.hasMore) {\n await loadJobs({ older: true });\n }\n state.page = Math.max(1, state.page + (name === \"next-page\" ? 1 : -1));\n renderRunsTable();\n return true;\n }\n if (name === \"detail-tab\" && state.run) {\n state.detailTab = value;\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"step\" && state.run) {\n state.step = Number(value);\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"jump-error\" && state.run) {\n const failed = state.run.steps?.findIndex((step) => step.status === \"failed\") ?? -1;\n if (failed >= 0) {\n state.step = failed;\n await renderRunTab(state.run);\n }\n return true;\n }\n if (name === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\")?.classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n return true;\n }\n if (name === \"copy-log\" && state.run) {\n try {\n await navigator.clipboard.writeText(state.run.steps?.[state.step]?.logContent || \"\");\n showToast(\"Step log copied.\");\n } catch {\n showToast(\"Clipboard unavailable.\");\n }\n return true;\n }\n if (name === \"download-logs\" && state.run) {\n const text = (state.run.steps || []).map((step) => `=== ${step.name} ===\\n${step.logContent || \"\"}`).join(\"\\n\\n\");\n downloadBlob(new Blob([text], { type: \"text/plain;charset=utf-8\" }), `flow-run-${state.run.jobId}.log`);\n return true;\n }\n if (name === \"download-artifact\" && state.run) {\n const artifact = state.run.artifacts?.[Number(value)];\n if (!artifact) {\n return true;\n }\n const encoded = String(artifact).split(\"/\").map(encodeURIComponent).join(\"/\");\n try {\n const blob = await apiBlob(`/api/runs/${encodeURIComponent(state.run.jobId)}/artifacts/${encoded}`);\n downloadBlob(blob, String(artifact).split(\"/\").pop() || \"artifact\");\n } catch (error) {\n showToast(`Download failed: ${error.message}`);\n }\n return true;\n }\n if (name === \"workflow-source\") {\n const workflow = state.workflows[Number(value)];\n if (workflow) {\n openDialog(\n workflow.name || workflow.id,\n `<p class=\"dialog-description\">Revision ${escape(workflow.revision || \"—\")} · ${escape(\n workflow.status || \"\"\n )}</p><div class=\"source-view\"><pre>${escape(\n workflow.sourceYaml || \"Workflow source unavailable.\"\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n return true;\n }\n if (name === \"validate-workflow\") {\n const source = workflowFormSource();\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n apiJson(`/api/workflows/validate`, {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source }),\n })\n .then((result) => {\n const status = result.valid ? \"passed\" : \"failed\";\n showToast(`Validation ${status}: ${result.error || \"\"}`);\n })\n .catch((error) => {\n showToast(`Validation failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"run-now\") {\n const id = document.getElementById(\"new-workflow-id\")?.value.trim();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const source = workflowFormSource();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n if (!id || !name) {\n showToast(\"Please enter a workflow name and ID first.\");\n return true;\n }\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return true;\n }\n if (!enabled) {\n showToast(\"Enable the workflow before running it.\");\n return true;\n }\n void (async () => {\n try {\n await apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n });\n const result = await apiJson(`/api/workflows/${encodeURIComponent(id)}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showToast(\n `Queued ${result.jobs?.length || 0} job${result.jobs?.length === 1 ? \"\" : \"s\"} from revision ${\n result.revision\n }.`\n );\n } catch (error) {\n showToast(`Failed to run workflow: ${error.message}`);\n }\n })();\n return true;\n }\n if (name === \"workflow-revision\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n const dir = Number(button.dataset.dir);\n const targetRev = (workflow.revision || 1) + dir;\n if (targetRev < 1) {\n return true;\n }\n loadRevisionDiff(workflow.id, targetRev);\n return true;\n }\n if (name === \"workflow-publish\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n if (!window.confirm(`Publish workflow ${workflow.name || workflow.id}? This will make it available for runs.`)) {\n return true;\n }\n apiJson(`/api/workflows/${workflow.id}/publish`, { method: \"POST\" })\n .then((result) => {\n showToast(`Published ${result.id || workflow.id} revision ${result.revision}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Publish failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"rerun\" && state.run) {\n if (state.mutating) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n if (\n !window.confirm(\n `Re-run ${\n state.run.workflowName || state.run.workflowId || \"this workflow\"\n }? This creates a new job from the active workflow revision.`\n )\n ) {\n state.mutating = false;\n button.disabled = false;\n return true;\n }\n try {\n const result = await apiJson(`/restart/${encodeURIComponent(state.run.jobId)}`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Re-run queued: #${result.id}`);\n window.location.hash = `/runs/${result.id}`;\n } catch (error) {\n showToast(`Re-run failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n if (name === \"cancel\" && state.run) {\n if (state.mutating) {\n return true;\n }\n if (\n ![\"pending\", \"running\"].includes(state.run.status) ||\n !window.confirm(`Cancel live run #${state.run.jobId}?`)\n ) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n try {\n await apiJson(`/api/jobs/${encodeURIComponent(state.run.jobId)}/cancel`, { method: \"POST\" });\n showToast(`Run #${state.run.jobId} cancelled.`);\n await liveRunPage(state.run.jobId);\n state.jobsLoaded = false;\n await loadJobs();\n } catch (error) {\n showToast(`Cancel failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n return false;\n };\n\n const setupEvents = () => {\n if (events) {\n return;\n }\n events = new EventSource(\"/api/events\");\n events.addEventListener(\"jobs.available\", () => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n });\n events.addEventListener(\"jobs.changed\", (event) => {\n let data = {};\n try {\n data = JSON.parse(event.data || \"{}\");\n } catch {\n /* Refresh the list even if an event payload is malformed. */\n }\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n if (state.run && (!data.jobId || String(data.jobId) === String(state.run.jobId))) {\n void liveRunPage(state.run.jobId);\n }\n });\n events.onerror = () => {\n /* EventSource retries transient disconnects automatically. */\n };\n };\n\n document.addEventListener(\"click\", async (event) => {\n const handled = await action(event);\n if (handled) {\n event.preventDefault();\n }\n });\n document.addEventListener(\"submit\", (event) => {\n if (event.target?.id === \"new-workflow-form\") {\n event.preventDefault();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const id = document.getElementById(\"new-workflow-id\")?.value.trim().toLowerCase();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n const source = workflowFormSource();\n if (!name || !id || !source) {\n showToast(\"Please fill in all fields (name, id, and source).\");\n return;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return;\n }\n apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n })\n .then(() => {\n showToast(`Created workflow ${id}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Failed to create workflow: ${error.message}`);\n });\n }\n });\n document.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"log-search\") {\n state.logSearch = event.target.value;\n if (state.run) {\n renderLiveLog(state.run.steps?.[state.step]?.logContent || \"\");\n }\n }\n });\n document.addEventListener(\"change\", (event) => {\n if (event.target.id === \"workflow-filter\") {\n state.workflow = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"worker-filter\") {\n state.worker = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"compact-setting\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n localStorage.setItem(\"flow-concept-compact\", String(event.target.checked));\n }\n if (event.target.id === \"wrap-setting\") {\n state.wrap = event.target.checked;\n localStorage.setItem(\"flow-concept-wrap\", String(event.target.checked));\n }\n });\n window.addEventListener(\"hashchange\", () => {\n state.run = null;\n void render();\n });\n window.addEventListener(\"beforeunload\", () => {\n events?.close();\n });\n document.querySelectorAll(\"[data-icon]\").forEach((node) => {\n node.innerHTML = icon(node.dataset.icon);\n });\n document.body.classList.toggle(\"compact\", localStorage.getItem(\"flow-concept-compact\") === \"true\");\n state.wrap = localStorage.getItem(\"flow-concept-wrap\") === \"true\";\n const initialQuery = route().query;\n state.workflow = initialQuery.get(\"workflow\") || \"all\";\n state.search = initialQuery.get(\"search\") || \"\";\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(initialQuery.get(\"status\"))) {\n state.status = initialQuery.get(\"status\");\n }\n main.innerHTML = `${pageHeading(\"Runs\", \"Runs\")}<div class=\"empty-state\">${icon(\"clock\")}<p>Loading runs…</p></div>`;\n setupEvents();\n void Promise.all([loadWorkflows(), loadJobs()]).then(() => {\n state.jobsLoaded = true;\n void render();\n });\n}\n", gg = 50, _g = 500, vg = class e {
|
|
12315
12315
|
server;
|
|
12316
12316
|
preprocessors = /* @__PURE__ */ new Map();
|
|
12317
12317
|
workflows = new d();
|