@cloud-cli/on 1.70.1 → 1.70.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.
|
@@ -2296,11 +2296,11 @@ var nn = {
|
|
|
2296
2296
|
let t = await r.run("UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL", [e]);
|
|
2297
2297
|
return Number(t?.changes || 0) > 0;
|
|
2298
2298
|
}
|
|
2299
|
-
}, on = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Runner settings</title>\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://at-li3.static.apphor.de/\" } }\n <\/script>\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"stylesheet\" href=\"/on.css\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n </head>\n <body data-page=\"__SETTINGS_PAGE__\" class=\"min-h-screen bg-flow-background p-4 font-sans text-flow-foreground md:p-6\">\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <app-header\n title=\"Settings\"\n subtitle=\"Account and automation settings\"\n back=\"/runs\"\n back-label=\"Back to jobs\"\n ></app-header>\n <nav\n class=\"mb-6 flex flex-wrap gap-2 rounded-xl border border-flow-border bg-flow-card p-1\"\n aria-label=\"Settings section navigation\"\n >\n <a href=\"#section-timezone\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Timezone</a\n >\n <a href=\"#section-keys\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >API keys</a\n >\n <a\n href=\"#section-notifications\"\n class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Notifications</a\n >\n <a href=\"#section-workers\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Workers</a\n >\n <a href=\"#section-users\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Users</a\n >\n <a href=\"#section-secrets\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Secrets</a\n >\n </nav>\n <section\n id=\"section-timezone\"\n class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\"\n aria-labelledby=\"timezone-title\"\n >\n <h2 id=\"timezone-title\" class=\"text-lg font-semibold\">Your timezone</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Used to display worker and API token timestamps.</p>\n <label for=\"timezone\" class=\"mt-4 block text-sm font-medium\">Timezone</label>\n <select\n id=\"timezone\"\n bind-value=\"timezone\"\n on-change=\"saveTimezone($event)\"\n class=\"mt-2 max-w-md rounded-lg border border-flow-border bg-white px-3 py-2 text-sm\"\n >\n <template for=\"zone of timezones\"><option attr-value=\"zone\">{{ zone }}</option></template>\n </select>\n <p class=\"mt-2 text-sm text-flow-muted\" role=\"status\">{{ preferenceMessage }}</p>\n </section>\n <section\n id=\"section-keys\"\n class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\"\n aria-labelledby=\"keys-title\"\n >\n <h2 id=\"keys-title\" class=\"text-lg font-semibold\">API keys</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">\n Issue narrowly scoped credentials for automation tools. Keys are shown only once.\n </p>\n <div\n class=\"mt-5 grid gap-3 rounded-xl border border-flow-border bg-flow-background p-4 md:grid-cols-[1fr_auto]\"\n >\n <label class=\"sr-only\" for=\"key-name\">Key name</label\n ><input\n id=\"key-name\"\n bind-value=\"keyName\"\n on-input=\"setKeyName($event)\"\n placeholder=\"Documentation bot\"\n class=\"rounded-lg border border-flow-border bg-white px-3 py-2.5 text-sm\"\n />\n <button\n type=\"button\"\n on-click=\"issueKey()\"\n class=\"rounded-lg bg-flow-primary px-4 py-2.5 text-sm font-semibold text-white\"\n >\n Issue key\n </button>\n <fieldset class=\"md:col-span-2\">\n <legend class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-flow-muted\">Scopes</legend>\n <div class=\"space-y-2\">\n <template for=\"scope of scopeOptions\"\n ><label class=\"flex cursor-pointer items-center gap-3 text-sm\"\n ><input type=\"checkbox\" bind-checked=\"scope.selected\" on-change=\"toggleScope(scope)\" />{{\n scope.label }}</label\n ></template\n >\n </div>\n </fieldset>\n </div>\n <template if=\"newToken\"\n ><div class=\"mt-4 rounded-xl border border-amber-300 bg-amber-50 p-4\" role=\"alert\">\n <p class=\"text-sm font-semibold\">Copy this key now. It cannot be shown again.</p>\n <code class=\"mt-2 block select-all break-all rounded bg-white p-3 font-mono text-xs\">{{ newToken }}</code>\n </div></template\n >\n <h3 class=\"mt-5 text-xs font-semibold uppercase tracking-wider text-flow-muted\">Active tokens</h3>\n <ul class=\"mt-2 space-y-2\" aria-label=\"Active API keys\">\n <template for=\"key of keys\"\n ><li class=\"flex items-center justify-between gap-3 rounded-xl border border-flow-border px-4 py-3\">\n <div>\n <p class=\"text-sm font-medium\">{{ key.name }}</p>\n <p class=\"text-xs text-flow-muted\">\n {{ key.scopes.join(', ') }} · created {{ formatTimestamp(key.created_at) }}\n </p>\n </div>\n <button\n type=\"button\"\n on-click=\"revokeKey(key)\"\n class=\"rounded-lg p-2 text-rose-700\"\n aria-label=\"Revoke {{ key.name }}\"\n >\n Revoke\n </button>\n </li></template\n >\n </ul>\n </section>\n <section id=\"section-notifications\" class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\">\n <h2 class=\"text-lg font-semibold\">Notifications</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Receive job completion notifications on this device.</p>\n <button\n type=\"button\"\n on-click=\"toggleNotifications()\"\n class=\"mt-5 rounded-lg border border-flow-border px-4 py-2.5 text-sm font-medium\"\n >\n {{ notificationsEnabled ? 'Disable notifications' : 'Enable notifications' }}\n </button>\n <p class=\"mt-3 text-sm text-flow-muted\" role=\"status\">{{ notificationMessage }}</p>\n </section>\n <section id=\"section-workers\" class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\">\n <h2 class=\"text-lg font-semibold\">Workers</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Connected runner status, versions, tags, and load.</p>\n <p class=\"mt-4 text-sm text-flow-muted\" class-hidden=\"!workersForbidden\" role=\"status\">\n Your account does not have permission to view worker status.\n </p>\n <div class=\"mt-5 overflow-x-auto rounded-xl border border-flow-border\" class-hidden=\"workersForbidden\">\n <table class=\"w-full text-left text-sm\">\n <thead class=\"bg-flow-background text-xs uppercase text-flow-muted\">\n <tr>\n <th class=\"w-1/5 px-4 py-3\">Worker</th>\n <th class=\"w-1/6 px-4 py-3\">Version</th>\n <th class=\"w-1/5 px-4 py-3\">Tags</th>\n <th class=\"w-1/6 px-4 py-3\">Load</th>\n <th class=\"w-[27%] px-4 py-3\">Last seen</th>\n </tr>\n </thead>\n <tbody>\n <template for=\"worker of workers\"\n ><tr class=\"border-t border-flow-border\">\n <td class=\"max-w-0 truncate px-4 py-3 font-mono\" title=\"{{ worker.workerId }}\">\n {{ worker.workerId }} · {{ worker.online ? 'online' : 'offline' }}\n </td>\n <td class=\"truncate px-4 py-3\">{{ worker.version }}</td>\n <td class=\"truncate px-4 py-3\" title=\"{{ worker.tags.join(', ') }}\">\n {{ worker.tags.join(', ') }}\n </td>\n <td class=\"whitespace-nowrap px-4 py-3\">{{ worker.activeJobs }} / {{ worker.concurrency }}</td>\n <td class=\"whitespace-nowrap px-4 py-3\">{{ formatTimestamp(worker.lastSeen) }}</td>\n </tr></template\n >\n </tbody>\n </table>\n <p class=\"p-5 text-sm text-flow-muted\" class-hidden=\"workers.length > 0\">\n No worker heartbeats received yet.\n </p>\n </div>\n </section>\n <section id=\"section-users\" class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\">\n <h2 class=\"text-lg font-semibold\">Users</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Manage access for this runner.</p>\n <p class=\"mt-4 text-sm text-rose-700\" class-hidden=\"!usersForbidden\" role=\"status\">\n User management is available to administrators only.\n </p>\n <ul class=\"mt-4 space-y-2\">\n <template for=\"user of users\"\n ><li class=\"flex items-center justify-between gap-3 rounded-xl border border-flow-border p-4\">\n <span\n ><span class=\"block text-sm font-medium\">{{ user.name || user.email || 'Account' }}</span\n ><span class=\"block text-xs text-flow-muted\"\n >{{ user.name && user.email ? user.email : user.role }}</span\n ></span\n ><button\n type=\"button\"\n on-click=\"toggleRole(user)\"\n attr-aria-label=\"'Change role for ' + (user.name || user.email || 'account')\"\n class=\"rounded-lg border border-flow-border px-3 py-2 text-sm\"\n >\n {{ user.role === 'admin' ? 'Remove admin' : 'Make admin' }}\n </button>\n </li></template\n >\n </ul>\n </section>\n <section\n id=\"section-secrets\"\n class=\"scroll-mt-6 secrets-panel rounded-2xl border border-flow-border bg-flow-card p-4 shadow-sm shadow-flow-foreground/5 sm:p-5\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-flow-primary/10 p-2 text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-flow-foreground\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-flow-secondary\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-flow-border bg-flow-foreground px-2.5 py-1 text-xs font-mono text-flow-secondary\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <details ref=\"secretForm\" class=\"mb-5 overflow-hidden rounded-xl border border-flow-border bg-flow-panel\">\n <summary\n class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-flow-secondary outline-none hover:bg-flow-panel focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span>Add a secret</span>\n <lucide-icon icon=\"plus\" size=\"17\"></lucide-icon>\n </summary>\n <div class=\"space-y-3 border-t border-flow-border p-4\">\n <p class=\"text-xs text-flow-secondary\">Saving an existing name replaces its encrypted value.</p>\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-flow-secondary\">\n <input\n type=\"checkbox\"\n bind-checked=\"fileMode\"\n on-change=\"setFileMode($event)\"\n class=\"h-4 w-4 rounded border-flow-border bg-flow-panel text-indigo-500 focus:ring-2 focus:ring-flow-primary/20\"\n />\n Binary file secret\n </label>\n <template if=\"!fileMode\">\n <label class=\"sr-only\" for=\"secret-value\">Text secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"New secret value\"\n />\n </template>\n <template if=\"fileMode\">\n <label class=\"sr-only\" for=\"secret-file\">Binary secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2 text-xs text-flow-secondary file:mr-3 file:rounded file:border-0 file:bg-flow-secondary file:px-2 file:py-1 file:text-xs file:text-flow-foreground focus:border-flow-primary focus:outline-none focus:ring-2 focus:ring-flow-primary/20\"\n />\n </template>\n <div>\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-primary/60 px-3 py-2.5 text-sm font-medium text-flow-primary outline-none transition hover:bg-flow-primary/10 focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\n </div>\n </details>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <div\n class=\"grid min-h-14 grid-cols-[minmax(0,1fr)_2rem] items-center gap-2 rounded-xl border border-flow-border bg-flow-panel px-3.5 py-3\"\n >\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto_1rem] items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-flow-sidebar text-flow-secondary group-hover:text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-flow-foreground\">{{ name }}</span>\n <span class=\"text-xs text-flow-secondary\">Select</span>\n <lucide-icon\n icon=\"chevron-right\"\n size=\"16\"\n class=\"text-flow-secondary transition group-hover:text-flow-primary\"\n ></lucide-icon>\n </button>\n <button\n type=\"button\"\n on-click=\"removeSecret(name)\"\n aria-label=\"Delete secret {{ name }}\"\n title=\"Delete secret\"\n class=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-flow-error outline-none transition hover:bg-flow-error/10 focus-visible:ring-2 focus-visible:ring-flow-error/40\"\n >\n <lucide-icon icon=\"trash\" size=\"15\"></lucide-icon>\n </button>\n </div>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-flow-border px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-flow-secondary\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-flow-secondary\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-flow-secondary\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n <p class=\"text-sm text-rose-700\" role=\"alert\" class-hidden=\"!error\">{{ error }}</p>\n </main>\n <script setup src=\"/settings-ui.mjs\"><\/script>\n </template>\n <script type=\"module\">\n import \"@li3/web\";\n <\/script>\n </body>\n</html>\n";
|
|
2299
|
+
}, on = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <title>Runner settings</title>\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://at-li3.static.apphor.de/\" } }\n <\/script>\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"stylesheet\" href=\"/on.css\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n </head>\n <body data-page=\"__SETTINGS_PAGE__\" class=\"min-h-screen bg-flow-background p-4 font-sans text-flow-foreground md:p-6\">\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <app-header\n title=\"Settings\"\n subtitle=\"Account and automation settings\"\n back=\"/runs\"\n back-label=\"Back to jobs\"\n ></app-header>\n <nav\n class=\"sticky top-0 z-20 -mx-1 mb-6 flex flex-wrap gap-2 rounded-xl border border-flow-border bg-flow-card/95 p-1 shadow-sm backdrop-blur\"\n aria-label=\"Settings section navigation\"\n >\n <a href=\"#section-timezone\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Timezone</a\n >\n <a href=\"#section-keys\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >API keys</a\n >\n <a\n href=\"#section-notifications\"\n class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Notifications</a\n >\n <a href=\"#section-workers\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Workers</a\n >\n <a href=\"#section-users\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Users</a\n >\n <a href=\"#section-secrets\" class=\"rounded-lg px-3 py-2 text-sm text-flow-secondary hover:bg-flow-background\"\n >Secrets</a\n >\n </nav>\n <section\n id=\"section-timezone\"\n class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\"\n aria-labelledby=\"timezone-title\"\n >\n <h2 id=\"timezone-title\" class=\"text-lg font-semibold\">Your timezone</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Used to display worker and API token timestamps.</p>\n <label for=\"timezone\" class=\"mt-4 block text-sm font-medium\">Timezone</label>\n <select\n id=\"timezone\"\n bind-value=\"timezone\"\n on-change=\"saveTimezone($event)\"\n class=\"mt-2 max-w-md rounded-lg border border-flow-border bg-white px-3 py-2 text-sm\"\n >\n <template for=\"zone of timezones\"><option attr-value=\"zone\">{{ zone }}</option></template>\n </select>\n <p class=\"mt-2 text-sm text-flow-muted\" role=\"status\">{{ preferenceMessage }}</p>\n </section>\n <section\n id=\"section-keys\"\n class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\"\n aria-labelledby=\"keys-title\"\n >\n <h2 id=\"keys-title\" class=\"text-lg font-semibold\">API keys</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">\n Issue narrowly scoped credentials for automation tools. Keys are shown only once.\n </p>\n <div\n class=\"mt-5 grid gap-3 rounded-xl border border-flow-border bg-flow-background p-4 md:grid-cols-[1fr_auto]\"\n >\n <label class=\"sr-only\" for=\"key-name\">Key name</label\n ><input\n id=\"key-name\"\n bind-value=\"keyName\"\n on-input=\"setKeyName($event)\"\n placeholder=\"Documentation bot\"\n class=\"rounded-lg border border-flow-border bg-white px-3 py-2.5 text-sm\"\n />\n <button\n type=\"button\"\n on-click=\"issueKey()\"\n class=\"rounded-lg bg-flow-primary px-4 py-2.5 text-sm font-semibold text-white\"\n >\n Issue key\n </button>\n <fieldset class=\"md:col-span-2\">\n <legend class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-flow-muted\">Scopes</legend>\n <div class=\"space-y-2\">\n <template for=\"scope of scopeOptions\"\n ><label class=\"flex cursor-pointer items-center gap-3 text-sm\"\n ><input type=\"checkbox\" bind-checked=\"scope.selected\" on-change=\"toggleScope(scope)\" /><span\n >{{ scope.label }}</span\n ></label\n ></template\n >\n </div>\n </fieldset>\n </div>\n <template if=\"newToken\"\n ><div class=\"mt-4 rounded-xl border border-amber-300 bg-amber-50 p-4\" role=\"alert\">\n <p class=\"text-sm font-semibold\">Copy this key now. It cannot be shown again.</p>\n <code class=\"mt-2 block select-all break-all rounded bg-white p-3 font-mono text-xs\">{{ newToken }}</code>\n </div></template\n >\n <h3 class=\"mt-5 text-xs font-semibold uppercase tracking-wider text-flow-muted\">Active tokens</h3>\n <ul class=\"mt-2 space-y-2\" aria-label=\"Active API keys\">\n <template for=\"key of keys\"\n ><li class=\"flex items-center justify-between gap-3 rounded-xl border border-flow-border px-4 py-3\">\n <div>\n <p class=\"text-sm font-medium\">{{ key.name }}</p>\n <p class=\"text-xs text-flow-muted\">\n {{ key.scopes.join(', ') }} · created {{ formatTimestamp(key.created_at) }}\n </p>\n </div>\n <button\n type=\"button\"\n on-click=\"revokeKey(key)\"\n class=\"rounded-lg p-2 text-rose-700\"\n aria-label=\"Revoke {{ key.name }}\"\n >\n Revoke\n </button>\n </li></template\n >\n </ul>\n </section>\n <section id=\"section-notifications\" class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\">\n <h2 class=\"text-lg font-semibold\">Notifications</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Receive job completion notifications on this device.</p>\n <button\n type=\"button\"\n on-click=\"toggleNotifications()\"\n class=\"mt-5 rounded-lg border border-flow-border px-4 py-2.5 text-sm font-medium\"\n >\n {{ notificationsEnabled ? 'Disable notifications' : 'Enable notifications' }}\n </button>\n <p class=\"mt-3 text-sm text-flow-muted\" role=\"status\">{{ notificationMessage }}</p>\n </section>\n <section id=\"section-workers\" class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\">\n <h2 class=\"text-lg font-semibold\">Workers</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Connected runner status, versions, tags, and load.</p>\n <p class=\"mt-4 text-sm text-flow-muted\" class-hidden=\"!workersForbidden\" role=\"status\">\n Your account does not have permission to view worker status.\n </p>\n <div class=\"mt-5 overflow-x-auto rounded-xl border border-flow-border\" class-hidden=\"workersForbidden\">\n <table class=\"w-full text-left text-sm\">\n <thead class=\"bg-flow-background text-xs uppercase text-flow-muted\">\n <tr>\n <th class=\"w-1/5 px-4 py-3\">Worker</th>\n <th class=\"w-1/6 px-4 py-3\">Version</th>\n <th class=\"w-1/5 px-4 py-3\">Tags</th>\n <th class=\"w-1/6 px-4 py-3\">Load</th>\n <th class=\"w-[27%] px-4 py-3\">Last seen</th>\n </tr>\n </thead>\n <tbody>\n <template for=\"worker of workers\"\n ><tr class=\"border-t border-flow-border\">\n <td class=\"max-w-0 truncate px-4 py-3 font-mono\" title=\"{{ worker.workerId }}\">\n {{ worker.workerId }} · {{ worker.online ? 'online' : 'offline' }}\n </td>\n <td class=\"truncate px-4 py-3\">{{ worker.version }}</td>\n <td class=\"truncate px-4 py-3\" title=\"{{ worker.tags.join(', ') }}\">\n {{ worker.tags.join(', ') }}\n </td>\n <td class=\"whitespace-nowrap px-4 py-3\">{{ worker.activeJobs }} / {{ worker.concurrency }}</td>\n <td class=\"whitespace-nowrap px-4 py-3\">{{ formatTimestamp(worker.lastSeen) }}</td>\n </tr></template\n >\n </tbody>\n </table>\n <p class=\"p-5 text-sm text-flow-muted\" class-hidden=\"workers.length > 0\">\n No worker heartbeats received yet.\n </p>\n </div>\n </section>\n <section id=\"section-users\" class=\"scroll-mt-6 rounded-2xl border border-flow-border bg-white p-5\">\n <h2 class=\"text-lg font-semibold\">Users</h2>\n <p class=\"mt-1 text-sm text-flow-muted\">Manage access for this runner.</p>\n <p class=\"mt-4 text-sm text-rose-700\" class-hidden=\"!usersForbidden\" role=\"status\">\n User management is available to administrators only.\n </p>\n <ul class=\"mt-4 space-y-2\">\n <template for=\"user of users\"\n ><li class=\"flex items-center justify-between gap-3 rounded-xl border border-flow-border p-4\">\n <span\n ><span class=\"block text-sm font-medium\">{{ user.name || user.email || 'Account' }}</span\n ><span class=\"block text-xs text-flow-muted\"\n >{{ user.name && user.email ? user.email : user.role }}</span\n ></span\n ><button\n type=\"button\"\n on-click=\"toggleRole(user)\"\n attr-aria-label=\"'Change role for ' + (user.name || user.email || 'account')\"\n class=\"rounded-lg border border-flow-border px-3 py-2 text-sm\"\n >\n {{ user.role === 'admin' ? 'Remove admin' : 'Make admin' }}\n </button>\n </li></template\n >\n </ul>\n </section>\n <section\n id=\"section-secrets\"\n class=\"scroll-mt-6 secrets-panel rounded-2xl border border-flow-border bg-flow-card p-4 shadow-sm shadow-flow-foreground/5 sm:p-5\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-flow-primary/10 p-2 text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-flow-foreground\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-flow-secondary\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-flow-border bg-flow-foreground px-2.5 py-1 text-xs font-mono text-flow-secondary\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <details ref=\"secretForm\" class=\"mb-5 overflow-hidden rounded-xl border border-flow-border bg-flow-panel\">\n <summary\n class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-flow-secondary outline-none hover:bg-flow-panel focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span>Add a secret</span>\n <lucide-icon icon=\"plus\" size=\"17\"></lucide-icon>\n </summary>\n <div class=\"space-y-3 border-t border-flow-border p-4\">\n <p class=\"text-xs text-flow-secondary\">Saving an existing name replaces its encrypted value.</p>\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-flow-secondary\">\n <input\n type=\"checkbox\"\n bind-checked=\"fileMode\"\n on-change=\"setFileMode($event)\"\n class=\"h-4 w-4 rounded border-flow-border bg-flow-panel text-indigo-500 focus:ring-2 focus:ring-flow-primary/20\"\n />\n Binary file secret\n </label>\n <template if=\"!fileMode\">\n <label class=\"sr-only\" for=\"secret-value\">Text secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"New secret value\"\n />\n </template>\n <template if=\"fileMode\">\n <label class=\"sr-only\" for=\"secret-file\">Binary secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2 text-xs text-flow-secondary file:mr-3 file:rounded file:border-0 file:bg-flow-secondary file:px-2 file:py-1 file:text-xs file:text-flow-foreground focus:border-flow-primary focus:outline-none focus:ring-2 focus:ring-flow-primary/20\"\n />\n </template>\n <div>\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-primary/60 px-3 py-2.5 text-sm font-medium text-flow-primary outline-none transition hover:bg-flow-primary/10 focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\n </div>\n </details>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <div\n class=\"grid min-h-14 grid-cols-[minmax(0,1fr)_2rem] items-center gap-2 rounded-xl border border-flow-border bg-flow-panel px-3.5 py-3\"\n >\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto_1rem] items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-flow-sidebar text-flow-secondary group-hover:text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-flow-foreground\">{{ name }}</span>\n <span class=\"text-xs text-flow-secondary\">Select</span>\n <lucide-icon\n icon=\"chevron-right\"\n size=\"16\"\n class=\"text-flow-secondary transition group-hover:text-flow-primary\"\n ></lucide-icon>\n </button>\n <button\n type=\"button\"\n on-click=\"removeSecret(name)\"\n aria-label=\"Delete secret {{ name }}\"\n title=\"Delete secret\"\n class=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-flow-error outline-none transition hover:bg-flow-error/10 focus-visible:ring-2 focus-visible:ring-flow-error/40\"\n >\n <lucide-icon icon=\"trash\" size=\"15\"></lucide-icon>\n </button>\n </div>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-flow-border px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-flow-secondary\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-flow-secondary\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-flow-secondary\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n <p class=\"text-sm text-rose-700\" role=\"alert\" class-hidden=\"!error\">{{ error }}</p>\n </main>\n <script setup src=\"/settings-ui.mjs\"><\/script>\n </template>\n <script type=\"module\">\n import \"@li3/web\";\n <\/script>\n </body>\n</html>\n";
|
|
2300
2300
|
//#endregion
|
|
2301
2301
|
//#region src/settings-ui.ts
|
|
2302
|
-
function sn(
|
|
2303
|
-
return on.replace("__SETTINGS_PAGE__",
|
|
2302
|
+
function sn() {
|
|
2303
|
+
return on.replace("__SETTINGS_PAGE__", "settings");
|
|
2304
2304
|
}
|
|
2305
2305
|
//#endregion
|
|
2306
2306
|
//#region node_modules/.pnpm/openai@7.15.0/node_modules/openai/internal/tslib.mjs
|
|
@@ -12244,7 +12244,7 @@ async function Gh(e, t, n, r) {
|
|
|
12244
12244
|
}
|
|
12245
12245
|
//#endregion
|
|
12246
12246
|
//#region src/app-header.html?raw
|
|
12247
|
-
var Kh = "<template component=\"app-header\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <header class=\"mx-auto flex w-full max-w-[78rem] items-center justify-between gap-4 border-b border-gray-800 pb-5\">\n <div class=\"flex min-w-0 items-start gap-3\">\n <template if=\"back\">\n <a\n class=\"mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-800 text-gray-400 no-underline outline-none hover:border-indigo-500 hover:text-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n attr-href=\"back\"\n attr-aria-label=\"backLabel || 'Back'\"\n attr-title=\"backLabel || 'Back'\"\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width=\"17\"\n height=\"17\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.8\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m15 18-6-6 6-6\"></path>\n </svg>\n </a>\n </template>\n <div>\n <div class=\"text-[.7rem] font-bold uppercase tracking-[.16em] text-indigo-400\">Runner engine</div>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">{{ title }}</h1>\n <template if=\"subtitle\"><p class=\"mt-1 text-xs text-gray-400\">{{ subtitle }}</p></template>\n </div>\n </div>\n <div class=\"flex items-center gap-2\">\n <template if=\"!authenticated\">\n <a\n class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/settings\"\n aria-label=\"Settings\"\n title=\"Settings\"\n >\n <lucide-icon icon=\"user-round\" size=\"16\"></lucide-icon>\n </a>\n </template>\n <template if=\"authenticated\">\n <span class=\"hidden text-xs text-gray-400 sm:inline\">{{ userName }}</span>\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/logout\">Sign out</a>\n </template>\n <a\n class=\"rounded px-2 py-1 text-xs text-indigo-300 no-underline outline-none hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/preview\"\n target=\"_top\"\n >Preview</a\n >\n <a\n class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/help\"\n aria-label=\"Help\"\n title=\"Help\"\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width=\"16\"\n height=\"16\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.8\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\"></circle>\n <path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path>\n <path d=\"M12 17h.01\"></path>\n </svg>\n </a>\n <template if=\"authenticated\">\n <a\n class=\"inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/settings\"\n aria-label=\"Settings\"\n title=\"Settings\"\n >\n <template if=\"userPhoto\"><img attr-src=\"userPhoto\" alt=\"\" class=\"h-full w-full object-cover\" /></template>\n <template if=\"!userPhoto\"><lucide-icon icon=\"circle-user-round\" size=\"16\"></lucide-icon></template>\n </a>\n </template>\n </div>\n </header>\n <script setup src=\"/app-header.mjs\"><\/script>\n</template>\n", qh = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#f3f5f1\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <link rel=\"stylesheet\" href=\"/on.css\" />\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@app/\": \"/\",\n \"@li3/\": \"https://at-li3.static.apphor.de/\",\n \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\",\n \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\",\n \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\"\n }\n }\n <\/script>\n <link rel=\"component\" href=\"/app-router.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <title>Flow · Runs</title>\n </head>\n <body class=\"min-h-screen bg-flow-background font-sans text-flow-foreground\">\n <template app>\n <a\n href=\"#main-content\"\n class=\"sr-only z-50 rounded-md bg-flow-foreground px-4 py-2 text-white focus:not-sr-only focus:fixed focus:left-4 focus:top-4\"\n >\n Skip to content\n </a>\n <div class=\"min-h-screen md:grid md:grid-cols-[15rem_minmax(0,1fr)]\">\n <aside class=\"hidden min-h-screen border-r border-flow-border bg-flow-sidebar px-4 py-5 md:flex md:flex-col\">\n <a\n href=\"/runs\"\n on-click=\"setActive('runs')\"\n class=\"mb-8 flex items-center gap-3 rounded-lg p-2 text-flow-foreground no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary\"\n aria-label=\"Flow home\"\n >\n <span\n class=\"grid h-9 w-9 place-items-center rounded-xl bg-flow-primary text-sm font-black tracking-tight text-white\"\n aria-hidden=\"true\"\n >F</span\n >\n <span class=\"text-lg font-bold tracking-[0.12em] text-flow-foreground\"\n >FLOW<span class=\"text-flow-primary\">.</span></span\n >\n </a>\n <nav aria-label=\"Primary navigation\" class=\"space-y-1\">\n <a\n href=\"/runs\"\n on-click=\"setActive('runs')\"\n attr-aria-current=\"activeSection === 'runs' ? 'page' : null\"\n class=\"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-flow-secondary no-underline outline-none hover:bg-white/70 hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:bg-white aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"play\" size=\"17\"></lucide-icon>Runs</a\n >\n <a\n href=\"/workflows\"\n on-click=\"setActive('workflows')\"\n attr-aria-current=\"activeSection === 'workflows' ? 'page' : null\"\n class=\"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-flow-secondary no-underline outline-none hover:bg-white/70 hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:bg-white aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>Workflows</a\n >\n <a\n href=\"/settings/tokens\"\n on-click=\"setActive('settings')\"\n attr-aria-current=\"activeSection === 'settings' ? 'page' : null\"\n class=\"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-flow-secondary no-underline outline-none hover:bg-white/70 hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:bg-white aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"settings\" size=\"17\"></lucide-icon>Settings</a\n >\n </nav>\n <template if=\"userAuthenticated\">\n <div class=\"mt-auto flex min-w-0 items-center gap-3 border-t border-flow-border pt-4\">\n <span\n class=\"grid h-9 w-9 shrink-0 place-items-center rounded-full bg-flow-accent text-sm font-semibold text-flow-foreground\"\n >{{ userInitial }}</span\n >\n <a\n attr-href=\"oidcProviderUrl ? oidcProviderUrl + '/me' : null\"\n target=\"_blank\"\n rel=\"noopener\"\n class=\"min-w-0 flex-1 text-flow-foreground no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary\"\n aria-label=\"Open profile at identity provider in a new tab\"\n >\n <span class=\"block truncate text-sm font-medium\">{{ user.name || user.email }}</span>\n <span class=\"block truncate text-xs text-flow-secondary\">{{ user.email }}</span>\n <span\n class=\"mt-1 inline-block rounded-full bg-flow-sidebar px-2 py-0.5 text-[10px] font-medium text-flow-secondary\"\n >{{ user.role }}</span\n >\n <lucide-icon icon=\"external-link\" size=\"13\" class=\"ml-1 inline-block align-middle\"></lucide-icon>\n </a>\n </div>\n </template>\n </aside>\n <div class=\"min-w-0 pb-20 md:pb-0\">\n <div id=\"main-content\" class=\"min-h-screen min-w-0 p-4 sm:p-6 lg:p-8\">\n <app-router></app-router>\n </div>\n </div>\n <nav\n aria-label=\"Mobile navigation\"\n class=\"fixed inset-x-0 bottom-0 z-10 grid grid-cols-3 border-t border-flow-border bg-flow-sidebar/95 px-2 pb-[env(safe-area-inset-bottom)] pt-2 backdrop-blur md:hidden\"\n >\n <a\n href=\"/runs\"\n on-click=\"setActive('runs')\"\n attr-aria-current=\"activeSection === 'runs' ? 'page' : null\"\n class=\"flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg text-xs text-flow-secondary no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"play\" size=\"17\"></lucide-icon>Runs</a\n >\n <a\n href=\"/workflows\"\n on-click=\"setActive('workflows')\"\n attr-aria-current=\"activeSection === 'workflows' ? 'page' : null\"\n class=\"flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg text-xs text-flow-secondary no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>Workflows</a\n >\n <a\n href=\"/settings/tokens\"\n on-click=\"setActive('settings')\"\n attr-aria-current=\"activeSection === 'settings' ? 'page' : null\"\n class=\"flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg text-xs text-flow-secondary no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"settings\" size=\"17\"></lucide-icon>Settings</a\n >\n </nav>\n </div>\n <script setup src=\"/app-shell.mjs\"><\/script>\n </template>\n <script type=\"module\">\n import \"@li3/web\";\n <\/script>\n </body>\n</html>\n", Jh = "<template component=\"app-router\">\n <div ref=\"outlet\" aria-live=\"polite\"></div>\n <script setup src=\"/app-router.mjs\"><\/script>\n</template>\n", Yh = "<!doctype html>\n<html lang=\"en\" class=\"dark\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@app/\": \"/\",\n \"@li3/\": \"https://at-li3.static.apphor.de/\",\n \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\"\n }\n }\n <\/script>\n </head>\n <body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-flow-background p-4 text-flow-foreground font-sans md:p-6\">\n <link rel=\"stylesheet\" href=\"/on.css\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <template if=\"page === 'secrets'\"\n ><app-header\n title=\"Secrets\"\n subtitle=\"Write-only credentials available to workflows\"\n back=\"/runs\"\n back-label=\"Back to dashboard\"\n ></app-header\n ></template>\n <template if=\"notice\">\n <div\n role=\"status\"\n class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\"\n bind-class=\"noticeError ? 'border-flow-error/40 bg-flow-error/10 text-flow-error' : 'border-flow-success/40 bg-flow-success/10 text-flow-success'\"\n >\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n <span>{{ notice }}</span>\n </div>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <nav\n class=\"workflow-list rounded-2xl border border-flow-border bg-flow-card p-4 shadow-sm shadow-flow-foreground/5\"\n class-hidden=\"page === 'secrets' || page === 'editor'\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-flow-border pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-flow-primary\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-flow-foreground\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-flow-primary px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-flow-primary focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"divide-y divide-flow-border\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li class=\"flex min-h-14 items-center gap-3 px-2 py-2\">\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"min-w-0 flex-1 text-left text-sm text-flow-secondary outline-none hover:text-flow-primary focus-visible:rounded focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span class=\"flex min-w-0 items-center gap-3\">\n <span class=\"truncate text-base font-semibold text-flow-foreground\">{{ workflow.name }}</span>\n <span class=\"shrink-0 font-mono text-xs text-flow-secondary\">v{{ workflow.revision }}</span>\n </span>\n </a>\n <span\n class=\"rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-wide\"\n bind-class=\"workflow.status === 'published' ? 'border-flow-success/30 bg-flow-success/10 text-flow-success' : workflow.status === 'archived' ? 'border-flow-border bg-flow-sidebar text-flow-secondary' : 'border-flow-warning/40 bg-flow-warning/10 text-flow-warning'\"\n >{{ workflow.status }}</span\n >\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n attr-aria-label=\"'Edit ' + workflow.name\"\n class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-flow-secondary outline-none hover:bg-flow-sidebar hover:text-flow-primary focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span class=\"sr-only\">Edit {{ workflow.name }}</span>\n <lucide-icon icon=\"pencil\" size=\"16\" aria-hidden=\"true\"></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-flow-border px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-flow-secondary\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-flow-secondary\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-flow-secondary\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-2xl border border-flow-border bg-flow-card shadow-sm shadow-flow-foreground/5\"\n class-hidden=\"page === 'list' || page === 'workflows' || page === 'secrets'\"\n >\n <div class=\"border-b border-flow-border px-4 pt-3 sm:px-5\">\n <a\n href=\"/workflows\"\n class=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-flow-secondary outline-none transition hover:bg-flow-sidebar hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"14\"></lucide-icon>\n Back to workflows\n </a>\n </div>\n <div class=\"border-b border-flow-border bg-flow-panel px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-end\">\n <label class=\"inline-flex items-center gap-2 text-sm text-flow-secondary\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-flow-border bg-flow-panel text-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button\n type=\"button\"\n aria-label=\"Get AI workflow help\"\n title=\"Get AI workflow help\"\n on-click=\"openAiHelp()\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-primary/50 px-3 py-2.5 text-sm font-medium text-flow-primary outline-none hover:bg-flow-primary/10 focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"sparkles\" size=\"16\"></lucide-icon><span class=\"hidden md:inline\">AI help</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Save workflow draft\"\n title=\"Save workflow draft\"\n on-click=\"save()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-flow-primary px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-flow-primary disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Save draft</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Publish workflow\"\n title=\"Publish workflow\"\n on-click=\"publish()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || enabled !== savedEnabled\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-success/50 px-3 py-2.5 text-sm font-medium text-flow-success transition hover:bg-flow-success/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Publish</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Run workflow now\"\n title=\"Run saved workflow draft now\"\n on-click=\"runNow()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || !validation.valid\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-info/50 px-3 py-2.5 text-sm font-medium text-flow-info transition hover:bg-flow-info/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"play\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Run now</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete workflow\"\n title=\"Delete workflow\"\n on-click=\"remove()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-error/50 px-3 py-2.5 text-sm font-medium text-flow-error transition hover:bg-flow-error/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Delete</span>\n </button>\n </div>\n </div>\n </div>\n <div class=\"p-3 sm:p-5\">\n <div class=\"mb-2 flex items-center justify-between px-1 text-xs text-flow-secondary\">\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n aria-label=\"Validate workflow\"\n title=\"Validate workflow\"\n on-click=\"validate(1)\"\n bind-disabled=\"validation.running\"\n bind-class=\"{ 'hidden': readOnly, 'text-flow-success': validation.valid, 'text-rose-400': !validation.valid }\"\n class=\"disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon\n class=\"inline-block\"\n bind-icon=\"validation.running ? 'loader-circle' : (validation.valid ? 'circle-check' : 'circle-alert')\"\n bind-class=\"validation.running ? 'animate-spin' : ''\"\n size=\"16\"\n ></lucide-icon>\n </button>\n <span class=\"font-medium\" bind-class=\"validation.error ? 'bg-flow-error/10 text-flow-error' : ''\"\n >{{ validation.error || '' }}</span\n >\n </div>\n <template if=\"revision\">\n <button\n type=\"button\"\n on-click=\"openRevisionDialog()\"\n class=\"font-mono text-flow-primary outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n Revision {{ revision }}\n </button>\n </template>\n <template if=\"!revision\"><span class=\"font-mono\">Unsaved draft</span></template>\n </div>\n <template if=\"readOnly\">\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-auto rounded-lg border border-flow-border bg-flow-foreground p-4\"\n bind-source=\"source\"\n ></code-block>\n </template>\n <template if=\"!readOnly\">\n <code-editor\n id=\"source-yaml\"\n language=\"yaml\"\n theme=\"atom-one-dark\"\n nostatus=\"true\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-flow-border bg-flow-foreground\"\n bind-value=\"source\"\n on-change=\"setSource($event.detail)\"\n ></code-editor>\n </template>\n <details\n class=\"mt-4 overflow-hidden rounded-xl border border-flow-border bg-flow-panel\"\n on-toggle=\"loadHelp($event)\"\n >\n <summary\n class=\"flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium text-flow-secondary outline-none transition hover:bg-flow-panel focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span class=\"inline-flex items-center gap-2\"\n ><lucide-icon icon=\"circle-question-mark\" size=\"16\"></lucide-icon>Syntax</span\n >\n <span class=\"ml-2 text-xs font-normal text-flow-secondary\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-flow-border bg-flow-foreground px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-flow-secondary\">Expand to load the workflow syntax reference.</p>\n </div>\n </details>\n </div>\n </section>\n </section>\n\n <template if=\"aiDialogOpen\">\n <div\n class=\"fixed inset-0 z-50 flex items-center justify-center bg-flow-foreground/70 p-4\"\n role=\"presentation\"\n >\n <div\n class=\"flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-flow-border bg-flow-panel shadow-2xl\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby=\"workflow-ai-title\"\n >\n <div class=\"flex items-start justify-between gap-3 border-b border-flow-border px-5 py-4\">\n <div>\n <h2 id=\"workflow-ai-title\" class=\"text-lg font-semibold text-flow-foreground\">AI workflow help</h2>\n <p class=\"mt-1 text-sm text-flow-secondary\">\n Describe the change you want. The suggestion will not modify the editor until you accept it.\n </p>\n </div>\n <button\n type=\"button\"\n on-click=\"closeAiHelp()\"\n aria-label=\"Close AI help\"\n class=\"rounded-lg p-2 text-flow-secondary hover:bg-flow-sidebar\"\n >\n <lucide-icon icon=\"x\" size=\"17\"></lucide-icon>\n </button>\n </div>\n <div class=\"min-h-0 overflow-auto p-5\">\n <label class=\"sr-only\" for=\"workflow-ai-request\">Requested workflow change</label>\n <textarea\n id=\"workflow-ai-request\"\n bind-value=\"aiRequest\"\n placeholder=\"Example: Add a Docker cache volume to the build step.\"\n class=\"min-h-24 w-full rounded-xl border border-flow-border bg-flow-panel p-3 text-sm text-flow-foreground outline-none focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n ></textarea>\n <p class=\"mt-2 text-xs text-flow-error\" class-hidden=\"!aiError\">{{ aiError }}</p>\n <template if=\"aiDiffHtml\">\n <div class=\"mt-4\">\n <p class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-flow-secondary\">\n Suggested changes\n </p>\n <pre\n class=\"max-h-[36rem] overflow-auto rounded-xl border border-flow-border bg-flow-foreground p-4 font-mono text-xs leading-6\"\n bind-innerhtml=\"aiDiffHtml\"\n ></pre>\n </div>\n </template>\n </div>\n <div class=\"flex items-center justify-end gap-2 border-t border-flow-border px-5 py-3\">\n <button\n type=\"button\"\n on-click=\"closeAiHelp()\"\n class=\"rounded-lg border border-flow-border px-3 py-2 text-sm text-flow-secondary hover:bg-flow-sidebar\"\n >\n Cancel</button\n ><template if=\"aiDiffHtml\"\n ><button\n type=\"button\"\n on-click=\"rejectAiSuggestion()\"\n class=\"rounded-lg border border-flow-error/50 px-3 py-2 text-sm text-flow-error hover:bg-flow-error/10\"\n >\n Reject</button\n ><button\n type=\"button\"\n on-click=\"acceptAiSuggestion()\"\n class=\"rounded-lg bg-flow-primary px-3 py-2 text-sm font-semibold text-white hover:bg-flow-primary\"\n >\n Accept suggestion\n </button></template\n ><button\n type=\"button\"\n on-click=\"requestAiHelp()\"\n bind-disabled=\"aiLoading || !aiRequest.trim()\"\n class=\"rounded-lg bg-flow-primary px-3 py-2 text-sm font-semibold text-white hover:bg-flow-primary disabled:opacity-50\"\n >\n {{ aiLoading ? 'Thinking...' : 'Suggest' }}\n </button>\n </div>\n </div>\n </div>\n </template>\n\n <template if=\"revisionDialogOpen\">\n <div\n class=\"fixed inset-0 z-50 flex items-center justify-center bg-flow-foreground/70 p-4\"\n role=\"presentation\"\n >\n <div\n class=\"flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-flow-border bg-flow-panel shadow-2xl\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby=\"revision-dialog-title\"\n >\n <div class=\"flex items-center justify-between border-b border-flow-border px-4 py-3\">\n <div>\n <h2 id=\"revision-dialog-title\" class=\"font-semibold text-flow-foreground\">\n Workflow revision {{ revisionNumber }}\n </h2>\n <p class=\"text-xs text-flow-secondary\">Read-only source snapshot</p>\n </div>\n <button\n type=\"button\"\n on-click=\"closeRevisionDialog()\"\n aria-label=\"Close revision dialog\"\n class=\"rounded-lg p-2 text-flow-secondary outline-none hover:bg-flow-sidebar hover:text-white focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"x\" size=\"17\"></lucide-icon>\n </button>\n </div>\n <div class=\"min-h-0 flex-1 overflow-auto p-4\">\n <p class=\"mb-3 text-sm text-flow-error\" class-hidden=\"!revisionError\">{{ revisionError }}</p>\n <pre\n class=\"min-h-[20rem] overflow-auto rounded-lg border border-flow-border bg-flow-foreground p-4 font-mono text-xs leading-6\"\n bind-innerhtml=\"revisionDiffHtml\"\n ></pre>\n </div>\n <div class=\"flex items-center justify-between border-t border-flow-border px-4 py-3\">\n <button\n type=\"button\"\n on-click=\"navigateRevision(-1)\"\n bind-disabled=\"revisionLoading || revisionNumber <= 1\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-border px-3 py-2 text-sm text-flow-foreground outline-none hover:bg-flow-sidebar focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-40\"\n >\n <lucide-icon icon=\"chevron-left\" size=\"16\"></lucide-icon>Previous\n </button>\n <button\n type=\"button\"\n on-click=\"navigateRevision(1)\"\n bind-disabled=\"revisionLoading || revisionNumber >= revision\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-border px-3 py-2 text-sm text-flow-foreground outline-none hover:bg-flow-sidebar focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-40\"\n >\n Next<lucide-icon icon=\"chevron-right\" size=\"16\"></lucide-icon>\n </button>\n </div>\n </div>\n </div>\n </template>\n\n <section\n class=\"secrets-panel rounded-2xl border border-flow-border bg-flow-card p-4 shadow-sm shadow-flow-foreground/5 sm:p-5\"\n class-hidden=\"page === 'list' || page === 'workflows' || page === 'editor'\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-flow-primary/10 p-2 text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-flow-foreground\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-flow-secondary\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-flow-border bg-flow-foreground px-2.5 py-1 text-xs font-mono text-flow-secondary\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <details ref=\"secretForm\" class=\"mb-5 overflow-hidden rounded-xl border border-flow-border bg-flow-panel\">\n <summary\n class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-flow-secondary outline-none hover:bg-flow-panel focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span>Add a secret</span>\n <lucide-icon icon=\"plus\" size=\"17\"></lucide-icon>\n </summary>\n <div class=\"space-y-3 border-t border-flow-border p-4\">\n <p class=\"text-xs text-flow-secondary\">Saving an existing name replaces its encrypted value.</p>\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-flow-secondary\">\n <input\n type=\"checkbox\"\n bind-checked=\"fileMode\"\n on-change=\"setFileMode($event)\"\n class=\"h-4 w-4 rounded border-flow-border bg-flow-panel text-indigo-500 focus:ring-2 focus:ring-flow-primary/20\"\n />\n Binary file secret\n </label>\n <template if=\"!fileMode\">\n <label class=\"sr-only\" for=\"secret-value\">Text secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"New secret value\"\n />\n </template>\n <template if=\"fileMode\">\n <label class=\"sr-only\" for=\"secret-file\">Binary secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2 text-xs text-flow-secondary file:mr-3 file:rounded file:border-0 file:bg-flow-secondary file:px-2 file:py-1 file:text-xs file:text-flow-foreground focus:border-flow-primary focus:outline-none focus:ring-2 focus:ring-flow-primary/20\"\n />\n </template>\n <div>\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-primary/60 px-3 py-2.5 text-sm font-medium text-flow-primary outline-none transition hover:bg-flow-primary/10 focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\n </div>\n </details>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <div\n class=\"grid min-h-14 grid-cols-[minmax(0,1fr)_2rem] items-center gap-2 rounded-xl border border-flow-border bg-flow-panel px-3.5 py-3\"\n >\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto_1rem] items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-flow-sidebar text-flow-secondary group-hover:text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-flow-foreground\">{{ name }}</span>\n <span class=\"text-xs text-flow-secondary\">Select</span>\n <lucide-icon\n icon=\"chevron-right\"\n size=\"16\"\n class=\"text-flow-secondary transition group-hover:text-flow-primary\"\n ></lucide-icon>\n </button>\n <button\n type=\"button\"\n on-click=\"removeSecret(name)\"\n aria-label=\"Delete secret {{ name }}\"\n title=\"Delete secret\"\n class=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-flow-error outline-none transition hover:bg-flow-error/10 focus-visible:ring-2 focus-visible:ring-flow-error/40\"\n >\n <lucide-icon icon=\"trash\" size=\"15\"></lucide-icon>\n </button>\n </div>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-flow-border px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-flow-secondary\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-flow-secondary\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-flow-secondary\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup src=\"/workflows-ui.mjs\"><\/script>\n </template>\n\n <script type=\"module\">\n import \"@li3/web\";\n <\/script>\n </body>\n</html>\n";
|
|
12247
|
+
var Kh = "<template component=\"app-header\">\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <header class=\"mx-auto flex w-full max-w-[78rem] items-center justify-between gap-4 border-b border-gray-800 pb-5\">\n <div class=\"flex min-w-0 items-start gap-3\">\n <template if=\"back\">\n <a\n class=\"mt-0.5 inline-flex h-9 w-9 items-center justify-center rounded-lg border border-gray-800 text-gray-400 no-underline outline-none hover:border-indigo-500 hover:text-indigo-200 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n attr-href=\"back\"\n attr-aria-label=\"backLabel || 'Back'\"\n attr-title=\"backLabel || 'Back'\"\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width=\"17\"\n height=\"17\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.8\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <path d=\"m15 18-6-6 6-6\"></path>\n </svg>\n </a>\n </template>\n <div>\n <div class=\"text-[.7rem] font-bold uppercase tracking-[.16em] text-indigo-400\">Runner engine</div>\n <h1 class=\"mt-1 text-2xl font-bold tracking-tight text-white\">{{ title }}</h1>\n <template if=\"subtitle\"><p class=\"mt-1 text-xs text-gray-400\">{{ subtitle }}</p></template>\n </div>\n </div>\n <div class=\"flex items-center gap-2\">\n <template if=\"!authenticated\">\n <a\n class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/settings\"\n aria-label=\"Settings\"\n title=\"Settings\"\n >\n <lucide-icon icon=\"user-round\" size=\"16\"></lucide-icon>\n </a>\n </template>\n <template if=\"authenticated\">\n <span class=\"hidden text-xs text-gray-400 sm:inline\">{{ userName }}</span>\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/logout\">Sign out</a>\n </template>\n <a\n class=\"rounded px-2 py-1 text-xs text-indigo-300 no-underline outline-none hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/preview\"\n target=\"_top\"\n >Preview</a\n >\n <a\n class=\"inline-flex h-9 w-9 items-center justify-center rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/help\"\n aria-label=\"Help\"\n title=\"Help\"\n >\n <svg\n aria-hidden=\"true\"\n viewBox=\"0 0 24 24\"\n width=\"16\"\n height=\"16\"\n fill=\"none\"\n stroke=\"currentColor\"\n stroke-width=\"1.8\"\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n >\n <circle cx=\"12\" cy=\"12\" r=\"9\"></circle>\n <path d=\"M9.6 9a2.5 2.5 0 1 1 4.2 1.8c-1.2 1-1.8 1.4-1.8 2.7\"></path>\n <path d=\"M12 17h.01\"></path>\n </svg>\n </a>\n <template if=\"authenticated\">\n <a\n class=\"inline-flex h-9 w-9 items-center justify-center overflow-hidden rounded-full border border-gray-700 text-indigo-200 no-underline outline-none hover:border-indigo-500 hover:bg-gray-900 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n href=\"/settings\"\n aria-label=\"Settings\"\n title=\"Settings\"\n >\n <template if=\"userPhoto\"><img attr-src=\"userPhoto\" alt=\"\" class=\"h-full w-full object-cover\" /></template>\n <template if=\"!userPhoto\"><lucide-icon icon=\"circle-user-round\" size=\"16\"></lucide-icon></template>\n </a>\n </template>\n </div>\n </header>\n <script setup src=\"/app-header.mjs\"><\/script>\n</template>\n", qh = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#f3f5f1\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <link rel=\"stylesheet\" href=\"/on.css\" />\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@app/\": \"/\",\n \"@li3/\": \"https://at-li3.static.apphor.de/\",\n \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\",\n \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\",\n \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\"\n }\n }\n <\/script>\n <link rel=\"component\" href=\"/app-router.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <title>Flow · Runs</title>\n </head>\n <body class=\"min-h-screen bg-flow-background font-sans text-flow-foreground\">\n <template app>\n <a\n href=\"#main-content\"\n class=\"sr-only z-50 rounded-md bg-flow-foreground px-4 py-2 text-white focus:not-sr-only focus:fixed focus:left-4 focus:top-4\"\n >\n Skip to content\n </a>\n <div class=\"min-h-screen md:grid md:grid-cols-[15rem_minmax(0,1fr)]\">\n <aside class=\"hidden min-h-screen border-r border-flow-border bg-flow-sidebar px-4 py-5 md:flex md:flex-col\">\n <a\n href=\"/runs\"\n on-click=\"setActive('runs')\"\n class=\"mb-8 flex items-center gap-3 rounded-lg p-2 text-flow-foreground no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary\"\n aria-label=\"Flow home\"\n >\n <span\n class=\"mr-0.5 flex h-[34px] w-[31px] -skew-y-[13deg] flex-col justify-center gap-[5px]\"\n aria-hidden=\"true\"\n ><i class=\"block h-[5px] w-[27px] rounded-[2px] bg-[#547d42]\"></i\n ><i class=\"block h-[5px] w-5 rounded-[2px] bg-[#547d42]\"></i\n ><i class=\"block h-[5px] w-[13px] rounded-[2px] bg-[#547d42]\"></i\n ></span>\n <span class=\"text-[33px] font-bold leading-none tracking-[-1.9px] text-flow-foreground\"\n >flow<span class=\"text-[#729b5b]\">.</span></span\n >\n </a>\n <nav aria-label=\"Primary navigation\" class=\"space-y-1\">\n <a\n href=\"/runs\"\n on-click=\"setActive('runs')\"\n attr-aria-current=\"activeSection === 'runs' ? 'page' : null\"\n class=\"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-flow-secondary no-underline outline-none hover:bg-white/70 hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:bg-white aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"play\" size=\"17\"></lucide-icon>Runs</a\n >\n <a\n href=\"/workflows\"\n on-click=\"setActive('workflows')\"\n attr-aria-current=\"activeSection === 'workflows' ? 'page' : null\"\n class=\"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-flow-secondary no-underline outline-none hover:bg-white/70 hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:bg-white aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>Workflows</a\n >\n <a\n href=\"/settings\"\n on-click=\"setActive('settings')\"\n attr-aria-current=\"activeSection === 'settings' ? 'page' : null\"\n class=\"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm text-flow-secondary no-underline outline-none hover:bg-white/70 hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:bg-white aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"settings\" size=\"17\"></lucide-icon>Settings</a\n >\n </nav>\n <template if=\"userAuthenticated\">\n <div class=\"mt-auto flex min-w-0 items-center gap-3 border-t border-flow-border pt-4\">\n <span\n class=\"grid h-9 w-9 shrink-0 place-items-center rounded-full bg-flow-accent text-sm font-semibold text-flow-foreground\"\n >{{ userInitial }}</span\n >\n <a\n attr-href=\"oidcProviderUrl ? oidcProviderUrl + '/me' : null\"\n target=\"_blank\"\n rel=\"noopener\"\n class=\"min-w-0 flex-1 text-flow-foreground no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary\"\n aria-label=\"Open profile at identity provider in a new tab\"\n >\n <span class=\"block truncate text-sm font-medium\">{{ user.name || user.email }}</span>\n <span class=\"block truncate text-xs text-flow-secondary\">{{ user.email }}</span>\n <span\n class=\"mt-1 inline-block rounded-full bg-flow-sidebar px-2 py-0.5 text-[10px] font-medium text-flow-secondary\"\n >{{ user.role }}</span\n >\n <lucide-icon icon=\"external-link\" size=\"13\" class=\"ml-1 inline-block align-middle\"></lucide-icon>\n </a>\n </div>\n </template>\n </aside>\n <div class=\"min-w-0 pb-20 md:pb-0\">\n <div id=\"main-content\" class=\"min-h-screen min-w-0 p-4 sm:p-6 lg:p-8\">\n <app-router></app-router>\n </div>\n </div>\n <nav\n aria-label=\"Mobile navigation\"\n class=\"fixed inset-x-0 bottom-0 z-10 grid grid-cols-3 border-t border-flow-border bg-flow-sidebar/95 px-2 pb-[env(safe-area-inset-bottom)] pt-2 backdrop-blur md:hidden\"\n >\n <a\n href=\"/runs\"\n on-click=\"setActive('runs')\"\n attr-aria-current=\"activeSection === 'runs' ? 'page' : null\"\n class=\"flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg text-xs text-flow-secondary no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"play\" size=\"17\"></lucide-icon>Runs</a\n >\n <a\n href=\"/workflows\"\n on-click=\"setActive('workflows')\"\n attr-aria-current=\"activeSection === 'workflows' ? 'page' : null\"\n class=\"flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg text-xs text-flow-secondary no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>Workflows</a\n >\n <a\n href=\"/settings\"\n on-click=\"setActive('settings')\"\n attr-aria-current=\"activeSection === 'settings' ? 'page' : null\"\n class=\"flex min-h-12 flex-col items-center justify-center gap-1 rounded-lg text-xs text-flow-secondary no-underline outline-none focus-visible:ring-2 focus-visible:ring-flow-primary aria-[current=page]:font-semibold aria-[current=page]:text-flow-primary\"\n ><lucide-icon icon=\"settings\" size=\"17\"></lucide-icon>Settings</a\n >\n </nav>\n </div>\n <script setup src=\"/app-shell.mjs\"><\/script>\n </template>\n <script type=\"module\">\n import \"@li3/web\";\n <\/script>\n </body>\n</html>\n", Jh = "<template component=\"app-router\">\n <div ref=\"outlet\" aria-live=\"polite\"></div>\n <script setup src=\"/app-router.mjs\"><\/script>\n</template>\n", Yh = "<!doctype html>\n<html lang=\"en\" class=\"dark\">\n <head>\n <meta charset=\"UTF-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <meta name=\"mobile-web-app-capable\" content=\"yes\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <title>Runner Workflow Management</title>\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@app/\": \"/\",\n \"@li3/\": \"https://at-li3.static.apphor.de/\",\n \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\"\n }\n }\n <\/script>\n </head>\n <body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-flow-background p-4 text-flow-foreground font-sans md:p-6\">\n <link rel=\"stylesheet\" href=\"/on.css\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-editor.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <template if=\"page === 'secrets'\"\n ><app-header\n title=\"Secrets\"\n subtitle=\"Write-only credentials available to workflows\"\n back=\"/runs\"\n back-label=\"Back to dashboard\"\n ></app-header\n ></template>\n <template if=\"notice\">\n <div\n role=\"status\"\n class=\"flex items-center gap-2 rounded-lg border px-3 py-2.5 text-sm\"\n bind-class=\"noticeError ? 'border-flow-error/40 bg-flow-error/10 text-flow-error' : 'border-flow-success/40 bg-flow-success/10 text-flow-success'\"\n >\n <lucide-icon bind-icon=\"noticeError ? 'circle-alert' : 'circle-check'\" size=\"17\"></lucide-icon>\n <span>{{ notice }}</span>\n </div>\n </template>\n\n <section id=\"workflow-layout\" class=\"space-y-5\">\n <nav\n class=\"workflow-list rounded-2xl border border-flow-border bg-flow-card p-4 shadow-sm shadow-flow-foreground/5\"\n class-hidden=\"page === 'secrets' || page === 'editor'\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-flow-border pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-flow-primary\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-flow-foreground\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-flow-primary px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-flow-primary focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"divide-y divide-flow-border\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li class=\"flex min-h-14 items-center gap-3 px-2 py-2\">\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"min-w-0 flex-1 text-left text-sm text-flow-secondary outline-none hover:text-flow-primary focus-visible:rounded focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span class=\"flex min-w-0 items-center gap-3\">\n <span class=\"truncate text-base font-semibold text-flow-foreground\">{{ workflow.name }}</span>\n <span class=\"shrink-0 font-mono text-xs text-flow-secondary\">v{{ workflow.revision }}</span>\n </span>\n </a>\n <span\n class=\"rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-wide\"\n bind-class=\"workflow.status === 'published' ? 'border-flow-success/30 bg-flow-success/10 text-flow-success' : workflow.status === 'archived' ? 'border-flow-border bg-flow-sidebar text-flow-secondary' : 'border-flow-warning/40 bg-flow-warning/10 text-flow-warning'\"\n >{{ workflow.status }}</span\n >\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n attr-aria-label=\"'Edit ' + workflow.name\"\n class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-flow-secondary outline-none hover:bg-flow-sidebar hover:text-flow-primary focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span class=\"sr-only\">Edit {{ workflow.name }}</span>\n <lucide-icon icon=\"pencil\" size=\"16\" aria-hidden=\"true\"></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-flow-border px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-flow-secondary\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-flow-secondary\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-flow-secondary\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-2xl border border-flow-border bg-flow-card shadow-sm shadow-flow-foreground/5\"\n class-hidden=\"page === 'list' || page === 'workflows' || page === 'secrets'\"\n >\n <div class=\"border-b border-flow-border px-4 pt-3 sm:px-5\">\n <a\n href=\"/workflows\"\n class=\"inline-flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-flow-secondary outline-none transition hover:bg-flow-sidebar hover:text-flow-foreground focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"arrow-left\" size=\"14\"></lucide-icon>\n Back to workflows\n </a>\n </div>\n <div class=\"border-b border-flow-border bg-flow-panel px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-end\">\n <label class=\"inline-flex items-center gap-2 text-sm text-flow-secondary\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-flow-border bg-flow-panel text-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button\n type=\"button\"\n aria-label=\"Get AI workflow help\"\n title=\"Get AI workflow help\"\n on-click=\"openAiHelp()\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-primary/50 px-3 py-2.5 text-sm font-medium text-flow-primary outline-none hover:bg-flow-primary/10 focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"sparkles\" size=\"16\"></lucide-icon><span class=\"hidden md:inline\">AI help</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Save workflow draft\"\n title=\"Save workflow draft\"\n on-click=\"save()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-flow-primary px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-flow-primary disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Save draft</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Publish workflow\"\n title=\"Publish workflow\"\n on-click=\"publish()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || enabled !== savedEnabled\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-success/50 px-3 py-2.5 text-sm font-medium text-flow-success transition hover:bg-flow-success/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Publish</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Run workflow now\"\n title=\"Run saved workflow draft now\"\n on-click=\"runNow()\"\n bind-disabled=\"busy || !selectedId || source !== savedSource || !validation.valid\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-info/50 px-3 py-2.5 text-sm font-medium text-flow-info transition hover:bg-flow-info/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"play\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Run now</span>\n </button>\n <button\n type=\"button\"\n aria-label=\"Delete workflow\"\n title=\"Delete workflow\"\n on-click=\"remove()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-error/50 px-3 py-2.5 text-sm font-medium text-flow-error transition hover:bg-flow-error/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden md:inline\">Delete</span>\n </button>\n </div>\n </div>\n </div>\n <div class=\"p-3 sm:p-5\">\n <div class=\"mb-2 flex items-center justify-between px-1 text-xs text-flow-secondary\">\n <div class=\"flex items-center gap-2\">\n <button\n type=\"button\"\n aria-label=\"Validate workflow\"\n title=\"Validate workflow\"\n on-click=\"validate(1)\"\n bind-disabled=\"validation.running\"\n bind-class=\"{ 'hidden': readOnly, 'text-flow-success': validation.valid, 'text-rose-400': !validation.valid }\"\n class=\"disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon\n class=\"inline-block\"\n bind-icon=\"validation.running ? 'loader-circle' : (validation.valid ? 'circle-check' : 'circle-alert')\"\n bind-class=\"validation.running ? 'animate-spin' : ''\"\n size=\"16\"\n ></lucide-icon>\n </button>\n <span class=\"font-medium\" bind-class=\"validation.error ? 'bg-flow-error/10 text-flow-error' : ''\"\n >{{ validation.error || '' }}</span\n >\n </div>\n <template if=\"revision\">\n <button\n type=\"button\"\n on-click=\"openRevisionDialog()\"\n class=\"font-mono text-flow-primary outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n Revision {{ revision }}\n </button>\n </template>\n <template if=\"!revision\"><span class=\"font-mono\">Unsaved draft</span></template>\n </div>\n <template if=\"readOnly\">\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-auto rounded-lg border border-flow-border bg-flow-foreground p-4\"\n bind-source=\"source\"\n ></code-block>\n </template>\n <template if=\"!readOnly\">\n <code-editor\n id=\"source-yaml\"\n language=\"yaml\"\n theme=\"atom-one-dark\"\n nostatus=\"true\"\n class=\"block h-[calc(100vh-23rem)] min-h-[28rem] overflow-hidden rounded-lg border border-flow-border bg-flow-foreground\"\n bind-value=\"source\"\n on-change=\"setSource($event.detail)\"\n ></code-editor>\n </template>\n <details\n class=\"mt-4 overflow-hidden rounded-xl border border-flow-border bg-flow-panel\"\n on-toggle=\"loadHelp($event)\"\n >\n <summary\n class=\"flex cursor-pointer list-none items-center gap-2 px-4 py-3 text-sm font-medium text-flow-secondary outline-none transition hover:bg-flow-panel focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span class=\"inline-flex items-center gap-2\"\n ><lucide-icon icon=\"circle-question-mark\" size=\"16\"></lucide-icon>Syntax</span\n >\n <span class=\"ml-2 text-xs font-normal text-flow-secondary\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-flow-border bg-flow-foreground px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-flow-secondary\">Expand to load the workflow syntax reference.</p>\n </div>\n </details>\n </div>\n </section>\n </section>\n\n <template if=\"aiDialogOpen\">\n <div\n class=\"fixed inset-0 z-50 flex items-center justify-center bg-flow-foreground/70 p-4\"\n role=\"presentation\"\n >\n <div\n class=\"flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-flow-border bg-flow-panel shadow-2xl\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby=\"workflow-ai-title\"\n >\n <div class=\"flex items-start justify-between gap-3 border-b border-flow-border px-5 py-4\">\n <div>\n <h2 id=\"workflow-ai-title\" class=\"text-lg font-semibold text-flow-foreground\">AI workflow help</h2>\n <p class=\"mt-1 text-sm text-flow-secondary\">\n Describe the change you want. The suggestion will not modify the editor until you accept it.\n </p>\n </div>\n <button\n type=\"button\"\n on-click=\"closeAiHelp()\"\n aria-label=\"Close AI help\"\n class=\"rounded-lg p-2 text-flow-secondary hover:bg-flow-sidebar\"\n >\n <lucide-icon icon=\"x\" size=\"17\"></lucide-icon>\n </button>\n </div>\n <div class=\"min-h-0 overflow-auto p-5\">\n <label class=\"sr-only\" for=\"workflow-ai-request\">Requested workflow change</label>\n <textarea\n id=\"workflow-ai-request\"\n bind-value=\"aiRequest\"\n placeholder=\"Example: Add a Docker cache volume to the build step.\"\n class=\"min-h-24 w-full rounded-xl border border-flow-border bg-flow-panel p-3 text-sm text-flow-foreground outline-none focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n ></textarea>\n <p class=\"mt-2 text-xs text-flow-error\" class-hidden=\"!aiError\">{{ aiError }}</p>\n <template if=\"aiDiffHtml\">\n <div class=\"mt-4\">\n <p class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-flow-secondary\">\n Suggested changes\n </p>\n <pre\n class=\"max-h-[36rem] overflow-auto rounded-xl border border-flow-border bg-flow-foreground p-4 font-mono text-xs leading-6\"\n bind-innerhtml=\"aiDiffHtml\"\n ></pre>\n </div>\n </template>\n </div>\n <div class=\"flex items-center justify-end gap-2 border-t border-flow-border px-5 py-3\">\n <button\n type=\"button\"\n on-click=\"closeAiHelp()\"\n class=\"rounded-lg border border-flow-border px-3 py-2 text-sm text-flow-secondary hover:bg-flow-sidebar\"\n >\n Cancel</button\n ><template if=\"aiDiffHtml\"\n ><button\n type=\"button\"\n on-click=\"rejectAiSuggestion()\"\n class=\"rounded-lg border border-flow-error/50 px-3 py-2 text-sm text-flow-error hover:bg-flow-error/10\"\n >\n Reject</button\n ><button\n type=\"button\"\n on-click=\"acceptAiSuggestion()\"\n class=\"rounded-lg bg-flow-primary px-3 py-2 text-sm font-semibold text-white hover:bg-flow-primary\"\n >\n Accept suggestion\n </button></template\n ><button\n type=\"button\"\n on-click=\"requestAiHelp()\"\n bind-disabled=\"aiLoading || !aiRequest.trim()\"\n class=\"rounded-lg bg-flow-primary px-3 py-2 text-sm font-semibold text-white hover:bg-flow-primary disabled:opacity-50\"\n >\n {{ aiLoading ? 'Thinking...' : 'Suggest' }}\n </button>\n </div>\n </div>\n </div>\n </template>\n\n <template if=\"revisionDialogOpen\">\n <div\n class=\"fixed inset-0 z-50 flex items-center justify-center bg-flow-foreground/70 p-4\"\n role=\"presentation\"\n >\n <div\n class=\"flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-flow-border bg-flow-panel shadow-2xl\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-labelledby=\"revision-dialog-title\"\n >\n <div class=\"flex items-center justify-between border-b border-flow-border px-4 py-3\">\n <div>\n <h2 id=\"revision-dialog-title\" class=\"font-semibold text-flow-foreground\">\n Workflow revision {{ revisionNumber }}\n </h2>\n <p class=\"text-xs text-flow-secondary\">Read-only source snapshot</p>\n </div>\n <button\n type=\"button\"\n on-click=\"closeRevisionDialog()\"\n aria-label=\"Close revision dialog\"\n class=\"rounded-lg p-2 text-flow-secondary outline-none hover:bg-flow-sidebar hover:text-white focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <lucide-icon icon=\"x\" size=\"17\"></lucide-icon>\n </button>\n </div>\n <div class=\"min-h-0 flex-1 overflow-auto p-4\">\n <p class=\"mb-3 text-sm text-flow-error\" class-hidden=\"!revisionError\">{{ revisionError }}</p>\n <pre\n class=\"min-h-[20rem] overflow-auto rounded-lg border border-flow-border bg-flow-foreground p-4 font-mono text-xs leading-6\"\n bind-innerhtml=\"revisionDiffHtml\"\n ></pre>\n </div>\n <div class=\"flex items-center justify-between border-t border-flow-border px-4 py-3\">\n <button\n type=\"button\"\n on-click=\"navigateRevision(-1)\"\n bind-disabled=\"revisionLoading || revisionNumber <= 1\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-border px-3 py-2 text-sm text-flow-foreground outline-none hover:bg-flow-sidebar focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-40\"\n >\n <lucide-icon icon=\"chevron-left\" size=\"16\"></lucide-icon>Previous\n </button>\n <button\n type=\"button\"\n on-click=\"navigateRevision(1)\"\n bind-disabled=\"revisionLoading || revisionNumber >= revision\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-border px-3 py-2 text-sm text-flow-foreground outline-none hover:bg-flow-sidebar focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-40\"\n >\n Next<lucide-icon icon=\"chevron-right\" size=\"16\"></lucide-icon>\n </button>\n </div>\n </div>\n </div>\n </template>\n\n <section\n class=\"secrets-panel rounded-2xl border border-flow-border bg-flow-card p-4 shadow-sm shadow-flow-foreground/5 sm:p-5\"\n class-hidden=\"page === 'list' || page === 'workflows' || page === 'editor'\"\n aria-labelledby=\"secrets-title\"\n >\n <div class=\"mb-5 flex items-start justify-between gap-3\">\n <div class=\"flex items-start gap-3\">\n <span class=\"rounded-lg bg-flow-primary/10 p-2 text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"18\"></lucide-icon\n ></span>\n <div>\n <h2 id=\"secrets-title\" class=\"text-lg font-semibold text-flow-foreground\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-flow-secondary\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-flow-border bg-flow-foreground px-2.5 py-1 text-xs font-mono text-flow-secondary\"\n aria-label=\"Secret count\"\n >{{ secrets.length }}</span\n >\n </div>\n <details ref=\"secretForm\" class=\"mb-5 overflow-hidden rounded-xl border border-flow-border bg-flow-panel\">\n <summary\n class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-flow-secondary outline-none hover:bg-flow-panel focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n >\n <span>Add a secret</span>\n <lucide-icon icon=\"plus\" size=\"17\"></lucide-icon>\n </summary>\n <div class=\"space-y-3 border-t border-flow-border p-4\">\n <p class=\"text-xs text-flow-secondary\">Saving an existing name replaces its encrypted value.</p>\n <label class=\"sr-only\" for=\"secret-name\">Secret name</label>\n <input\n id=\"secret-name\"\n bind-value=\"secretName\"\n on-input=\"setSecretName($event)\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-flow-secondary\">\n <input\n type=\"checkbox\"\n bind-checked=\"fileMode\"\n on-change=\"setFileMode($event)\"\n class=\"h-4 w-4 rounded border-flow-border bg-flow-panel text-indigo-500 focus:ring-2 focus:ring-flow-primary/20\"\n />\n Binary file secret\n </label>\n <template if=\"!fileMode\">\n <label class=\"sr-only\" for=\"secret-value\">Text secret value</label>\n <input\n id=\"secret-value\"\n ref=\"secretValueInput\"\n bind-value=\"secretValue\"\n on-input=\"setSecretValue($event)\"\n type=\"password\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-flow-secondary focus:border-flow-primary focus:ring-2 focus:ring-flow-primary/20\"\n placeholder=\"New secret value\"\n />\n </template>\n <template if=\"fileMode\">\n <label class=\"sr-only\" for=\"secret-file\">Binary secret file</label>\n <input\n id=\"secret-file\"\n ref=\"secretFileInput\"\n on-change=\"setSecretFile($event)\"\n type=\"file\"\n class=\"rounded-lg border border-flow-border bg-flow-panel px-3 py-2 text-xs text-flow-secondary file:mr-3 file:rounded file:border-0 file:bg-flow-secondary file:px-2 file:py-1 file:text-xs file:text-flow-foreground focus:border-flow-primary focus:outline-none focus:ring-2 focus:ring-flow-primary/20\"\n />\n </template>\n <div>\n <button\n type=\"button\"\n aria-label=\"Save secret value\"\n title=\"Save secret value\"\n on-click=\"saveSecret()\"\n bind-disabled=\"busy\"\n class=\"inline-flex items-center justify-center gap-2 rounded-lg border border-flow-primary/60 px-3 py-2.5 text-sm font-medium text-flow-primary outline-none transition hover:bg-flow-primary/10 focus-visible:ring-2 focus-visible:ring-flow-primary/40 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\n </div>\n </details>\n <ul class=\"grid gap-2\" aria-label=\"Saved secrets\">\n <template for=\"name of secrets\">\n <li>\n <div\n class=\"grid min-h-14 grid-cols-[minmax(0,1fr)_2rem] items-center gap-2 rounded-xl border border-flow-border bg-flow-panel px-3.5 py-3\"\n >\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group grid min-w-0 grid-cols-[2rem_minmax(0,1fr)_auto_1rem] items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-flow-primary/40\"\n aria-label=\"Edit secret {{ name }}\"\n >\n <span\n class=\"flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-flow-sidebar text-flow-secondary group-hover:text-flow-primary\"\n ><lucide-icon icon=\"key-round\" size=\"15\"></lucide-icon\n ></span>\n <span class=\"min-w-0 flex-1 truncate font-mono text-sm text-flow-foreground\">{{ name }}</span>\n <span class=\"text-xs text-flow-secondary\">Select</span>\n <lucide-icon\n icon=\"chevron-right\"\n size=\"16\"\n class=\"text-flow-secondary transition group-hover:text-flow-primary\"\n ></lucide-icon>\n </button>\n <button\n type=\"button\"\n on-click=\"removeSecret(name)\"\n aria-label=\"Delete secret {{ name }}\"\n title=\"Delete secret\"\n class=\"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-flow-error outline-none transition hover:bg-flow-error/10 focus-visible:ring-2 focus-visible:ring-flow-error/40\"\n >\n <lucide-icon icon=\"trash\" size=\"15\"></lucide-icon>\n </button>\n </div>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-flow-border px-4 py-8 text-center\"\n class-hidden=\"secrets.length > 0\"\n >\n <lucide-icon icon=\"key-round\" size=\"22\" class=\"mx-auto text-flow-secondary\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-flow-secondary\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-flow-secondary\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup src=\"/workflows-ui.mjs\"><\/script>\n </template>\n\n <script type=\"module\">\n import \"@li3/web\";\n <\/script>\n </body>\n</html>\n";
|
|
12248
12248
|
//#endregion
|
|
12249
12249
|
//#region src/workflows-ui.ts
|
|
12250
12250
|
function Xh(e, t = "", n) {
|
|
@@ -12410,7 +12410,7 @@ var rg = class {
|
|
|
12410
12410
|
async setTimezone(e, t) {
|
|
12411
12411
|
await r.run("INSERT INTO user_preferences (user_id, preference, value) VALUES (?, 'timezone', ?)\n ON CONFLICT(user_id, preference) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP", [e, t]);
|
|
12412
12412
|
}
|
|
12413
|
-
}, ig = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nexport async function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) {\n if (response.status === 401) {\n // When /api/auth/token returns 401, clear OIDC in-memory token state and redirect\n // to login so the user can re-authenticate. This even applies when there was\n // no prior cached token, fixing the reported symptom where 401 was silently\n // swallowed and settings APIs stayed broken.\n token = '';\n expiresAt = 0;\n if (sessionStorage.getItem('runner-api-token')) {\n sessionStorage.removeItem('runner-api-token');\n }\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n }\n return '';\n }\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n}\n\nexport async function apiFetch(input, init = {}) {\n const target = new URL(input, window.location.href);\n if (\n target.origin !== window.location.origin ||\n (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))\n ) {\n return rawFetch(input, init);\n }\n const headers = new Headers(init.headers);\n const accessToken = await loadToken();\n if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);\n return rawFetch(input, { ...init, headers });\n}\n\nexport function setApiToken(value) {\n token = value || '';\n expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n if (token) sessionStorage.setItem('runner-api-token', token);\n else sessionStorage.removeItem('runner-api-token');\n}\n", ag = "import { onDestroy, onInit, ref } from \"@li3/web\";\n\nexport default function setup() {\n const activeSection = ref(\"runs\");\n const user = ref({ name: \"\", email: \"\", role: \"\" });\n const oidcProviderUrl = ref(\"\");\n const userAuthenticated = ref(false);\n const userInitial = ref(\"U\");\n\n const syncSection = () => {\n const path = window.location.pathname;\n activeSection.value = path.startsWith(\"/settings\")\n ? \"settings\"\n : path.startsWith(\"/workflows\")\n ? \"workflows\"\n : \"runs\";\n };\n\n const setActive = (section) => {\n activeSection.value = section;\n };\n\n onInit(() => {\n syncSection();\n window.addEventListener(\"popstate\", syncSection);\n void fetch(\"/api/auth/session\", { credentials: \"same-origin\", headers: { accept: \"application/json\" } })\n .then((response) => (response.ok ? response.json() : null))\n .then((session) => {\n if (!session?.authenticated || !session.user) return;\n user.value = session.user;\n oidcProviderUrl.value = session.providerUrl || \"\";\n userAuthenticated.value = true;\n userInitial.value = String(session.user.name || session.user.email || \"U\")\n .trim()\n .slice(0, 1)\n .toUpperCase();\n })\n .catch((error) => console.error(\"Unable to load the signed-in profile\", error));\n });\n\n onDestroy(() => window.removeEventListener(\"popstate\", syncSection));\n\n return { activeSection, user, userAuthenticated, userInitial, oidcProviderUrl, setActive };\n}\n", og = "import { defineProp, onInit, ref } from '@li3/web';\n\nexport default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = ref('');\n const userPhoto = ref('');\n onInit(async () => {\n try {\n const response = await fetch('/api/auth/session');\n if (!response.ok) return;\n const session = await response.json();\n authConfigured.value = session.configured;\n authenticated.value = session.authenticated;\n userName.value = session.user?.name || session.user?.email || '';\n userPhoto.value = session.user?.photo || '';\n } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName, userPhoto };\n}\n", sg = "import { getElement, load, onDestroy, onInit, templateRef } from \"@li3/web\";\n\nexport default function () {\n const outlet = templateRef(\"outlet\");\n let navigating = false;\n const settingsSections = {\n \"/settings/secrets\": \"secrets\",\n \"/settings/tokens\": \"keys\",\n \"/settings/notifications\": \"notifications\",\n \"/settings/workers\": \"workers\",\n \"/settings/timezone\": \"timezone\",\n };\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === \"/\" || path === \"/runs\")\n return { component: \"page-dashboard\", url: \"/pages/dashboard.html\", page: \"dashboard\" };\n if (path.match(/^\\/runs\\/\\d+$/))\n return { component: \"page-run\", url: `/pages/run.html?jobId=${path.split(\"/\").pop()}`, page: \"run\" };\n if (path === \"/help\") return { component: \"page-help\", url: \"/pages/help.html\", page: \"help\" };\n if (path === \"/settings\" || settingsSections[path]) {\n const section = settingsSections[path] || \"timezone\";\n return {\n component: \"page-settings\",\n url: `/pages/settings.html?page=settings${path === \"/settings\" ? \"\" : `#section-${section}`}`,\n page: \"settings\",\n };\n }\n if (path === \"/workflows\")\n return { component: \"page-workflows\", url: \"/pages/workflows.html?page=workflows\", page: \"workflows\" };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor)\n return {\n component: \"page-workflow-editor\",\n url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get(\"revision\") ? `&revision=${url.searchParams.get(\"revision\")}` : \"\"}`,\n page: \"editor\",\n };\n return { component: \"page-not-found\", page: \"not-found\" };\n };\n const navigate = async (replace = false) => {\n if (navigating) return;\n navigating = true;\n try {\n const current = route();\n document.body.dataset.page = current.page;\n if (current.page === \"not-found\") {\n outlet.value.innerHTML =\n '<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold\">404 — Page not found</h1><p class=\"mt-2 text-sm\">The page you are looking for does not exist. <a href=\"/\">Go home</a> or <a href=\"/runs\">view runs</a>.</p></main>';\n } else {\n await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\n const settingsSection = settingsSections[window.location.pathname];\n if (settingsSection && !window.location.hash) {\n history.replaceState(\n null,\n \"\",\n `${window.location.pathname}${window.location.search}#section-${settingsSection}`,\n );\n }\n if (window.location.hash) {\n requestAnimationFrame(() =>\n requestAnimationFrame(() => document.querySelector(window.location.hash)?.scrollIntoView()),\n );\n }\n }\n if (replace) history.replaceState(null, \"\", window.location.href);\n } catch (error) {\n outlet.value.innerHTML = `<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold text-white\">Unable to load page</h1><p class=\"mt-2 text-sm text-rose-300\">${String(error.message || error)}</p></main>`;\n } finally {\n navigating = false;\n }\n };\n const handleClick = (event) => {\n const anchor = event.target.closest?.(\"a\");\n if (!anchor || anchor.target || anchor.hasAttribute(\"download\")) return;\n const target = new URL(anchor.href, window.location.href);\n if (\n target.origin !== window.location.origin ||\n !target.pathname.startsWith(\"/\") ||\n target.pathname.startsWith(\"/auth/\")\n )\n return;\n if (target.pathname.startsWith(\"/api/\") || target.pathname.startsWith(\"/webhooks/\")) return;\n if (target.pathname === window.location.pathname && target.hash) return;\n event.preventDefault();\n history.pushState(null, \"\", `${target.pathname}${target.search}${target.hash}`);\n void navigate();\n };\n const element = getElement();\n onInit(() => {\n element.addEventListener(\"click\", handleClick);\n window.addEventListener(\"popstate\", navigate);\n void navigate(true);\n });\n onDestroy(() => {\n element.removeEventListener(\"click\", handleClick);\n window.removeEventListener(\"popstate\", navigate);\n });\n return {};\n}\n", cg = "import { onDestroy, onInit, ref } from \"@li3/web\";\nimport { apiFetch } from \"@app/api-client.mjs\";\n\nexport default function setup() {\n const jobs = ref([]),\n refreshError = ref(false),\n hasMore = ref(false),\n loadingMore = ref(false),\n page = ref(1);\n const pageSize = 8;\n const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\");\n const filter = ref(\"\"),\n activeFilter = ref(\"\"),\n pushConfigured = ref(false);\n const workflows = ref([]),\n workers = ref([]),\n workflowFilter = ref(\"\"),\n workerFilter = ref(\"\"),\n statusFilter = ref(\"all\");\n const notificationsSupported = ref(\"Notification\" in window && \"serviceWorker\" in navigator);\n const notificationsEnabled = ref(\n notificationsSupported.value &&\n Notification.permission === \"granted\" &&\n localStorage.getItem(\"runner-notifications\") === \"enabled\",\n );\n const notificationNotice = ref(\"\");\n let refreshTimer,\n eventSource,\n refreshing = false,\n refreshPending = false,\n searchInProgress = false,\n refreshGeneration = 0;\n const upper = (value) => String(value || \"\").toUpperCase();\n const workflowName = (id) => workflows.value.find((workflow) => workflow.id === id)?.name || \"Workflow\";\n const formatDate = (value) => {\n if (!value) return \"—\";\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n if (Number.isNaN(date.getTime())) return \"—\";\n return new Intl.DateTimeFormat(undefined, {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n timeZone: timezone.value,\n }).format(date);\n };\n const filteredJobs = () =>\n jobs.value.filter(\n (job) =>\n (!workerFilter.value || job.workerId === workerFilter.value) &&\n (statusFilter.value === \"all\" ||\n (statusFilter.value === \"running\"\n ? [\"pending\", \"running\"].includes(job.status)\n : job.status === statusFilter.value)) &&\n (!activeFilter.value ||\n /^[A-Za-z0-9_-]+:.+$/.test(activeFilter.value) ||\n `${workflowName(job.workflowId)} ${job.id} ${job.status} ${job.workerId || \"\"}`\n .toLowerCase()\n .includes(activeFilter.value.toLowerCase())),\n );\n const visibleJobs = () => filteredJobs().slice((page.value - 1) * pageSize, page.value * pageSize);\n const statusTabs = () => [\n { status: \"all\", label: \"All runs\", count: jobs.value.length },\n {\n status: \"running\",\n label: \"In progress\",\n count: jobs.value.filter((job) => [\"pending\", \"running\"].includes(job.status)).length,\n },\n {\n status: \"failed\",\n label: \"Failed\",\n count: jobs.value.filter((job) => job.status === \"failed\").length,\n },\n {\n status: \"success\",\n label: \"Passed\",\n count: jobs.value.filter((job) => job.status === \"success\").length,\n },\n ];\n const loadTimezone = async () => {\n const response = await apiFetch(\"/api/preferences\", { headers: { accept: \"application/json\" } });\n if (!response.ok) throw new Error(`Loading timezone preference failed: ${response.status}`);\n const preferences = await response.json();\n if (preferences.timezone) timezone.value = preferences.timezone;\n };\n const loadOptions = async () => {\n const load = async (url, key, target, getLabel) => {\n try {\n const response = await apiFetch(url, { headers: { accept: \"application/json\" } });\n if (!response.ok) return;\n const items = (await response.json())[key] || [];\n target.value = items.map((item) => ({ id: item.id || item.workerId, name: getLabel(item) }));\n } catch {\n /* Filters remain optional when scope is unavailable. */\n }\n };\n await Promise.all([\n load(\"/api/workflows\", \"workflows\", workflows, (item) => item.name || \"Workflow\"),\n load(\"/api/workers\", \"workers\", workers, (item) => item.workerId || \"Worker\"),\n ]);\n };\n const terminalStatuses = new Set([\"success\", \"failed\", \"cancelled\"]);\n const statusIcons = {\n success: \"circle-check\",\n failed: \"circle-x\",\n pending: \"clock\",\n running: \"loader-circle\",\n };\n const statusClass = (status) =>\n ({\n success: \"border-emerald-300 bg-emerald-100 text-emerald-800\",\n failed: \"border-rose-300 bg-rose-100 text-rose-800\",\n pending: \"border-amber-300 bg-amber-100 text-amber-900\",\n running: \"border-blue-300 bg-blue-100 text-blue-800\",\n cancelled: \"border-slate-300 bg-slate-100 text-slate-700\",\n })[status] || \"border-slate-300 bg-slate-100 text-slate-700\";\n const statusIcon = (status) => statusIcons[status] || \"circle-help\";\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || ![\"success\", \"failed\"].includes(job.status)) return;\n try {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n job.status === \"success\" ? `Job #${job.id} succeeded` : `Job #${job.id} failed`,\n {\n body: `${workflowName(job.workflowId)} finished with status ${job.status}.`,\n icon: \"/app-icon.svg\",\n badge: \"/app-icon.svg\",\n tag: `runner-job-${job.id}`,\n renotify: true,\n data: { url: `/runs/${job.id}` },\n },\n );\n } catch (error) {\n console.error(\"Unable to show job notification\", error);\n }\n };\n const urlBase64ToUint8Array = (value) => {\n const padding = \"=\".repeat((4 - (value.length % 4)) % 4);\n const raw = atob((value + padding).replace(/-/g, \"+\").replace(/_/g, \"/\"));\n return Uint8Array.from([...raw].map((character) => character.charCodeAt(0)));\n };\n const enablePushNotifications = async () => {\n try {\n if (!notificationsSupported.value) return;\n const response = await apiFetch(\"/api/push/public-key\", { headers: { accept: \"application/json\" } });\n if (!response.ok) return;\n const { publicKey } = await response.json();\n pushConfigured.value = true;\n const registration = await navigator.serviceWorker.ready;\n const subscription =\n (await registration.pushManager.getSubscription()) ||\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(publicKey),\n }));\n const saved = await apiFetch(\"/api/push/subscriptions\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(subscription),\n });\n if (!saved.ok) throw new Error(`Push subscription failed: ${saved.status}`);\n } catch (error) {\n console.error(\"Unable to enable background notifications\", error);\n }\n };\n const disablePushNotifications = async () => {\n try {\n const subscription = await (await navigator.serviceWorker.ready).pushManager.getSubscription();\n if (!subscription) return;\n await apiFetch(\"/api/push/subscriptions\", {\n method: \"DELETE\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ endpoint: subscription.endpoint }),\n });\n await subscription.unsubscribe();\n } catch (error) {\n console.error(\"Unable to disable background notifications\", error);\n }\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem(\"runner-notifications\");\n await disablePushNotifications();\n notificationNotice.value = \"Job notifications are paused on this device.\";\n return;\n }\n if (Notification.permission === \"denied\") {\n notificationNotice.value = \"Notifications are blocked. Allow them in this site’s Android browser settings.\";\n return;\n }\n notificationsEnabled.value = (await Notification.requestPermission()) === \"granted\";\n if (notificationsEnabled.value) {\n localStorage.setItem(\"runner-notifications\", \"enabled\");\n await enablePushNotifications();\n notificationNotice.value =\n \"Notifications enabled. Background delivery is available when server push is configured.\";\n } else notificationNotice.value = \"Notification permission was not granted.\";\n };\n const refreshJobs = async (isSearch = false) => {\n if (searchInProgress && !isSearch) return;\n if (refreshing && !isSearch) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n const generation = refreshGeneration;\n try {\n const activeIds = jobs.value.filter((job) => !terminalStatuses.has(job.status)).map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length\n ? Math.max(0, Math.min(...activeIds) - 1)\n : knownIds.length\n ? Math.max(...knownIds)\n : null;\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : \"\";\n const workflowQuery = workflowFilter.value ? `&workflowId=${encodeURIComponent(workflowFilter.value)}` : \"\";\n const url =\n afterId === null\n ? `/api/jobs?limit=100${filterQuery}${workflowQuery}`\n : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}${workflowQuery}`;\n const response = await apiFetch(url, { headers: { accept: \"application/json\" } });\n if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n const data = await response.json();\n if (generation !== refreshGeneration) return;\n hasMore.value = data.hasMore;\n const previousStatuses = new Map(jobs.value.map((job) => [job.id, job.status]));\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) {\n const previousStatus = previousStatuses.get(job.id);\n merged.set(job.id, job);\n if (previousStatus && !terminalStatuses.has(previousStatus) && terminalStatuses.has(job.status))\n void notifyCompletedJob(job);\n }\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n refreshError.value = false;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n refreshing = false;\n if (refreshPending && !searchInProgress) {\n refreshPending = false;\n void refreshJobs();\n }\n }\n };\n const loadMore = async () => {\n if (loadingMore.value || !hasMore.value || !jobs.value.length) return;\n loadingMore.value = true;\n try {\n const beforeId = Math.min(...jobs.value.map((job) => Number(job.id)));\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : \"\";\n const workflowQuery = workflowFilter.value ? `&workflowId=${encodeURIComponent(workflowFilter.value)}` : \"\";\n const response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}${workflowQuery}`, {\n headers: { accept: \"application/json\" },\n });\n if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`);\n const data = await response.json();\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) merged.set(job.id, job);\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n hasMore.value = data.hasMore;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n loadingMore.value = false;\n }\n };\n const updateSearchUrl = (value) => {\n const params = new URLSearchParams(window.location.search);\n if (value) params.set(\"search\", value);\n else params.delete(\"search\");\n if (workflowFilter.value) params.set(\"workflow\", workflowFilter.value);\n else params.delete(\"workflow\");\n const query = params.toString();\n history.pushState(null, \"\", `${window.location.pathname}${query ? `?${query}` : \"\"}`);\n };\n const applyFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\n page.value = 1;\n activeFilter.value = filter.value.trim();\n updateSearchUrl(activeFilter.value);\n jobs.value = [];\n hasMore.value = false;\n try {\n await refreshJobs(true);\n } finally {\n searchInProgress = false;\n refreshPending = false;\n }\n };\n const clearFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\n page.value = 1;\n filter.value = \"\";\n activeFilter.value = \"\";\n updateSearchUrl(\"\");\n jobs.value = [];\n hasMore.value = false;\n try {\n await refreshJobs(true);\n } finally {\n searchInProgress = false;\n refreshPending = false;\n }\n };\n const setFilter = (event) => {\n filter.value = event.target.value;\n };\n const setWorkflowFilter = async (event) => {\n searchInProgress = true;\n refreshGeneration++;\n page.value = 1;\n workflowFilter.value = event.target.value;\n updateSearchUrl(activeFilter.value);\n jobs.value = [];\n hasMore.value = false;\n try {\n await refreshJobs(true);\n } finally {\n searchInProgress = false;\n refreshPending = false;\n }\n };\n const setWorkerFilter = (event) => {\n page.value = 1;\n workerFilter.value = event.target.value;\n };\n const setStatusFilter = (status) => {\n page.value = 1;\n statusFilter.value = status;\n };\n const previousPage = () => {\n page.value = Math.max(1, page.value - 1);\n };\n const nextPage = async () => {\n const pageCount = Math.max(1, Math.ceil(filteredJobs().length / pageSize));\n if (page.value < pageCount) {\n page.value += 1;\n return;\n }\n if (!hasMore.value || loadingMore.value) return;\n const previousLength = jobs.value.length;\n await loadMore();\n if (jobs.value.length > previousLength) page.value += 1;\n };\n const hasNextPage = () => page.value < Math.ceil(filteredJobs().length / pageSize) || hasMore.value;\n onInit(() => {\n const params = new URLSearchParams(window.location.search);\n const initialSearch = params.get(\"search\")?.trim() || \"\";\n filter.value = initialSearch;\n activeFilter.value = initialSearch;\n workflowFilter.value = params.get(\"workflow\") || \"\";\n void loadOptions();\n void loadTimezone().catch((error) => console.error(\"Unable to load timezone preference\", error));\n if (notificationsSupported.value)\n navigator.serviceWorker\n .register(\"/service-worker.js\")\n .then(() => {\n if (notificationsEnabled.value) void enablePushNotifications();\n })\n .catch((error) => console.error(\"Service worker registration failed\", error));\n eventSource = new EventSource(\"/api/events\");\n eventSource.addEventListener(\"jobs.available\", refreshJobs);\n eventSource.addEventListener(\"jobs.changed\", refreshJobs);\n eventSource.onerror = () => {\n refreshError.value = true;\n };\n void refreshJobs(true);\n refreshTimer = setInterval(refreshJobs, 60000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n });\n return {\n jobs,\n visibleJobs,\n statusTabs,\n workflows,\n workers,\n workflowFilter,\n workerFilter,\n statusFilter,\n page,\n hasNextPage,\n refreshError,\n hasMore,\n loadingMore,\n filter,\n activeFilter,\n pushConfigured,\n setFilter,\n setWorkflowFilter,\n setWorkerFilter,\n setStatusFilter,\n workflowName,\n formatDate,\n statusClass,\n statusIcon,\n loadMore,\n previousPage,\n nextPage,\n applyFilter,\n clearFilter,\n upper,\n notificationsSupported,\n notificationsEnabled,\n notificationNotice,\n toggleNotifications,\n };\n}\n", lg = "\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '@app/api-client.mjs';\n import { formatTimestampedLogLine } from '@app/timezone-format.mjs';\n\n export default function setup() {\n const jobId = window.location.pathname.split('/').filter(Boolean).pop() || '';\n const report = ref({ jobId });\n const previousRuns = ref([]);\n const aiMessages = ref([]);\n const aiStepId = ref('');\n const aiQuestion = ref('');\n const aiError = ref('');\n const aiLoading = ref(false);\n const manualRestartOpen = ref(false);\n const manualRestartLoading = ref(false);\n const manualInputs = ref([]);\n let previousRunsLoaded = false;\n const step = ref(0);\n const detailTab = ref('logs');\n const now = ref(Date.now());\n const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');\n const ansiUp = new AnsiUp();\n ansiUp.use_classes = false;\n let eventSource;\n let refreshTimer;\n let clockTimer;\n let refreshing = false;\n let refreshPending = false;\n const cancelling = ref(false);\n\n const active = computed(() => ['pending', 'running'].includes(report.value.status));\n const inputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const originalInputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const timing = computed(() => {\n if (report.value.status === 'pending') return 'Waiting for a worker';\n if (report.value.status === 'running') {\n return `Running for ${Math.max(0, now.value - Date.parse(report.value.startedAt))}ms`;\n }\n return `Finished in ${report.value.durationMs}ms`;\n });\n\n const upper = (value) => String(value || '').toUpperCase();\n const formatDate = (value) => {\n if (!value) return '—';\n const text = String(value);\n const date = new Date(text.includes('T') ? text : `${text.replace(' ', 'T')}Z`);\n if (Number.isNaN(date.getTime())) return '—';\n return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: timezone.value }).format(date);\n };\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) return '—';\n const seconds = Math.floor(value / 1000);\n return `${seconds > 0 ? `${seconds}m ` : ''}${seconds % 60}s`;\n };\n const formatStepCount = () => {\n const successful = report.steps?.filter((s) => s.status === 'success').length || 0;\n const total = report.steps?.length || 0;\n return `${successful} / ${total}`;\n };\n const loadTimezone = async () => {\n const response = await apiFetch('/api/preferences', { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Loading timezone preference failed: ${response.status}`);\n const preferences = await response.json();\n if (preferences.timezone) timezone.value = preferences.timezone;\n };\n const artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await apiFetch(artifactUrl(path));\n if (!response.ok) return;\n const link = document.createElement('a');\n link.href = URL.createObjectURL(await response.blob());\n link.download = path.split('/').pop() || 'artifact';\n link.click();\n URL.revokeObjectURL(link.href);\n };\n const loadPreviousRuns = async () => {\n if (previousRunsLoaded || !report.value.parentId) return;\n const lineage = [];\n const visited = new Set();\n let parentId = report.value.parentId;\n while (parentId && !visited.has(String(parentId)) && lineage.length < 50) {\n visited.add(String(parentId));\n const response = await apiFetch(`/api/runs/${parentId}`, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Previous run request failed: ${response.status}`);\n const parent = await response.json();\n lineage.push({ id: parent.jobId, status: parent.status, startedAt: parent.startedAt });\n parentId = parent.parentId;\n }\n previousRuns.value = lineage;\n previousRunsLoaded = true;\n };\n const stepLog = (step) => {\n if (step.status === 'skipped') return '';\n if (step.status === 'running')\n return '<span class=\"text-indigo-400\">Step is running. Logs will appear after it finishes.</span>';\n if (step.status === 'pending') return '<span class=\"text-gray-500\">Waiting to run.</span>';\n if (step.logContent) {\n const localized = step.logContent.split('\\n').map((line) => formatTimestampedLogLine(line, timezone.value)).join('\\n');\n return ansiUp.ansi_to_html(localized);\n }\n return '<span class=\"text-gray-500\">(No terminal log output recorded for this step)</span>';\n };\n const sanitizeAiHtml = (html) => {\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach((node) => node.remove());\n parsed.querySelectorAll('*').forEach((element) => {\n [...element.attributes].forEach((attribute) => {\n if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);\n if (['href', 'src'].includes(attribute.name.toLowerCase()) && /^(javascript|data):/i.test(attribute.value)) element.removeAttribute(attribute.name);\n });\n });\n return parsed.body.innerHTML;\n };\n const renderAiMarkdown = (content) => sanitizeAiHtml(marked.parse(content));\n const askAi = async (step, event) => {\n event.stopPropagation();\n aiStepId.value = step.id;\n aiMessages.value = [];\n aiQuestion.value = '';\n await requestAiHelp(step.id, 'Please explain why this step failed and what I should do next.');\n };\n const requestAiHelp = async (stepId, question) => {\n aiLoading.value = true;\n aiError.value = '';\n try {\n const response = await apiFetch(`/api/runs/${report.value.jobId}/ai-help`, {\n method: 'POST',\n headers: { accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ stepId, question, conversation: aiMessages.value }),\n });\n if (!response.ok) {\n const data = await response.json().catch(() => ({}));\n throw new Error(data.error || `AI help failed: ${response.status}`);\n }\n aiMessages.value = [...aiMessages.value, { role: 'user', content: question }, { role: 'assistant', content: '' }];\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.delta) {\n const messages = [...aiMessages.value];\n messages[messages.length - 1] = { ...messages[messages.length - 1], content: messages[messages.length - 1].content + data.delta };\n aiMessages.value = messages;\n }\n if (data.error) throw new Error(data.error);\n }\n }\n const messages = [...aiMessages.value];\n const answer = messages.at(-1);\n if (answer) messages[messages.length - 1] = { ...answer, html: renderAiMarkdown(answer.content) };\n aiMessages.value = messages;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const askFollowup = async () => {\n const question = aiQuestion.value.trim();\n if (!question || !aiStepId.value) return;\n aiQuestion.value = '';\n await requestAiHelp(aiStepId.value, question);\n };\n const restartJob = async () => {\n const response = await apiFetch(`/restart/${report.value.jobId}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ inputs: report.value.inputs || {} }),\n });\n if (response.ok) {\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n }\n };\n const openManualRestart = (event) => {\n event.preventDefault();\n event.stopPropagation();\n manualInputs.value = [{ key: '', value: '' }];\n manualRestartOpen.value = true;\n };\n const closeManualRestart = () => { manualRestartOpen.value = false; };\n const addManualInput = () => { manualInputs.value = [...manualInputs.value, { key: '', value: '' }]; };\n const removeManualInput = (entry) => { manualInputs.value = manualInputs.value.filter((item) => item !== entry); };\n const setManualKey = (entry, event) => { entry.key = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const setManualValue = (entry, event) => { entry.value = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const restartWithInputs = async () => {\n const inputs = Object.fromEntries(manualInputs.value.filter((entry) => entry.key.trim()).map((entry) => [entry.key.trim(), entry.value]));\n manualRestartLoading.value = true;\n try {\n const response = await apiFetch(`/restart/${report.value.jobId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs }) });\n if (!response.ok) throw new Error(`Restart failed: ${response.status}`);\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n manualRestartLoading.value = false;\n }\n };\n const cancelJob = async () => {\n if (cancelling.value || report.value.status !== 'running') return;\n cancelling.value = true;\n try {\n const response = await apiFetch(`/api/jobs/${report.value.jobId}/cancel`, { method: 'POST' });\n if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);\n await refreshRun();\n } catch (error) {\n console.error(error);\n } finally {\n cancelling.value = false;\n }\n };\n const refreshRun = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const response = await apiFetch(`/api/runs/${jobId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Run refresh failed: ${response.status}`);\n const previousStatus = report.value.status;\n const nextReport = await response.json();\n report.value = nextReport;\n document.title = `Run #${nextReport.jobId} - ${nextReport.workflowName}`;\n void loadPreviousRuns();\n if (\n ['pending', 'running'].includes(previousStatus) &&\n ['success', 'failed'].includes(nextReport.status) &&\n 'Notification' in window &&\n 'serviceWorker' in navigator &&\n Notification.permission === 'granted' &&\n localStorage.getItem('runner-notifications') === 'enabled'\n ) {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n nextReport.status === 'success'\n ? `Job #${nextReport.jobId} succeeded`\n : `Job #${nextReport.jobId} failed`,\n {\n body: `${nextReport.workflowName} finished with status ${nextReport.status}.`,\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n tag: `runner-job-${nextReport.jobId}`,\n renotify: true,\n data: { url: `/runs/${nextReport.jobId}` },\n },\n );\n }\n } catch (error) {\n console.error(error);\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshRun();\n void loadTimezone().catch((error) => console.error('Unable to load timezone preference', error));\n }\n }\n };\n const handleJobChange = (event) => {\n const data = JSON.parse(event.data || '{}');\n if (!data.jobId || String(data.jobId) === String(report.value.jobId)) void refreshRun();\n };\n\n onInit(() => {\n void refreshRun();\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/service-worker.js').catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.changed', handleJobChange);\n refreshTimer = setInterval(() => {\n if (active.value) void refreshRun();\n }, 60000);\n clockTimer = setInterval(() => {\n now.value = Date.now();\n }, 1000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n clearInterval(clockTimer);\n });\n\n return { report, previousRuns, manualRestartOpen, manualRestartLoading, manualInputs, aiMessages, aiStepId, aiQuestion, aiError, aiLoading, inputsJson, originalInputsJson, timing, upper, timezone, formatDate, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling,\n step,\n detailTab };\n }\n \n", ug = "import { onInit, ref } from \"@li3/web\";\nimport { apiFetch } from \"@app/api-client.mjs\";\n\nexport default function () {\n const page = \"settings\";\n const keys = ref([]);\n const workers = ref([]);\n const workersForbidden = ref(false);\n const users = ref([]);\n const usersForbidden = ref(false);\n const keyName = ref(\"\");\n const newToken = ref(\"\");\n const error = ref(\"\");\n const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\");\n const preferenceMessage = ref(\"\");\n const timezones = ref(\n typeof Intl.supportedValuesOf === \"function\"\n ? [\"UTC\", ...Intl.supportedValuesOf(\"timeZone\")]\n : [\"UTC\", timezone.value],\n );\n const notificationsSupported = ref(\"Notification\" in window && \"serviceWorker\" in navigator);\n const notificationsEnabled = ref(\n notificationsSupported.value &&\n Notification.permission === \"granted\" &&\n localStorage.getItem(\"runner-notifications\") === \"enabled\",\n );\n const notificationMessage = ref(\"\");\n const scopeOptions = ref([\n { name: \"workflows:read\", label: \"Read workflows\", selected: true },\n { name: \"workflows:write\", label: \"Modify workflows\", selected: false },\n { name: \"logs:read\", label: \"Read logs\", selected: false },\n { name: \"artifacts:read\", label: \"Read artifacts\", selected: false },\n { name: \"runs:control\", label: \"Control runs\", selected: false },\n { name: \"runs:dispatch\", label: \"Dispatch runs\", selected: false },\n { name: \"workers:read\", label: \"Read workers\", selected: false },\n { name: \"secrets:read\", label: \"Read secret names\", selected: false },\n { name: \"secrets:write\", label: \"Manage secrets\", selected: false },\n ]);\n const secrets = ref([]);\n const secretName = ref(\"\");\n const secretValue = ref(\"\");\n const fileMode = ref(false);\n const secretFileName = ref(\"\");\n const secretFileData = ref(\"\");\n const busy = ref(false);\n const api = async (url, options = {}) => {\n const response = await apiFetch(url, {\n headers: { accept: \"application/json\", ...(options.body ? { \"content-type\": \"application/json\" } : {}) },\n ...options,\n });\n const body = await response.json().catch(() => ({}));\n if (!response.ok) {\n const reason = new Error(body.error || `Request failed: ${response.status}`);\n reason.status = response.status;\n throw reason;\n }\n return body;\n };\n const load = async () => {\n keys.value = (await api(\"/api/api-keys\")).keys;\n };\n const loadSecrets = async () => {\n secrets.value = (await api(\"/api/secrets\")).secrets;\n };\n const runSecretAction = async (action) => {\n if (busy.value) return;\n busy.value = true;\n error.value = \"\";\n try {\n await action();\n } catch (reason) {\n error.value = reason.message;\n } finally {\n busy.value = false;\n }\n };\n const saveSecret = () =>\n runSecretAction(async () => {\n const name = secretName.value.trim().toUpperCase();\n if (!/^[A-Z][A-Z0-9_]*$/.test(name))\n throw new Error(\"Secret names must start with a letter and contain only letters, numbers, and underscores.\");\n if (fileMode.value ? !secretFileData.value : !secretValue.value)\n throw new Error(fileMode.value ? \"Choose a file first.\" : \"Set a secret value first.\");\n await api(`/api/secrets/${encodeURIComponent(name)}`, {\n method: \"PUT\",\n body: JSON.stringify(\n fileMode.value\n ? { value: secretFileData.value, encoding: \"base64\", originalName: secretFileName.value }\n : { value: secretValue.value, encoding: \"utf8\" },\n ),\n });\n secretName.value = \"\";\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n await loadSecrets();\n });\n const removeSecret = (name) => {\n if (!confirm(`Delete ${name}?`)) return;\n return runSecretAction(async () => {\n await api(`/api/secrets/${encodeURIComponent(name)}`, { method: \"DELETE\" });\n await loadSecrets();\n });\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = \"\";\n for (let index = 0; index < bytes.length; index += 0x8000)\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n };\n const selectSecret = (name) => {\n secretName.value = name;\n fileMode.value = false;\n secretValue.value = \"\";\n };\n const loadWorkers = async () => {\n try {\n workers.value = (await api(\"/api/workers\")).workers;\n } catch (reason) {\n if (reason.status === 403) {\n workersForbidden.value = true;\n return;\n }\n throw reason;\n }\n };\n const loadUsers = async () => {\n try {\n users.value = (await api(\"/api/users\")).users;\n } catch (reason) {\n if (reason.status === 403) {\n usersForbidden.value = true;\n return;\n }\n throw reason;\n }\n };\n const loadPreferences = async () => {\n const preferences = await api(\"/api/preferences\");\n if (preferences.timezone) {\n timezone.value = preferences.timezone;\n if (!timezones.value.includes(preferences.timezone)) {\n timezones.value = [...timezones.value, preferences.timezone];\n }\n }\n };\n const formatTimestamp = (value) => {\n if (!value) {\n return \"—\";\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n if (Number.isNaN(date.getTime())) {\n return value;\n }\n return new Intl.DateTimeFormat(undefined, {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n timeZone: timezone.value,\n }).format(date);\n };\n const saveTimezone = async (event) => {\n const previousTimezone = timezone.value;\n timezone.value = event.target.value;\n preferenceMessage.value = \"\";\n try {\n await api(\"/api/preferences\", { method: \"PUT\", body: JSON.stringify({ timezone: timezone.value }) });\n preferenceMessage.value = \"Timezone saved.\";\n } catch (reason) {\n timezone.value = previousTimezone;\n error.value = reason.message;\n }\n };\n const issueKey = async () => {\n try {\n error.value = \"\";\n const result = await api(\"/api/api-keys\", {\n method: \"POST\",\n body: JSON.stringify({\n name: keyName.value,\n scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name),\n }),\n });\n newToken.value = result.token;\n keyName.value = \"\";\n await load();\n } catch (reason) {\n error.value = reason.message;\n }\n };\n const revokeKey = async (key) => {\n if (!confirm(`Revoke ${key.name}?`)) {\n return;\n }\n try {\n await api(`/api/api-keys/${key.id}`, { method: \"DELETE\" });\n await load();\n } catch (reason) {\n error.value = reason.message;\n }\n };\n const toggleRole = async (user) => {\n const role = user.role === \"admin\" ? \"user\" : \"admin\";\n if (!confirm(`Change ${user.name || user.email || user.id} to ${role}?`)) {\n return;\n }\n error.value = \"\";\n try {\n await api(`/api/users/${encodeURIComponent(user.id)}/role`, { method: \"PUT\", body: JSON.stringify({ role }) });\n await loadUsers();\n } catch (reason) {\n error.value = reason.status === 403 ? \"Only administrators can change user roles.\" : reason.message;\n }\n };\n const setKeyName = (event) => {\n keyName.value = event.target.value;\n };\n const toggleScope = (scope) => {\n scope.selected = !scope.selected;\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem(\"runner-notifications\");\n notificationMessage.value = \"Notifications disabled on this device.\";\n return;\n }\n if (!notificationsSupported.value) {\n notificationMessage.value = \"Notifications are not supported by this browser.\";\n return;\n }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === \"granted\";\n if (notificationsEnabled.value) {\n localStorage.setItem(\"runner-notifications\", \"enabled\");\n notificationMessage.value = \"Notifications enabled.\";\n } else {\n notificationMessage.value = \"Notification permission was not granted.\";\n }\n };\n onInit(() => {\n loadPreferences().catch((reason) => {\n error.value = reason.message;\n });\n load().catch((reason) => {\n error.value = reason.message;\n });\n loadSecrets().catch((reason) => {\n error.value = reason.message;\n });\n loadWorkers().catch((reason) => {\n error.value = reason.message;\n });\n loadUsers().catch((reason) => {\n error.value = reason.message;\n });\n });\n return {\n page,\n secrets,\n secretName,\n secretValue,\n fileMode,\n busy,\n saveSecret,\n removeSecret,\n setSecretFile,\n setSecretName,\n setSecretValue,\n setFileMode,\n selectSecret,\n keys,\n workers,\n workersForbidden,\n users,\n usersForbidden,\n keyName,\n newToken,\n error,\n timezone,\n timezones,\n preferenceMessage,\n formatTimestamp,\n saveTimezone,\n scopeOptions,\n issueKey,\n revokeKey,\n setKeyName,\n toggleScope,\n toggleRole,\n notificationsEnabled,\n notificationMessage,\n toggleNotifications,\n };\n}\n", dg = "import { onDestroy, onInit, ref, templateRef } from \"@li3/web\";\nimport { diffLines } from \"diff\";\n\nexport default function () {\n const page = document.body.dataset.page;\n const params = new URLSearchParams(window.location.search);\n const workflowPath = window.location.pathname.match(/^\\/(?:settings\\/)?workflows\\/(new|[a-z0-9-]+)$/);\n const pathWorkflowId = workflowPath && workflowPath[1] !== \"new\" ? workflowPath[1] : \"\";\n const initialId = params.get(\"id\") || pathWorkflowId;\n const initialRevision = params.get(\"revision\") ? Number(params.get(\"revision\")) : null;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref(\"\");\n const validation = ref({ valid: true });\n const savedSource = ref(\"\");\n const revision = ref(null);\n const revisionDialogOpen = ref(false);\n const revisionNumber = ref(0);\n const revisionDiffHtml = ref(\"\");\n const revisionError = ref(\"\");\n const revisionLoading = ref(false);\n const aiDialogOpen = ref(false);\n const aiRequest = ref(\"\");\n const aiLoading = ref(false);\n const aiError = ref(\"\");\n const aiSuggestion = ref(\"\");\n const aiDiffHtml = ref(\"\");\n const workflowId = ref(initialId || `wf-${crypto.randomUUID()}`);\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref(\"\");\n const secretName = ref(\"\");\n const secretValue = ref(\"\");\n const fileMode = ref(false);\n const secretFileName = ref(\"\");\n const secretFileData = ref(\"\");\n const notice = ref(\"\");\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef(\"secretValueInput\");\n const secretFileInput = templateRef(\"secretFileInput\");\n const secretForm = templateRef(\"secretForm\");\n const helpContent = templateRef(\"helpContent\");\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const redirectToLogin = () => {\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n };\n\n const api = async (url, options = {}) => {\n const response = await fetch(url, {\n ...options,\n credentials: \"same-origin\",\n headers: {\n accept: \"application/json\",\n ...(options.body ? { \"content-type\": \"application/json\" } : {}),\n ...(options.headers || {}),\n },\n });\n if (response.status === 401) redirectToLogin();\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api(\"/api/workflows\")).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api(\"/api/secrets\")).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision ? `/api/workflows/${id}?revision=${initialRevision}` : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n revision.value = workflow.revision || null;\n };\n\n const validateSource = async () => {\n return api(\"/api/workflows/validate\", {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== \"editor\") return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: \"\" };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = selectedId.value || workflowId.value;\n if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice(\"No changes to save.\");\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n revision.value = workflow.revision;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, \"\", `/workflows/${workflow.id}`);\n });\n const publish = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error(\"Save a workflow before publishing it.\");\n const workflow = await api(`/api/workflows/${id}/publish`, { method: \"POST\" });\n selectedId.value = workflow.id;\n revision.value = workflow.revision;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const runNow = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error(\"Save the workflow before running it.\");\n if (source.value !== savedSource.value) throw new Error(\"Save the latest draft before running it.\");\n const result = await api(`/api/workflows/${id}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showNotice(\n `Queued ${result.jobs.length} job${result.jobs.length === 1 ? \"\" : \"s\"} from revision ${result.revision}.`,\n );\n });\n const remove = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: \"DELETE\" });\n window.location.href = \"/workflows\";\n });\n const saveSecret = () =>\n run(async () => {\n const name = secretName.value.trim().toUpperCase();\n if (!/^[A-Z][A-Z0-9_]*$/.test(name))\n throw new Error(\"Secret names must start with a letter and contain only letters, numbers, and underscores.\");\n secretName.value = name;\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value))\n throw new Error(fileMode.value ? \"Choose a file first.\" : \"Set a secret value first.\");\n await api(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify(\n fileMode.value\n ? { value: secretFileData.value, encoding: \"base64\", originalName: secretFileName.value }\n : { value: secretValue.value, encoding: \"utf8\" },\n ),\n });\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n if (secretForm.value) secretForm.value.open = false;\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = (requestedName = \"\") =>\n run(async () => {\n const name = (requestedName || secretName.value.trim()).toUpperCase();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: \"DELETE\" });\n if (secretName.value.trim().toUpperCase() === name) {\n secretName.value = \"\";\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n }\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n fileMode.value = false;\n if (secretValue.value) {\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n }\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n if (!fileMode.value) {\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n } else {\n secretValue.value = \"\";\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = \"\";\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = \"\";\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = \"Loading workflow syntax help...\";\n try {\n const response = await fetch(\"/help?embed=1\", { headers: { accept: \"text/html\" } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, \"text/html\");\n const sourceNodes = [...Array.from(parsed.head.querySelectorAll(\"style\")), ...Array.from(parsed.body.childNodes)];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = \"Unable to load workflow syntax help.\";\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n const escapeRevisionHtml = (value) =>\n value.replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[character] || \"\",\n );\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null)\n return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource)\n .map((part) => {\n const className = part.added\n ? \"text-emerald-300 bg-emerald-500/10\"\n : part.removed\n ? \"text-rose-300 bg-rose-500/10\"\n : \"text-gray-300\";\n const prefix = part.added ? \"+ \" : part.removed ? \"- \" : \" \";\n return part.value\n .split(\"\\n\")\n .filter((line, index, lines) => index < lines.length - 1 || line)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`)\n .join(\"\");\n })\n .join(\"\");\n };\n const openRevisionDialog = async () => {\n if (!revision.value) return;\n revisionNumber.value = revision.value;\n revisionDiffHtml.value = \"\";\n revisionError.value = \"\";\n revisionDialogOpen.value = true;\n await loadRevisionDiff(revision.value);\n };\n const closeRevisionDialog = () => {\n revisionDialogOpen.value = false;\n };\n const loadRevisionDiff = async (number) => {\n revisionLoading.value = true;\n revisionError.value = \"\";\n try {\n const current = await api(`/api/workflows/${selectedId.value}?revision=${number}`);\n const previous = number > 1 ? await api(`/api/workflows/${selectedId.value}?revision=${number - 1}`) : null;\n revisionDiffHtml.value = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n } catch (error) {\n revisionError.value = error.message;\n } finally {\n revisionLoading.value = false;\n }\n };\n const navigateRevision = async (offset) => {\n if (!selectedId.value || revisionLoading.value) return;\n const target = revisionNumber.value + offset;\n if (target < 1 || target > revision.value) return;\n revisionNumber.value = target;\n await loadRevisionDiff(target);\n };\n const openAiHelp = () => {\n aiRequest.value = \"\";\n aiError.value = \"\";\n aiSuggestion.value = \"\";\n aiDiffHtml.value = \"\";\n aiDialogOpen.value = true;\n };\n const closeAiHelp = () => {\n if (!aiLoading.value) aiDialogOpen.value = false;\n };\n const requestAiHelp = async () => {\n if (!aiRequest.value.trim() || aiLoading.value) return;\n aiLoading.value = true;\n aiError.value = \"\";\n aiDiffHtml.value = \"\";\n try {\n const response = await fetch(\"/api/ai/workflow-help\", {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { accept: \"text/event-stream\", \"content-type\": \"application/json\" },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (response.status === 401) redirectToLogin();\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error || `AI help failed: ${response.status}`);\n }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let suggestion = \"\";\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split(\"\\n\\n\");\n buffer = events.pop() || \"\";\n for (const event of events) {\n const line = event.split(\"\\n\").find((value) => value.startsWith(\"data: \"));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.error) throw new Error(data.error);\n if (data.delta) suggestion += data.delta;\n }\n }\n suggestion = suggestion\n .replace(/^```(?:yaml|yml)?\\s*/i, \"\")\n .replace(/\\s*```$/i, \"\")\n .trim();\n if (!source.value.trim()) {\n source.value = suggestion;\n aiDialogOpen.value = false;\n validate();\n } else {\n aiSuggestion.value = suggestion;\n aiDiffHtml.value = renderRevisionDiff(source.value, suggestion);\n }\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const acceptAiSuggestion = () => {\n source.value = aiSuggestion.value;\n aiDialogOpen.value = false;\n validate();\n };\n const rejectAiSuggestion = () => {\n aiSuggestion.value = \"\";\n aiDiffHtml.value = \"\";\n };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage =\n page === \"editor\"\n ? initialId\n ? openWorkflow(initialId)\n : Promise.resolve()\n : page === \"secrets\"\n ? loadSecrets()\n : loadWorkflows();\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n page,\n workflows,\n secrets,\n source,\n validation,\n revision,\n revisionDialogOpen,\n revisionNumber,\n revisionDiffHtml,\n revisionError,\n revisionLoading,\n aiDialogOpen,\n aiRequest,\n aiLoading,\n aiError,\n aiDiffHtml,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n fileMode,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n runNow,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setEnabled,\n setSecretName,\n setSecretValue,\n setFileMode,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n openRevisionDialog,\n closeRevisionDialog,\n navigateRevision,\n openAiHelp,\n closeAiHelp,\n requestAiHelp,\n acceptAiSuggestion,\n rejectAiSuggestion,\n };\n}\n", fg = "/* global document, window, localStorage, navigator, URL, Blob, setTimeout, clearTimeout */\n\nconst paths = {\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 chevron: '<path d=\"m9 6 6 6-6 6\"/>',\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 edit: '<path d=\"m16 5 3 3M4 20l4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10Z\"/><path d=\"M13.5 7.5 17 11\"/>',\n external: '<path d=\"M14 4h6v6m-11 3L20 4\"/><path d=\"M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5\"/>',\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 keyboard:\n '<rect x=\"2\" y=\"5\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10\"/>',\n close: '<path d=\"m6 6 12 12M6 18 18 6\"/>',\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 info: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 11v6m0-10h.01\"/>',\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 paths[name] || paths.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 ${status}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst badge = (status) => `<span class=\"status ${status}\">${statusIcon(status)}${labels[status]}</span>`;\nconst workflows = [\n {\n id: \"build-docker-image\",\n name: \"Build Docker image\",\n description: \"Build, test, and publish a container image to the registry.\",\n labels: [\"docker\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build image\", \"Push to registry\"],\n },\n {\n id: \"build-publish-npm\",\n name: \"Build & publish npm\",\n description: \"Validate packages and publish a new release to npm.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build package\", \"Publish to npm\"],\n },\n {\n id: \"deploy-production\",\n name: \"Deploy production\",\n description: \"Release the latest application and verify service health.\",\n labels: [\"main-server\", \"linux\"],\n steps: [\"Checkout repository\", \"Pull image\", \"Verify configuration\", \"Deploy service\", \"Health check\"],\n },\n {\n id: \"quality-checks\",\n name: \"Quality checks\",\n description: \"Type checking, linting, and tests for every pull request.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Type check\", \"Lint\"],\n },\n];\nconst seed = [\n [\"running\", 0, \"feat: add multi-platform builds\", \"feat/multi-platform\", \"a8f3c12\", 93, \"Just now\", \"on\"],\n [\"pending\", 1, \"chore: prepare September release\", \"main\", \"d72e8b1\", 0, \"1 min ago\", \"lithium\"],\n [\"failed\", 0, \"fix: resolve image build context\", \"main\", \"7bc91e4\", 142, \"4 min ago\", \"on\"],\n [\"success\", 2, \"feat: improve workflow navigation\", \"main\", \"f38ac02\", 68, \"12 min ago\", \"on\"],\n [\"success\", 1, \"fix: handle reconnect gracefully\", \"main\", \"c52d190\", 104, \"18 min ago\", \"sodium\"],\n [\"success\", 3, \"feat: add keyboard navigation\", \"feat/keyboard-nav\", \"8a12e6f\", 46, \"26 min ago\", \"lithium\"],\n [\"failed\", 1, \"chore: update dependency versions\", \"main\", \"e90b721\", 38, \"34 min ago\", \"lithium\"],\n [\"success\", 0, \"fix: restore health check endpoint\", \"main\", \"2a19d63\", 127, \"42 min ago\", \"auth\"],\n [\"success\", 1, \"feat: expose workflow metadata\", \"main\", \"b94d512\", 88, \"1 hr ago\", \"on\"],\n [\"success\", 3, \"test: cover retry boundaries\", \"fix/retries\", \"6fb841a\", 59, \"1 hr ago\", \"on\"],\n [\"cancelled\", 0, \"chore: rebuild base image\", \"main\", \"51ea273\", 17, \"2 hrs ago\", \"auth\"],\n [\"success\", 2, \"fix: improve session recovery\", \"main\", \"832df1a\", 62, \"2 hrs ago\", \"auth\"],\n [\"success\", 3, \"refactor: simplify component lifecycle\", \"main\", \"27d1c98\", 71, \"3 hrs ago\", \"lithium\"],\n [\"success\", 1, \"chore: release patch version\", \"main\", \"92dc128\", 96, \"3 hrs ago\", \"sodium\"],\n [\"success\", 0, \"feat: cache dependency layers\", \"main\", \"bd5201c\", 112, \"4 hrs ago\", \"on\"],\n [\"success\", 2, \"fix: persist worker labels\", \"main\", \"876da42\", 65, \"5 hrs ago\", \"on\"],\n];\nlet jobs = seed.map(([status, workflow, message, branch, commit, seconds, time, repo], index) => ({\n id: 2856 - index,\n status,\n workflow: workflows[workflow],\n message,\n branch,\n commit,\n seconds,\n time,\n repo,\n worker: status === \"pending\" ? null : \"alpha\",\n attempt: 1,\n parent: null,\n}));\nconst state = {\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n branch: \"all\",\n page: 1,\n step: null,\n detailTab: \"logs\",\n logQuery: \"\",\n wrap: false,\n};\nconst main = document.querySelector(\"#main\");\nconst dialog = document.querySelector(\"#dialog\");\nlet toastTimer;\nlet previousRoute = \"\";\n\nfunction duration(seconds) {\n if (!seconds) {\n return \"—\";\n }\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n}\nfunction toast(message) {\n const element = document.querySelector(\"#toast\");\n clearTimeout(toastTimer);\n element.textContent = message;\n element.hidden = false;\n toastTimer = setTimeout(() => {\n element.hidden = true;\n }, 4500);\n}\nfunction route() {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URL(`https://example.test/?${query}`).searchParams };\n}\nfunction currentJob() {\n return jobs.find((job) => job.id === Number(route().path.split(\"/\")[2]));\n}\nfunction updateFilterUrl() {\n const params = new URL(\"https://example.test\").searchParams;\n for (const key of [\"search\", \"status\", \"workflow\", \"branch\", \"page\"]) {\n if (state[key] !== \"\" && state[key] !== \"all\" && state[key] !== 1) {\n params.set(key, state[key]);\n }\n }\n window.history.replaceState(\n null,\n \"\",\n `${window.location.pathname}${window.location.search}#/runs${params.size ? `?${params}` : \"\"}`,\n );\n}\nfunction readFilters() {\n const { query } = route();\n state.search = query.get(\"search\") || \"\";\n state.status = [\"all\", \"running\", \"failed\", \"success\"].includes(query.get(\"status\")) ? query.get(\"status\") : \"all\";\n state.workflow = workflows.some((workflow) => workflow.id === query.get(\"workflow\")) ? query.get(\"workflow\") : \"all\";\n state.branch = [\"main\", \"feature\"].includes(query.get(\"branch\")) ? query.get(\"branch\") : \"all\";\n const page = Number(query.get(\"page\"));\n state.page = Number.isSafeInteger(page) && page > 0 ? page : 1;\n}\nfunction navigation(page) {\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, symbol]) =>\n `<a href=\"#/${id}\" class=\"nav-item flex items-center gap-[10px] rounded-md px-[14px] py-3 text-xs font-medium text-flow-muted hover:bg-flow-subtle max-[900px]:justify-center max-[900px]:px-[10px] max-[900px]:py-[13px] max-[600px]:flex-1 max-[600px]:flex-col max-[600px]:gap-1 max-[600px]:rounded-none max-[600px]:px-[6px] max-[600px]:pt-[9px] max-[600px]:pb-[6px] max-[600px]:text-[10px] ${id === page ? \"active bg-[#e8efdf] text-[#344d29] max-[600px]:bg-transparent max-[600px]:text-[#41602f]\" : \"\"}\" ${\n id === page ? 'aria-current=\"page\"' : \"\"\n } aria-label=\"${title}\">${icon(symbol).replace(\"<svg \", '<svg class=\"h-[17px] w-[17px] shrink-0 max-[600px]:h-[18px] max-[600px]:w-[18px]\" ')}<span class=\"nav-label max-[900px]:hidden max-[600px]:block\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count ml-auto rounded bg-[#e2e8dc] px-1.5 py-0.5 text-[13px] max-[900px]:hidden\">${\n jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`,\n )\n .join(\"\");\n document.title = `${page[0].toUpperCase() + page.slice(1)} · Flow`;\n}\nfunction runsPage() {\n main.innerHTML = `<div class=\"mb-7 flex justify-end\"><button class=\"button primary\" data-action=\"new-run\">${icon(\n \"plus\",\n )}Run workflow</button></div>\n <section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Runs <span class=\"count\">${\n jobs.length\n }</span></h2></div>\n <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, commits, or repositories…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label>\n <select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${workflows\n .map(\n (workflow) =>\n `<option value=\"${workflow.id}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${workflow.name}</option>`,\n )\n .join(\"\")}</select>\n <select id=\"branch-filter\" class=\"select-filter\" aria-label=\"Filter by branch\"><option value=\"all\">All branches</option><option value=\"main\" ${\n state.branch === \"main\" ? \"selected\" : \"\"\n }>main</option><option value=\"feature\" ${\n state.branch === \"feature\" ? \"selected\" : \"\"\n }>Feature branches</option></select></div>\n <div id=\"run-results\"></div></section>\n <div class=\"attention-strip\">${icon(\"alert\")}<p><strong>${\n jobs.filter((job) => job.status === \"failed\").length\n } failed runs</strong><span>A little attention goes a long way.</span></p><button class=\"text-button\" data-action=\"filter-failed\">Review failures ${icon(\n \"arrow\",\n )}</button></div>`;\n renderResults();\n}\nfunction filteredJobs(includeStatus = true) {\n return jobs.filter((job) => {\n const search = state.search.trim().toLowerCase();\n const qualifier = search.match(/^([a-z0-9_-]+):(.+)$/);\n const text = [job.id, job.message, job.workflow.id, job.workflow.name, job.repo, job.commit, job.branch]\n .join(\" \")\n .toLowerCase();\n const repository = String(job.repo || \"\").toLowerCase();\n const fullRepository = repository.includes(\"/\") ? repository : `cloud-cli/${repository}`;\n const owner = fullRepository.split(\"/\")[0];\n const qualifiedValue = qualifier\n ? qualifier[1] === \"owner\"\n ? owner\n : qualifier[1] === \"repo\"\n ? fullRepository\n : String(job.inputs?.[qualifier[1]] ?? job[qualifier[1]] ?? \"\").toLowerCase()\n : \"\";\n return (\n (qualifier ? qualifiedValue.includes(qualifier[2]) : text.includes(search)) &&\n (state.workflow === \"all\" || job.workflow.id === state.workflow) &&\n (state.branch === \"all\" || (state.branch === \"main\" ? job.branch === \"main\" : job.branch !== \"main\")) &&\n (!includeStatus ||\n state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status))\n );\n });\n}\nfunction renderResults() {\n const filtered = filteredJobs();\n const all = filteredJobs(false);\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(pages, state.page);\n const visible = filtered.slice((state.page - 1) * 8, state.page * 8);\n document.querySelector(\"#run-results\").innerHTML =\n `<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 all.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 visible.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Workflow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / commit</th><th scope=\"col\">Status</th><th scope=\"col\">Branch</th><th scope=\"col\">Duration</th><th scope=\"col\">Started</th></tr></thead><tbody>${visible\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${job.id}\" aria-label=\"Run ${job.id}: ${escape(\n job.workflow.name,\n )}, ${labels[job.status]}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${\n job.workflow.name\n }</span><span class=\"run-subtitle\"><span class=\"mono\">#${\n job.id\n }</span><span class=\"separator\">·</span><span>cloud-cli/${\n job.repo\n }</span><span class=\"separator\">·</span><span class=\"mono\">${\n job.commit\n }</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"branch\",\n )}${escape(job.branch)}</span></td><td><span class=\"mono duration\">${duration(\n job.seconds,\n )}</span></td><td>${job.time}</td></tr>`,\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\n \"search\",\n )}<h3>No runs found</h3><p>Try a different search or clear your filters.</p><button class=\"button\" data-action=\"clear-filters\">Clear filters</button></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 ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div><div class=\"table-hint\">${icon(\n \"info\",\n )}Open a run to explore its steps, logs, and execution history.</div>`;\n}\nfunction stepsFor(job) {\n return job.workflow.steps.map((name, index) => {\n let status = \"success\";\n if (job.status === \"failed\") {\n status = index < 2 ? \"success\" : index === 2 ? \"failed\" : \"skipped\";\n }\n if (job.status === \"running\") {\n status = index < 3 ? \"success\" : index === 3 ? \"running\" : \"pending\";\n }\n if (job.status === \"pending\") {\n status = \"pending\";\n }\n if (job.status === \"cancelled\") {\n status = index === 0 ? \"success\" : index === 1 ? \"cancelled\" : \"skipped\";\n }\n const seconds = [\"skipped\", \"pending\"].includes(status)\n ? 0\n : Math.round(job.seconds * [0.03, 0.24, 0.45, 0.2, 0.08][index]);\n return { name, status, seconds, index };\n });\n}\nfunction detailPage(job) {\n const steps = stepsFor(job);\n if (state.step === null) {\n state.step = Math.max(\n 0,\n steps.findIndex((step) => [\"failed\", \"running\"].includes(step.status)),\n );\n }\n const active = [\"running\", \"pending\"].includes(job.status);\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\n \"back\",\n )}All runs</a><div class=\"page-heading run-heading\"><div><h1>${statusIcon(job.status)}${\n job.workflow.name\n }</h1><p class=\"subtitle\"><span class=\"mono\">#${job.id}</span><span>·</span><span>${escape(\n job.message,\n )}</span></p></div><div class=\"heading-actions\"><button class=\"button\" data-action=\"download-logs\">${icon(\n \"download\",\n )}Download logs</button><button class=\"button ${active ? \"danger\" : \"primary\"}\" data-action=\"${\n active ? \"cancel-run\" : \"rerun\"\n }\">${icon(active ? \"stop\" : \"refresh\")}${active ? \"Cancel run\" : \"Re-run workflow\"}</button></div></div>\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n job.status,\n )}</span></div><div><span class=\"meta-label\">REPOSITORY / BRANCH</span><span class=\"meta-value\">${icon(\n \"branch\",\n )}${escape(job.branch)}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\n \"commit\",\n )}${job.commit}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\",\n )}${duration(job.seconds)}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\n \"server\",\n )}${job.worker || \"Awaiting worker\"}${job.worker ? '<span class=\"tag\">linux · x64</span>' : \"\"}</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}${\n value === \"history\" ? `<span class=\"count\">${job.attempt}</span>` : \"\"\n }</button>`,\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n renderDetailContent(job);\n}\nfunction renderDetailContent(job) {\n const content = document.querySelector(\"#detail-content\");\n if (state.detailTab === \"inputs\") {\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs <span class=\"tag\">${\n job.parent ? \"manual restart\" : \"push event\"\n }</span></h3><pre>${escape(\n JSON.stringify(\n {\n repository: `cloud-cli/${job.repo}`,\n ref: `refs/heads/${job.branch}`,\n commit: job.commit,\n workflow: job.workflow.id,\n triggered_by: job.parent ? \"user:jamie\" : \"webhook:github\",\n },\n null,\n 2,\n ),\n )}</pre></section><section class=\"source-view\" style=\"margin-top:20px\"><h3>Workflow source · revision 12</h3><pre>${escape(\n workflowSource(job.workflow),\n )}</pre></section>`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n content.innerHTML =\n job.status === \"success\"\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">1</span></h2><div class=\"setting-row\"><div><strong>${icon(\n \"box\",\n )} build-manifest.json</strong><p>Sample build metadata · JSON</p></div><button class=\"button\" data-action=\"artifact\">${icon(\n \"download\",\n )}Download</button></div></section>`\n : `<div class=\"empty-state\">${icon(\"box\")}<h3>No artifacts yet</h3><p>${\n activeStatus(job)\n ? \"Artifacts appear here when the run completes.\"\n : \"This run did not produce any artifacts.\"\n }</p></div>`;\n return;\n }\n if (state.detailTab === \"history\") {\n const history = [];\n let current = job;\n while (current) {\n history.push(current);\n current = jobs.find((candidate) => candidate.id === current.parent);\n }\n content.innerHTML = `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${entry.id}\">Run #${entry.id} · Attempt ${\n entry.attempt\n }</a></strong><p>${\n entry.parent ? `Re-run of #${entry.parent} · Triggered by Jamie` : \"Original run · Triggered by GitHub\"\n } · ${entry.time}</p></div>${badge(entry.status)}</div>`,\n )\n .join(\"\")}</section>`;\n return;\n }\n const steps = stepsFor(job);\n const selected = steps[state.step];\n content.innerHTML = `${\n job.status === \"failed\"\n ? `<div class=\"failure-banner\">${icon(\n \"alert\",\n )}<div><strong>Run tests failed with exit code 1</strong><p>One test failed. The remaining steps were skipped.</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\",\n )}</button></div>`\n : \"\"\n }\n <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) =>\n `<button class=\"step-button ${state.step === step.index ? \"active\" : \"\"}\" data-action=\"step\" data-value=\"${\n step.index\n }\" aria-pressed=\"${state.step === step.index}\" aria-label=\"${step.name}, ${labels[step.status]}\">${statusIcon(\n step.status,\n )}<span>${step.name}</span><span class=\"mono\">${duration(step.seconds)}</span></button>`,\n )\n .join(\"\")}<div class=\"step-count\">${icon(\"clock\")}Total duration ${duration(job.seconds)}</div></aside>\n <div class=\"log-panel\"><div class=\"log-heading\"><h3>${selected.name}<small>${\n selected.status === \"failed\" ? \"exit code 1\" : labels[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 }\" title=\"Wrap log lines\">${icon(\n \"wrap\",\n )}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\" title=\"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.logQuery,\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 ? \"Process exited with code 1\"\n : activeStatus(job)\n ? \"Sample execution output\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>\n <div class=\"detail-bottom\"><span>${icon(\"lock\")}Logs are visible to workspace members.</span><span>cloud-cli/${\n job.repo\n }<span>·</span>Workflow revision 12<span>·</span>Attempt ${job.attempt}</span></div>`;\n renderLogs(job);\n}\nfunction activeStatus(job) {\n return [\"pending\", \"running\"].includes(job.status);\n}\nfunction logsFor(job, index) {\n const step = stepsFor(job)[index];\n if (step.status === \"skipped\") {\n return [{ text: \"Step skipped because an earlier step did not complete.\", type: \"dim\" }];\n }\n if (step.status === \"pending\") {\n return [{ text: \"Waiting for a worker…\", type: \"dim\" }];\n }\n if (step.status === \"failed\") {\n return [\n [\"$ pnpm test\", \"\"],\n [\"> @cloud-cli/on test\", \"dim\"],\n [\"> vitest run\", \"dim\"],\n [\"\", \"\"],\n [\" RUN v4.1.11 /workspace/cloud-cli/on\", \"\"],\n [\"\", \"\"],\n [\" ✓ src/config.spec.ts (8 tests) 14ms\", \"pass\"],\n [\" ✓ src/queue.spec.ts (12 tests) 31ms\", \"pass\"],\n [\" ❯ src/worker.spec.ts (6 tests | 1 failed) 89ms\", \"error\"],\n [\" × worker > retries a failed job\", \"error\"],\n [\"\", \"\"],\n [\" FAIL src/worker.spec.ts > worker > retries a failed job\", \"error\"],\n [\" AssertionError: expected 2 to equal 3\", \"error\"],\n [\"\", \"\"],\n [\" Expected: 3\", \"pass\"],\n [\" Received: 2\", \"error\"],\n [\"\", \"\"],\n [\" ❯ src/worker.spec.ts:142:28\", \"\"],\n [\" 140| await worker.retry(job);\", \"dim\"],\n [\" 141|\", \"dim\"],\n [\" 142| expect(job.attempts).toBe(3);\", \"error\"],\n [\" | ^\", \"error\"],\n [\"\", \"\"],\n [\" Test Files 1 failed | 2 passed (3)\", \"\"],\n [\" Tests 1 failed | 25 passed (26)\", \"\"],\n [\" Duration 1.42s\", \"dim\"],\n [\"\", \"\"],\n [\" ELIFECYCLE Test failed. See above for more details.\", \"error\"],\n ].map(([text, type]) => ({ text, type }));\n }\n const logs =\n index === 0\n ? [\n `$ git clone https://github.com/cloud-cli/${job.repo}.git .`,\n \"Cloning into '.'...\",\n \"remote: Enumerating objects: 184, done.\",\n \"remote: Compressing objects: 100% (92/92), done.\",\n \"Receiving objects: 100% (184/184), 148.2 KiB | 3.1 MiB/s, done.\",\n `$ git checkout ${job.commit}`,\n `HEAD is now at ${job.commit} ${job.message}`,\n \"\",\n \"✓ Repository ready.\",\n ]\n : index === 1\n ? [\n \"$ pnpm install --frozen-lockfile\",\n \"Lockfile is up to date, resolution step is skipped\",\n \"Progress: resolved 248, reused 248, downloaded 0, added 248\",\n \"\",\n \"✓ Dependencies installed from cache.\",\n \"Done in 2.4s.\",\n ]\n : index === 2\n ? [\n \"$ pnpm test\",\n \" RUN v4.1.11 /workspace\",\n \"\",\n \" ✓ src/config.spec.ts (8 tests)\",\n \" ✓ src/queue.spec.ts (12 tests)\",\n \" ✓ src/worker.spec.ts (6 tests)\",\n \"\",\n \" Test Files 3 passed (3)\",\n \" Tests 26 passed (26)\",\n \"\",\n \"✓ All tests passed.\",\n ]\n : [\n `$ ${job.workflow.id === \"build-docker-image\" ? \"docker build --tag app:latest .\" : \"pnpm build\"}`,\n \"#1 Loading build configuration\",\n \"#1 DONE 0.1s\",\n \"#2 Resolving dependencies\",\n \"#2 CACHED\",\n \"#3 Compiling source files\",\n \"#3 DONE 1.3s\",\n \"#4 Creating production bundle\",\n ...(step.status === \"running\"\n ? [\"#4 Building…\"]\n : [\"#4 DONE 0.8s\", \"\", \"✓ Step completed successfully.\"]),\n ];\n if (step.status === \"cancelled\") {\n logs.push(\"Run cancelled by Jamie.\");\n }\n return logs.map((text) => ({ text, type: text.startsWith(\"✓\") || text.includes(\" passed\") ? \"pass\" : \"\" }));\n}\nfunction renderLogs(job) {\n const logs = logsFor(job, state.step);\n const query = state.logQuery.toLowerCase();\n let matches = 0;\n document.querySelector(\"#log-lines\").innerHTML = logs\n .map((line, index) => {\n let text = escape(line.text);\n const position = query ? line.text.toLowerCase().indexOf(query) : -1;\n if (position >= 0) {\n matches++;\n text = `${escape(line.text.slice(0, position))}<mark>${escape(\n line.text.slice(position, position + query.length),\n )}</mark>${escape(line.text.slice(position + query.length))}`;\n }\n return `<div class=\"log-line ${line.type}\" ${\n line.type === \"error\" ? 'data-error=\"true\"' : \"\"\n }><span class=\"line-number\">${index + 1}</span><span class=\"log-time\">12:04:${String(\n 16 + Math.floor(index / 3),\n ).padStart(2, \"0\")}</span><span class=\"line-text\">${text || \" \"}</span></div>`;\n })\n .join(\"\");\n document.querySelector(\"#log-matches\").textContent = query ? `${matches} matching lines` : \"\";\n}\nfunction workflowSource(workflow) {\n return `name: ${workflow.name}\\nruns-on: [${workflow.labels.join(\n \", \",\n )}]\\n\\n# Illustrative workflow definition\\nsteps:\\n${workflow.steps\n .map(\n (name, index) =>\n ` - name: ${name}\\n run: ${\n [\n 'git checkout \"$COMMIT\"',\n \"pnpm install --frozen-lockfile\",\n \"pnpm test\",\n \"pnpm build\",\n 'echo \"Release complete\"',\n ][index]\n }`,\n )\n .join(\"\\n\")}`;\n}\nfunction workflowsPage() {\n main.innerHTML = `<div class=\"workflow-list overflow-hidden rounded-[7px] border border-[#cbd3c8] bg-white\">${workflows\n .map(\n (workflow, index) =>\n `<article class=\"workflow-item grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-[5px] border-b border-flow-border px-4 py-[13px] last:border-b-0 max-[900px]:grid-cols-1 max-[900px]:gap-2\"><div class=\"workflow-header col-start-1 flex min-w-0 items-center gap-[10px]\"><span class=\"workflow-name overflow-hidden text-ellipsis whitespace-nowrap font-semibold\">${workflow.name}</span><span class=\"workflow-status success rounded bg-[#edf5e8] px-[7px] py-[3px] text-[11px] capitalize text-[#4d7040]\">Enabled</span></div><div class=\"workflow-meta col-start-1 flex min-w-0 items-center gap-[10px] pl-[27px] text-[11px] text-[#7e8874]\"><span>v${workflow.revision || 1}</span><span>${workflow.steps.length} steps</span></div><div class=\"workflow-actions col-start-2 row-span-2 row-start-1 flex min-w-0 flex-wrap items-center justify-end gap-[10px] max-[900px]:col-start-1 max-[900px]:row-start-auto max-[900px]:justify-start max-[900px]:pl-[27px]\"><a class=\"button !px-[9px] !py-[6px] !text-[11px]\" href=\"#/runs?workflow=${workflow.id}\" aria-label=\"View runs for ${workflow.name}\" title=\"View runs\">${icon(\n \"runs\",\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\" aria-label=\"View source for ${workflow.name}\" title=\"View source\">${icon(\n \"code\",\n )}</button></div></article>`,\n )\n .join(\"\")}</div>`;\n}\nfunction workerCards() {\n return `<div class=\"cards grid grid-cols-2 gap-4 max-[600px]:grid-cols-1\">${[\n [\"alpha\", \"main-server, docker\", 36, 42, \"1 / 4\"],\n [\"bravo\", \"node, linux\", 12, 28, \"0 / 4\"],\n [\"charlie\", \"docker, linux\", 8, 21, \"0 / 2\"],\n ]\n .map(\n ([name, tags, cpu, memory, slots]) =>\n `<article class=\"worker-card rounded-lg border border-flow-border bg-white p-5\"><div class=\"card-heading mb-[19px] flex items-center gap-[10px]\"><span class=\"text-[19px] text-[#7e9468]\">${icon(\n \"server\",\n )}</span><h2 class=\"text-[13px]\">${name}</h2><span class=\"status success ml-auto\">Online</span></div><p class=\"card-description text-[13px] leading-[1.8] text-[#7e8874]\">Linux x64 · systemd runner</p><div class=\"card-details mt-[14px] flex flex-wrap gap-[7px]\">${tags\n .split(\", \")\n .map(\n (tag) =>\n `<span class=\"tag mono rounded bg-[#f2f4ee] px-[7px] py-1 text-[10px] text-[#6b795e]\">${tag}</span>`,\n )\n .join(\n \"\",\n )}</div><div class=\"worker-stats mt-[23px] grid grid-cols-2 gap-5\"><div><span class=\"meta-label flex justify-between\">CPU <span>${cpu}%</span></span><div class=\"progress-track mt-[10px] h-1 overflow-hidden rounded-[3px] bg-[#edf0e7]\"><div class=\"progress-fill h-full rounded-[3px] bg-[#a1bf85]\" style=\"width:${cpu}%\"></div></div></div><div><span class=\"meta-label flex justify-between\">Memory <span>${memory}%</span></span><div class=\"progress-track mt-[10px] h-1 overflow-hidden rounded-[3px] bg-[#edf0e7]\"><div class=\"progress-fill h-full rounded-[3px] bg-[#a1bf85]\" style=\"width:${memory}%\"></div></div></div></div><div class=\"card-footer mt-[23px] flex items-center justify-between border-t border-flow-border pt-4\"><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">${slots} slots in use</span><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Sample snapshot</span></div></article>`,\n )\n .join(\"\")}</div>`;\n}\nfunction getPreference(key) {\n try {\n return localStorage.getItem(`flow-concept-${key}`) === \"true\";\n } catch {\n return false;\n }\n}\nfunction settingsPage() {\n const timezone = localStorage.getItem(\"flow-concept-timezone\") || \"UTC\";\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Settings</h1><p class=\"subtitle\">The essentials for your workspace.</p></div></div><section class=\"settings-section mb-4 rounded-[7px] border border-flow-border bg-white px-5 py-[18px]\"><h2 class=\"mb-[15px]\">Preferences</h2><label class=\"setting-row flex items-center justify-between gap-4 py-3\"><span><strong class=\"block text-xs font-medium\">Compact run list</strong><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n getPreference(\"compact\") ? \"checked\" : \"\"\n }/></label><label class=\"setting-row flex items-center justify-between gap-4 border-t border-flow-border py-3\"><span><strong class=\"block text-xs font-medium\">Wrap log lines</strong><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label><label class=\"setting-row flex items-center justify-between gap-4 border-t border-flow-border py-3 max-[600px]:gap-[14px]\"><span><strong class=\"block text-xs font-medium\">Timezone</strong><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Use this timezone for displayed dates.</span></span><select id=\"timezone-setting\"><option ${timezone === \"UTC\" ? \"selected\" : \"\"}>UTC</option><option ${timezone === \"America/Los_Angeles\" ? \"selected\" : \"\"}>America/Los_Angeles</option><option ${timezone === \"Europe/London\" ? \"selected\" : \"\"}>Europe/London</option></select></label></section><section class=\"settings-section mb-4 rounded-[7px] border border-flow-border bg-white px-5 py-[18px] max-[600px]:px-[18px]\"><h2 class=\"mb-[15px]\">Workers</h2><p class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Execution capacity available to workflows.</p>${workerCards()}</section><section class=\"settings-section mb-4 rounded-[7px] border border-flow-border bg-white px-5 py-[18px] max-[600px]:px-[18px]\"><h2 class=\"mb-[15px]\">${icon(\n \"lock\",\n )} Secrets</h2><p class=\"settings-note\">Available to workflows. Values are never displayed in the dashboard.</p>${[\n \"NPM_TOKEN\",\n \"REGISTRY_PASSWORD\",\n \"DEPLOY_KEY\",\n ]\n .map(\n (name) =>\n `<div class=\"setting-row flex items-center justify-between gap-4 border-t border-flow-border py-3 max-[600px]:gap-[14px]\"><div class=\"min-w-0\"><strong class=\"mono block max-w-[min(58vw,480px)] truncate text-xs font-medium\" title=\"${name}\">${name}</strong><p class=\"mt-1 text-[11px] text-flow-muted max-[600px]:text-[13px]\">Workspace secret · Example</p></div><span class=\"secret-value shrink-0 text-xs text-flow-muted\" aria-label=\"Value hidden\">••••••••••••</span></div>`,\n )\n .join(\n \"\",\n )}</section><section class=\"settings-section\"><h2>Tokens</h2><p class=\"settings-note\">Access tokens are hidden after creation.</p><div class=\"setting-row\"><span>CI deploy token</span><span class=\"secret-value\" aria-label=\"Token value hidden\">••••••••••••</span></div></section><section class=\"settings-section\"><h2>Admin roster</h2><div class=\"setting-row\"><div><strong>Jamie Davis</strong><p>jamie@example.com</p></div><span class=\"workflow-status success\">Admin</span></div></section><section class=\"settings-section\"><h2>About</h2><p class=\"settings-note\">An interactive design prototype based on Flow’s job list and run details. Run controls update this browser session. Display preferences are saved on this device.</p><div class=\"setting-row\"><span class=\"settings-note\">Explore without setup.</span><button class=\"button\" data-action=\"shortcuts\">${icon(\n \"keyboard\",\n )}Keyboard shortcuts</button></div></section>`;\n}\nfunction openDialog(title, body) {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${title}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\",\n )}</button></div>${body}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n}\nfunction newRunDialog() {\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">Choose what to run. We’ll take it from here.</p><form id=\"new-run-form\"><label class=\"form-field\"><span>Workflow</span><select name=\"workflow\">${workflows\n .map((workflow) => `<option value=\"${workflow.id}\">${workflow.name}</option>`)\n .join(\n \"\",\n )}</select></label><label class=\"form-field\"><span>Branch</span><input name=\"branch\" value=\"main\" required maxlength=\"100\" pattern=\"[a-zA-Z0-9._\\\\x2f\\\\x2d]+\"/></label><p class=\"dialog-note\">Prototype mode: creates a sample queued run in this session.</p><div class=\"dialog-footer\"><button class=\"button\" type=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" type=\"submit\">${icon(\n \"runs\",\n )}Run workflow</button></div></form>`,\n );\n}\nfunction createRun(original, workflow, branch) {\n const job = {\n id: Math.max(...jobs.map((entry) => entry.id)) + 1,\n workflow,\n branch,\n status: \"pending\",\n message: original?.message || \"Manually triggered workflow\",\n commit: original?.commit || \"a8f3c12\",\n seconds: 0,\n time: \"Just now\",\n repo: original?.repo || \"on\",\n worker: null,\n parent: original?.id || null,\n attempt: original ? original.attempt + 1 : 1,\n };\n jobs.unshift(job);\n dialog.close();\n window.location.hash = `/runs/${job.id}`;\n toast(`Sample run #${job.id} queued.`);\n}\nfunction download(name, text) {\n const url = URL.createObjectURL(new Blob([text], { type: \"text/plain;charset=utf-8\" }));\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = name;\n anchor.click();\n setTimeout(() => {\n URL.revokeObjectURL(url);\n }, 1000);\n}\nfunction render() {\n const { path } = route();\n const page = path.split(\"/\")[1] || \"runs\";\n const changed = previousRoute !== path;\n if (changed) {\n state.step = null;\n state.detailTab = \"logs\";\n state.logQuery = \"\";\n }\n previousRoute = path;\n navigation([\"runs\", \"workflows\", \"settings\"].includes(page) ? page : page === \"workers\" ? \"settings\" : \"runs\");\n if (path === \"/runs\") {\n readFilters();\n runsPage();\n } else if (page === \"runs\" && currentJob()) {\n detailPage(currentJob());\n } else if (page === \"workflows\") {\n workflowsPage();\n } else if (page === \"workers\") {\n settingsPage();\n } else if (page === \"settings\") {\n settingsPage();\n } else {\n main.innerHTML = `<div class=\"empty-state\">${icon(\n \"search\",\n )}<h1>Run not found</h1><p>This run is not part of the sample workspace.</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n if (changed) {\n window.scrollTo(0, 0);\n main.focus({ preventScroll: true });\n }\n}\ndocument.addEventListener(\"click\", (event) => {\n if (event.target.closest('#profile-link[aria-disabled=\"true\"]')) {\n event.preventDefault();\n }\n});\ndocument.addEventListener(\"click\", async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return;\n }\n const { action, value } = button.dataset;\n const job = currentJob();\n if (action === \"new-run\") {\n newRunDialog();\n }\n if (action === \"close-dialog\") {\n dialog.close();\n }\n if (action === \"filter-failed\" || action === \"status-filter\") {\n state.status = action === \"filter-failed\" ? \"failed\" : value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`.filter-tab[data-value=\"${state.status}\"]`).focus({ preventScroll: true });\n }\n if (action === \"clear-filters\") {\n Object.assign(state, { search: \"\", status: \"all\", workflow: \"all\", branch: \"all\", page: 1 });\n updateFilterUrl();\n runsPage();\n document.querySelector(\"#run-search\").focus();\n }\n if (action === \"prev-page\" || action === \"next-page\") {\n state.page += action === \"next-page\" ? 1 : -1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`button[data-action=\"${action}\"]:not(:disabled)`)?.focus({ preventScroll: true });\n }\n if (action === \"detail-tab\") {\n state.detailTab = value;\n detailPage(job);\n document.querySelector(`[data-action=\"detail-tab\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"step\") {\n state.step = Number(value);\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(`[data-action=\"step\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"jump-error\") {\n state.step = 2;\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(\"[data-error]\")?.scrollIntoView({ block: \"nearest\" });\n document.querySelector(\"#log-lines\").focus({ preventScroll: true });\n }\n if (action === \"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 }\n if (action === \"copy-log\") {\n try {\n await navigator.clipboard.writeText(\n logsFor(job, state.step)\n .map((line) => line.text)\n .join(\"\\n\"),\n );\n toast(\"Step log copied.\");\n } catch {\n toast(\"Clipboard unavailable. Use Download logs to save the output.\");\n }\n }\n if (action === \"download-logs\") {\n download(\n `flow-run-${job.id}.log`,\n job.workflow.steps\n .map(\n (name, index) =>\n `=== ${name} ===\\n${logsFor(job, index)\n .map((line) => line.text)\n .join(\"\\n\")}`,\n )\n .join(\"\\n\\n\"),\n );\n toast(\"Run log downloaded.\");\n }\n if (action === \"artifact\") {\n download(\n `run-${job.id}-build-manifest.json`,\n JSON.stringify({ sample: true, run: job.id, commit: job.commit, workflow: job.workflow.id }, null, 2),\n );\n }\n if (action === \"rerun\") {\n createRun(job, job.workflow, job.branch);\n }\n if (action === \"cancel-run\") {\n job.status = \"cancelled\";\n state.step = null;\n navigation(\"runs\");\n detailPage(job);\n toast(`Sample run #${job.id} cancelled.`);\n }\n if (action === \"workflow-source\") {\n const workflow = workflows[Number(value)];\n openDialog(\n workflow.name,\n `<p class=\"dialog-description\">Illustrative YAML · revision 12</p><div class=\"source-view\"><pre>${escape(\n workflowSource(workflow),\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`,\n );\n }\n if (action === \"preferences\") {\n window.location.hash = \"/settings\";\n }\n if (action === \"shortcuts\") {\n openDialog(\n \"A few useful shortcuts\",\n `<p class=\"dialog-description\">Less clicking. More getting things done.</p><div class=\"shortcut-row\"><span>Search runs</span><kbd>/</kbd></div><div class=\"shortcut-row\"><span>Run a workflow</span><kbd>N</kbd></div><div class=\"shortcut-row\"><span>Show shortcuts</span><kbd>?</kbd></div><div class=\"shortcut-row\"><span>Close dialog</span><kbd>Esc</kbd></div><div class=\"dialog-footer\"><button class=\"button primary\" data-action=\"close-dialog\">Got it</button></div>`,\n );\n }\n});\ndocument.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if (event.target.id === \"log-search\") {\n state.logQuery = event.target.value;\n renderLogs(currentJob());\n }\n});\ndocument.addEventListener(\"change\", (event) => {\n if ([\"workflow-filter\", \"branch-filter\"].includes(event.target.id)) {\n state[event.target.id === \"workflow-filter\" ? \"workflow\" : \"branch\"] = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if ([\"compact-setting\", \"wrap-setting\"].includes(event.target.id)) {\n const key = event.target.id === \"compact-setting\" ? \"compact\" : \"wrap\";\n if (key === \"compact\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n } else {\n state.wrap = event.target.checked;\n }\n try {\n localStorage.setItem(`flow-concept-${key}`, event.target.checked);\n toast(\"Preference saved on this device.\");\n } catch {\n toast(\"Preference applied for this session.\");\n }\n }\n if (event.target.id === \"timezone-setting\") {\n localStorage.setItem(\"flow-concept-timezone\", event.target.value);\n }\n});\ndocument.addEventListener(\"submit\", (event) => {\n if (event.target.id === \"new-run-form\") {\n event.preventDefault();\n createRun(\n null,\n workflows.find((workflow) => workflow.id === event.target.elements.workflow.value),\n event.target.elements.branch.value.trim(),\n );\n }\n});\ndocument.addEventListener(\"keydown\", (event) => {\n if (\n event.ctrlKey ||\n event.metaKey ||\n event.altKey ||\n event.target.closest('input,select,textarea,[contenteditable=\"true\"]') ||\n dialog.open\n ) {\n return;\n }\n if (event.key === \"/\" && route().path === \"/runs\") {\n event.preventDefault();\n document.querySelector(\"#run-search\").focus();\n }\n if (event.key.toLowerCase() === \"n\") {\n event.preventDefault();\n newRunDialog();\n }\n if (event.key === \"?\") {\n event.preventDefault();\n document.querySelector('[data-action=\"shortcuts\"]').click();\n }\n});\nwindow.addEventListener(\"hashchange\", render);\ndocument.querySelectorAll(\"[data-icon]\").forEach((element) => {\n element.innerHTML = icon(element.dataset.icon);\n});\ndocument.body.classList.toggle(\"compact\", getPreference(\"compact\"));\nstate.wrap = getPreference(\"wrap\");\nrender();\n", pg = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#f7f8f6\" />\n <meta name=\"description\" content=\"Flow — a quieter place to ship. An interactive CI dashboard design concept.\" />\n <title>Runs · Flow</title>\n <link\n rel=\"icon\"\n href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23244336'/%3E%3Cpath d='M9 10h14M9 16h10M9 22h6' stroke='%23c4efad' stroke-width='3'/%3E%3C/svg%3E\"\n />\n <link rel=\"stylesheet\" href=\"./style.css\" />\n <script src=\"./app.js\" defer><\/script>\n </head>\n <body>\n <a href=\"#main\" class=\"skip-link\">Skip to content</a>\n <aside\n class=\"sidebar fixed inset-y-0 left-0 z-20 flex w-56 flex-col border-r border-flow-border bg-flow-sidebar px-[17px] pt-8 pb-[18px] max-[1180px]:w-48 max-[1180px]:px-3 max-[900px]:w-[72px] max-[900px]:items-center max-[900px]:px-[9px] max-[900px]:pt-[27px] max-[900px]:pb-[15px] max-[600px]:inset-x-0 max-[600px]:inset-y-auto max-[600px]:bottom-0 max-[600px]:h-[62px] max-[600px]:w-full max-[600px]:flex-row max-[600px]:items-center max-[600px]:border-r-0 max-[600px]:border-t max-[600px]:bg-[#fafbf8f5] max-[600px]:px-3 max-[600px]:pt-0 max-[600px]:pb-[env(safe-area-inset-bottom)] max-[600px]:backdrop-blur-[15px]\"\n aria-label=\"Workspace navigation\"\n >\n <a\n class=\"brand flex w-fit items-center px-[13px] text-[33px] font-bold tracking-[-1.9px] max-[900px]:p-0 max-[900px]:text-[0px] max-[600px]:hidden\"\n href=\"#/runs\"\n aria-label=\"Flow home\"\n ><span\n class=\"brand-mark mr-[10px] flex h-[34px] w-[31px] -skew-y-[13deg] flex-col justify-center gap-[5px] max-[900px]:m-0\"\n ><i class=\"block h-[5px] w-[27px] rounded-[2px] bg-[#547d42]\"></i\n ><i class=\"block h-[5px] w-5 rounded-[2px] bg-[#547d42]\"></i\n ><i class=\"block h-[5px] w-[13px] rounded-[2px] bg-[#547d42]\"></i></span\n >flow<span class=\"brand-period text-[#729b5b]\">.</span></a\n >\n <div\n class=\"workspace mx-[2px] my-[38px] flex items-center gap-[10px] rounded-[7px] border border-flow-border bg-flow-subtle px-[10px] py-3 max-[1180px]:gap-[7px] max-[900px]:hidden\"\n >\n <span\n class=\"workspace-icon grid h-[31px] w-[30px] place-items-center rounded-[5px] border border-[#e2e7dc] bg-[#edf1e7] text-[19px] font-bold\"\n >a</span\n >\n <div>\n <strong class=\"block text-[13px] font-semibold\">Apphor</strong\n ><span class=\"mt-[3px] block text-[13px] text-flow-muted\">Engineering workspace</span>\n </div>\n <span\n class=\"workspace-label ml-auto rounded-[3px] border border-flow-border px-1 py-[2px] text-[13px] text-flow-muted max-[1180px]:hidden\"\n >PRO</span\n >\n </div>\n <div\n class=\"nav-caption mb-3 px-[13px] text-[13px] font-semibold tracking-[1.4px] text-flow-muted max-[900px]:hidden\"\n >\n WORKSPACE\n </div>\n <nav\n class=\"grid gap-[5px] max-[900px]:mt-10 max-[900px]:w-full max-[600px]:mt-0 max-[600px]:flex max-[600px]:h-full max-[600px]:justify-around\"\n id=\"navigation\"\n ></nav>\n <div class=\"sidebar-bottom mt-auto max-[600px]:hidden\">\n <div class=\"runner-health flex items-center gap-[7px] px-2 py-[18px] text-[13px] text-flow-muted\">\n <span class=\"health-dot h-[6px] w-[6px] shrink-0 rounded-full bg-[#699d55]\"></span\n ><span>Runners operational</span><span class=\"mono ml-auto text-[10px]\">3/3</span>\n </div>\n <a\n id=\"profile-link\"\n class=\"profile flex w-full items-center gap-[10px] border-t border-flow-border px-[5px] pt-[19px] text-left text-flow-foreground no-underline hover:bg-flow-subtle\"\n href=\"#\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-disabled=\"true\"\n aria-label=\"Open identity profile in a new tab\"\n >\n <span\n class=\"avatar grid h-[33px] w-[33px] shrink-0 place-items-center rounded-full bg-[#ebe6d9] text-[13px] font-semibold text-[#675c44]\"\n id=\"profile-avatar\"\n >JD</span\n ><span class=\"min-w-0 max-w-[138px] max-[900px]:hidden\"\n ><strong id=\"profile-name\" class=\"block truncate text-[13px] font-medium\">Jamie Davis</strong\n ><small id=\"profile-email\" class=\"mt-[3px] block truncate text-[12px] text-flow-muted\">jamie@example.com</small\n ><span\n id=\"profile-role\"\n class=\"mt-1 inline-flex w-fit rounded-full bg-flow-subtle px-1.5 py-0.5 text-[10px] font-medium text-flow-muted\"\n >Admin</span\n ></span\n ><span class=\"ml-auto text-flow-muted max-[900px]:hidden\" data-icon=\"external\"></span>\n </a>\n </div>\n </aside>\n <div\n class=\"app-shell ml-56 flex min-h-screen flex-col max-[1180px]:ml-48 max-[900px]:ml-[72px] max-[600px]:ml-0 max-[600px]:pb-[62px]\"\n >\n <main id=\"main\" tabindex=\"-1\"></main>\n </div>\n <dialog id=\"dialog\" aria-labelledby=\"dialog-title\"></dialog>\n <div id=\"toast\" role=\"status\" class=\"toast\" hidden></div>\n </body>\n</html>\n", mg = "@import url(\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=IBM+Plex+Mono:wght@400;500&display=swap\");\n\n:root {\n --bg: #f3f5f1;\n --sidebar: #e9eee6;\n --ink: #1a1e22;\n --muted: #505b52;\n --line: #cbd3c8;\n --subtle: #fafbf8;\n --green: #387045;\n --green-bg: #e8f0e5;\n --accent: #c8e6a8;\n --red: #b54138;\n --red-bg: #fee2e2;\n --blue: #4a66a0;\n --blue-bg: #e8eef6;\n --amber: #845e1c;\n font-family:\n \"DM Sans\",\n -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--ink);\n font-size: 15px;\n font-synthesis: none;\n}\n* {\n box-sizing: border-box;\n}\nbody {\n margin: 0;\n background: var(--bg);\n}\nbutton,\ninput,\nselect {\n font: inherit;\n}\nbutton,\na,\ninput,\nselect,\nsummary {\n -webkit-tap-highlight-color: transparent;\n}\nbutton,\nselect,\nsummary {\n cursor: pointer;\n}\nbutton {\n color: inherit;\n}\na {\n color: inherit;\n text-decoration: none;\n}\nbutton {\n border: 0;\n background: none;\n}\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n:focus-visible {\n outline: 2px solid #467b40;\n outline-offset: 4px;\n}\ninput:focus-visible,\nselect:focus-visible {\n outline-offset: 1px;\n}\nbutton,\na {\n touch-action: manipulation;\n}\nsvg {\n width: 18px;\n height: 18px;\n flex-shrink: 0;\n vertical-align: middle;\n}\nh1,\nh2,\nh3,\np {\n margin: 0;\n}\n.mono,\ncode,\nkbd,\npre {\n font-family: \"IBM Plex Mono\", monospace;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n.skip-link {\n position: fixed;\n top: -60px;\n left: 20px;\n z-index: 100;\n background: var(--ink);\n color: white;\n padding: 12px;\n}\n.skip-link:focus {\n top: 10px;\n}\n.demo-label {\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: var(--muted);\n}\n.demo-label > span {\n width: 5px;\n height: 5px;\n border-radius: 50%;\n background: #8c9c7f;\n}\n.icon-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n min-height: 32px;\n border-radius: 5px;\n color: var(--muted);\n}\n.icon-button:hover {\n background: #edf0e8;\n color: var(--ink);\n}\nmain {\n width: 100%;\n max-width: 1600px;\n margin: 0 auto;\n padding: 43px 42px 30px;\n}\nmain:focus-visible {\n outline: none;\n}\n.page-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n margin-bottom: 32px;\n}\nh1 {\n font-size: 29px;\n letter-spacing: -1px;\n font-weight: 550;\n line-height: 1.25;\n}\n.subtitle {\n color: var(--muted);\n font-size: 12px;\n margin-top: 9px;\n line-height: 1.7;\n}\n.heading-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n.button {\n display: inline-flex;\n justify-content: center;\n align-items: center;\n gap: 7px;\n padding: 10px 13px;\n border: 1px solid #dfe4d9;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 550;\n min-height: 36px;\n background: var(--bg);\n white-space: nowrap;\n}\n.button svg {\n width: 14px;\n height: 14px;\n}\n.button:hover {\n background: var(--subtle);\n border-color: #c8d0bf;\n}\n.button.primary {\n background: var(--accent);\n border-color: #b6d79d;\n color: #2c4320;\n}\n.button.primary:hover {\n background: #b7e198;\n}\n.button.danger {\n color: var(--red);\n border-color: #edcdc8;\n}\n.text-button {\n color: #52644b;\n font-size: 13px;\n padding: 6px 0;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n.text-button:hover {\n text-decoration: underline;\n}\n.text-button svg {\n width: 13px;\n}\n.overview {\n display: grid;\n grid-template-columns: 1.08fr 1.08fr 1fr 1fr;\n border: 1px solid var(--line);\n border-radius: 8px;\n margin-bottom: 31px;\n background: linear-gradient(110deg, #fcfdf9, #fff);\n overflow: hidden;\n}\n.metric {\n position: relative;\n padding: 22px 24px;\n min-height: 117px;\n}\n.metric + .metric::before {\n content: \"\";\n position: absolute;\n width: 1px;\n background: var(--line);\n top: 23px;\n bottom: 23px;\n left: 0;\n}\n.metric-label {\n display: flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: #70786a;\n}\n.metric-label svg {\n width: 13px;\n height: 13px;\n color: #8a9482;\n}\n.metric-bottom {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin-top: 13px;\n}\n.metric-value {\n font-size: 27px;\n letter-spacing: -1px;\n font-weight: 500;\n}\n.metric-value small {\n font-size: 15px;\n letter-spacing: 0;\n}\n.metric-note {\n font-size: 13px;\n color: #69765f;\n}\n.metric-note span {\n color: var(--green);\n}\n.tiny-bars {\n display: flex;\n gap: 3px;\n align-items: end;\n height: 25px;\n margin-left: auto;\n}\n.tiny-bars i {\n width: 5px;\n background: #c4d9b2;\n border-radius: 2px;\n}\n.metric-foot {\n margin-top: 7px;\n font-size: 10px;\n color: #858b7f;\n}\n.section-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 15px;\n margin-bottom: 17px;\n}\nh2 {\n font-size: 14px;\n font-weight: 550;\n letter-spacing: -0.2px;\n}\n.section-heading h2 {\n display: flex;\n gap: 9px;\n align-items: center;\n}\n.count {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 10px;\n font-weight: 400;\n background: #f0f2ed;\n color: #747d6d;\n border: 1px solid #e7ebe1;\n border-radius: 4px;\n padding: 2px 5px;\n}\n.section-heading > span {\n color: var(--muted);\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.section-heading > span svg {\n width: 12px;\n height: 12px;\n}\n.filters {\n display: flex;\n gap: 9px;\n align-items: center;\n margin-bottom: 15px;\n}\n.search-box {\n display: flex;\n align-items: center;\n gap: 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n height: 35px;\n padding: 0 10px;\n min-width: 0;\n background: white;\n}\n.search-box svg {\n width: 14px;\n height: 14px;\n color: #8a9184;\n}\n.search-box input {\n border: 0;\n min-width: 0;\n width: 100%;\n outline: 0;\n background: transparent;\n color: var(--ink);\n font-size: 13px;\n}\n.search-box:focus-within {\n border-color: #729859;\n box-shadow: 0 0 0 2px #edf5e8;\n}\n.search-box input::placeholder {\n color: #899080;\n}\n.filters .search-box {\n width: 300px;\n margin-right: auto;\n}\nkbd {\n font-size: 10px;\n border: 1px solid #e6e9e1;\n padding: 1px 4px;\n color: #838b7a;\n border-radius: 3px;\n}\n.select-filter {\n border: 1px solid var(--line);\n background: white;\n border-radius: 6px;\n padding: 8px 28px 8px 11px;\n color: #666f5e;\n font-size: 13px;\n height: 35px;\n max-width: 170px;\n}\n.filter-tabs {\n display: flex;\n gap: 23px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 0;\n}\n.filter-tab {\n padding: 1px 0 13px;\n font-size: 13px;\n color: var(--muted);\n border-bottom: 2px solid transparent;\n display: flex;\n align-items: center;\n gap: 7px;\n margin-bottom: -1px;\n}\n.filter-tab.active {\n color: var(--ink);\n border-bottom-color: #5c7a46;\n font-weight: 550;\n}\n.filter-tab .count {\n border: 0;\n background: #f0f2ec;\n padding: 1px 5px;\n font-size: 10px;\n}\n.filter-tab.active .count {\n background: #eaf1e1;\n color: #536d40;\n}\n.run-table {\n width: 100%;\n border-collapse: collapse;\n text-align: left;\n table-layout: fixed;\n}\n.run-table th {\n font-weight: 400;\n font-size: 12px;\n color: #808877;\n padding: 14px 10px;\n background: #fafbf8;\n border-bottom: 1px solid var(--line);\n}\n.run-table th:first-child {\n padding-left: 15px;\n width: 44%;\n}\n.run-table th:nth-child(2) {\n width: 16%;\n}\n.run-table th:nth-child(3) {\n width: 17%;\n}\n.run-table th:nth-child(4) {\n width: 11%;\n}\n.run-table th:last-child {\n width: 12%;\n text-align: right;\n padding-right: 17px;\n}\n.run-table td {\n padding: 18px 10px;\n border-bottom: 1px solid var(--line);\n font-size: 13px;\n color: #747c6c;\n}\n.run-table td:first-child {\n padding-left: 15px;\n}\n.run-table td:last-child {\n text-align: right;\n padding-right: 17px;\n}\n.run-table tr:hover td {\n background: #fafbf8;\n}\n.run-link {\n display: flex;\n align-items: center;\n gap: 12px;\n min-width: 0;\n}\n.run-title {\n display: block;\n font-size: 13px;\n color: #30382c;\n font-weight: 550;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.run-copy {\n min-width: 0;\n}\n.run-subtitle {\n font-size: 13px;\n color: #818878;\n margin-top: 5px;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.run-subtitle .mono {\n font-size: 10px;\n}\n.run-subtitle .separator {\n color: #bdc4b5;\n}\n.status-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 21px;\n height: 21px;\n}\n.status-icon svg {\n width: 18px;\n height: 18px;\n}\n.status-icon.success {\n color: #6b9355;\n}\n.status-icon.failed {\n color: #cc7064;\n}\n.status-icon.running {\n color: #647fab;\n}\n.status-icon.pending {\n color: #af955e;\n}\n.status-icon.cancelled,\n.status-icon.skipped {\n color: #91998a;\n}\n.status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n border-radius: 5px;\n padding: 4px 7px;\n white-space: nowrap;\n}\n.status .status-icon {\n width: 11px;\n height: 11px;\n}\n.status .status-icon svg {\n width: 11px;\n height: 11px;\n}\n.status.success {\n background: var(--green-bg);\n color: var(--green);\n}\n.status.failed {\n background: var(--red-bg);\n color: var(--red);\n}\n.status.running {\n background: var(--blue-bg);\n color: var(--blue);\n}\n.status.pending {\n background: #faf4e6;\n color: var(--amber);\n}\n.status.cancelled,\n.status.skipped {\n background: #f0f2ed;\n color: #737c68;\n}\n.branch {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-size: 13px;\n}\n.branch svg {\n width: 12px;\n height: 12px;\n}\n.duration {\n font-size: 13px;\n color: var(--muted);\n}\n.table-footer {\n padding: 18px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n font-size: 13px;\n color: #838b7b;\n}\n.pagination {\n display: flex;\n align-items: center;\n gap: 14px;\n}\n.pagination .button {\n padding: 6px 9px;\n min-height: 28px;\n font-size: 13px;\n}\n.table-hint {\n display: flex;\n align-items: center;\n gap: 5px;\n color: #8b9186;\n font-size: 10px;\n}\n.table-hint svg {\n width: 11px;\n height: 11px;\n}\n.attention-strip {\n display: flex;\n align-items: center;\n gap: 12px;\n border: 1px solid #e8e4d8;\n border-radius: 7px;\n padding: 15px 17px;\n margin-top: 18px;\n background: #fcfbf7;\n}\n.attention-strip > svg {\n color: #aa8c50;\n width: 17px;\n}\n.attention-strip p {\n font-size: 13px;\n color: #5f6153;\n}\n.attention-strip p span {\n color: #89907f;\n margin-left: 5px;\n}\n.attention-strip .text-button {\n margin-left: auto;\n white-space: nowrap;\n}\n.page-footer {\n margin: auto 42px 0;\n padding: 25px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n color: #8b9382;\n font-size: 10px;\n border-top: 1px solid var(--line);\n}\n.footer-mark {\n font-size: 19px;\n color: #45643c;\n margin-right: 5px;\n vertical-align: middle;\n}\n.footer-dot {\n margin: 0 7px;\n color: #b4bdae;\n}\n.back-link {\n display: inline-flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.back-link svg {\n width: 13px;\n height: 13px;\n}\n.run-heading h1 {\n display: flex;\n align-items: center;\n gap: 10px;\n font-size: 26px;\n}\n.run-heading h1 .status-icon {\n width: 27px;\n height: 27px;\n}\n.run-heading h1 .status-icon svg {\n width: 25px;\n height: 25px;\n}\n.run-heading .subtitle {\n display: flex;\n flex-wrap: wrap;\n gap: 7px;\n align-items: center;\n margin-left: 38px;\n font-size: 13px;\n}\n.run-heading .subtitle .mono {\n font-size: 13px;\n}\n.detail-meta {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n padding: 19px 22px;\n gap: 20px;\n margin-bottom: 26px;\n}\n.meta-label {\n display: block;\n font-size: 10px;\n color: #7b8571;\n margin-bottom: 10px;\n}\n.meta-value {\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.meta-value svg {\n width: 13px;\n height: 13px;\n color: #939c88;\n}\n.detail-tabs {\n display: flex;\n gap: 25px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 22px;\n}\n.detail-tabs button {\n padding: 0 1px 13px;\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: #7b8373;\n border-bottom: 2px solid transparent;\n}\n.detail-tabs button svg {\n width: 14px;\n height: 14px;\n}\n.detail-tabs button.active {\n color: #34422b;\n border-bottom-color: #657d4f;\n}\n.failure-banner {\n padding: 14px 17px;\n background: #fdf6f3;\n border: 1px solid #f0ddd5;\n display: flex;\n gap: 12px;\n align-items: center;\n border-radius: 6px;\n margin-bottom: 20px;\n}\n.failure-banner > svg {\n color: #bf7864;\n}\n.failure-banner strong {\n font-size: 13px;\n font-weight: 550;\n color: #8e5746;\n}\n.failure-banner p {\n font-size: 13px;\n color: #9b7d6c;\n margin-top: 4px;\n}\n.failure-banner button {\n margin-left: auto;\n color: #9f5e4b;\n white-space: nowrap;\n}\n.execution {\n display: grid;\n grid-template-columns: 226px minmax(0, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n min-height: 385px;\n}\n.steps-panel {\n background: #fafbf8;\n border-right: 1px solid var(--line);\n padding: 17px 10px;\n}\n.steps-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 2px 10px 16px;\n font-size: 13px;\n color: #656e5d;\n}\n.steps-heading .mono {\n font-size: 10px;\n color: #8e9685;\n}\n.step-button {\n display: flex;\n align-items: center;\n width: 100%;\n gap: 9px;\n padding: 12px 10px;\n border: 1px solid transparent;\n border-radius: 5px;\n text-align: left;\n margin-bottom: 3px;\n font-size: 13px;\n color: #626c58;\n}\n.step-button .status-icon,\n.step-button .status-icon svg {\n width: 14px;\n height: 14px;\n}\n.step-button .mono {\n margin-left: auto;\n font-size: 10px;\n color: #87907c;\n}\n.step-button.active {\n border-color: #e2e5dc;\n background: white;\n box-shadow: 0 1px 3px #23351204;\n color: #303e25;\n}\n.step-button:hover {\n background: #f0f3eb;\n}\n.step-count {\n border-top: 1px solid var(--line);\n padding: 16px 10px 0;\n margin-top: 18px;\n font-size: 10px;\n color: #7d8672;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.step-count svg {\n width: 12px;\n height: 12px;\n}\n.log-panel {\n min-width: 0;\n background: #1c211e;\n color: #c9d0c5;\n display: flex;\n flex-direction: column;\n}\n.log-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 16px 19px;\n border-bottom: 1px solid #343b31;\n background: #252b26;\n gap: 10px;\n}\n.log-heading h3 {\n font-size: 13px;\n font-weight: 500;\n color: #dce2d7;\n}\n.log-heading h3 small {\n margin-left: 8px;\n color: #adba9f;\n font-size: 10px;\n font-weight: 400;\n}\n.log-actions {\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.log-actions .icon-button {\n color: #a6b09e;\n min-height: 25px;\n min-width: 25px;\n}\n.log-actions .icon-button:hover {\n background: #3d453a;\n color: white;\n}\n.log-actions svg {\n width: 14px;\n height: 14px;\n}\n.log-search {\n border-bottom: 1px solid #343b31;\n padding: 9px 17px;\n display: flex;\n align-items: center;\n gap: 7px;\n}\n.log-search svg {\n color: #8b9983;\n width: 13px;\n}\n.log-search input {\n width: 100%;\n min-width: 0;\n background: none;\n border: 0;\n color: #d6dfce;\n outline: none;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n}\n.log-search input::placeholder {\n color: #8d9b85;\n}\n.log-search:focus-within {\n box-shadow: inset 0 0 0 1px #a0c983;\n}\n.log-search span {\n font-size: 10px;\n white-space: nowrap;\n color: #a6b299;\n}\n.log-lines {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n line-height: 2.15;\n overflow: auto;\n padding: 15px 0;\n flex: 1;\n min-height: 260px;\n max-height: 490px;\n}\n.log-line {\n display: flex;\n white-space: pre;\n padding: 0 18px 0 0;\n min-width: max-content;\n}\n.line-number {\n color: #86927d;\n width: 44px;\n padding-right: 15px;\n text-align: right;\n user-select: none;\n flex-shrink: 0;\n}\n.log-time {\n color: #8a9782;\n margin-right: 16px;\n}\n.log-line.error {\n background: #59332c4a;\n color: #f0a28e;\n}\n.log-line.pass {\n color: #b9d49e;\n}\n.log-line.dim {\n color: #98a48f;\n}\n.log-line mark {\n background: #bdcb92;\n color: #202923;\n}\n.log-lines.wrap .log-line {\n white-space: pre-wrap;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.log-lines.wrap .line-text {\n min-width: 0;\n}\n.log-bottom {\n font-size: 10px;\n color: #a1af95;\n padding: 11px 17px;\n border-top: 1px solid #343b31;\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n.detail-bottom {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 0 7px;\n color: #838d78;\n font-size: 10px;\n}\n.detail-bottom span {\n display: flex;\n align-items: center;\n gap: 5px;\n}\n.detail-bottom svg {\n width: 12px;\n height: 12px;\n}\n.source-view {\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n}\n.source-view h3 {\n font-size: 13px;\n font-weight: 500;\n background: var(--subtle);\n padding: 15px 20px;\n border-bottom: 1px solid var(--line);\n}\n.source-view pre {\n margin: 0;\n background: #1c211e;\n color: #c2d3b5;\n padding: 25px;\n font-size: 12px;\n line-height: 1.9;\n overflow: auto;\n}\n.empty-state {\n text-align: center;\n padding: 55px 20px;\n color: var(--muted);\n}\n.empty-state svg {\n width: 27px;\n height: 27px;\n color: #99aa8a;\n margin-bottom: 15px;\n}\n.empty-state h3 {\n font-size: 14px;\n color: var(--ink);\n font-weight: 500;\n margin-bottom: 8px;\n}\n.empty-state p {\n font-size: 13px;\n line-height: 1.8;\n}\n.empty-state .button {\n margin-top: 14px;\n}\n.tag {\n background: #f2f4ee;\n padding: 4px 7px;\n border-radius: 4px;\n font-size: 10px;\n color: #6b795e;\n}\n.status-tabs {\n display: flex;\n gap: 6px;\n overflow-x: auto;\n border-bottom: 1px solid var(--line);\n margin: 20px 0 0;\n}\n.status-tab {\n display: inline-flex;\n align-items: center;\n gap: 7px;\n padding: 10px 12px;\n color: #727b6e;\n white-space: nowrap;\n border-bottom: 2px solid transparent;\n}\n.status-tab:hover,\n.status-tab.active {\n color: #334334;\n}\n.status-tab.active {\n border-bottom-color: #718a62;\n}\n.status-tab .count {\n min-width: 18px;\n text-align: center;\n}\n.workflow-status {\n padding: 3px 7px;\n border-radius: 4px;\n background: #f2f4ee;\n color: #68745d;\n font-size: 11px;\n text-transform: capitalize;\n}\n.workflow-status.success {\n background: #edf5e8;\n color: #4d7040;\n}\n.settings-section {\n border: 1px solid var(--line);\n border-radius: 7px;\n margin-bottom: 16px;\n padding: 18px 20px;\n background: #fff;\n}\n.settings-section h2 {\n margin-bottom: 15px;\n}\n.setting-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 0;\n gap: 16px;\n}\n.setting-row + .setting-row {\n border-top: 1px solid var(--line);\n}\n.setting-row strong {\n display: block;\n font-size: 12px;\n font-weight: 550;\n}\n.setting-row p {\n font-size: 11px;\n color: var(--muted);\n margin-top: 4px;\n}\n.setting-row input[type=\"checkbox\"] {\n accent-color: #678951;\n width: 17px;\n height: 17px;\n}\n.secret-value {\n color: var(--muted);\n font-size: 12px;\n}\n.settings-note {\n color: var(--muted);\n font-size: 13px;\n line-height: 1.8;\n}\ndialog {\n width: min(470px, calc(100% - 32px));\n padding: 27px;\n border: 1px solid #dde4d5;\n border-radius: 11px;\n box-shadow: 0 20px 80px #17261530;\n color: var(--ink);\n}\ndialog::backdrop {\n background: #17211755;\n backdrop-filter: blur(3px);\n}\n.dialog-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 10px;\n}\n.dialog-heading h2 {\n font-size: 19px;\n letter-spacing: -0.6px;\n}\n.dialog-description {\n font-size: 12px;\n line-height: 1.8;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.form-field {\n display: block;\n margin: 17px 0;\n}\n.form-field > span {\n display: block;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 8px;\n}\n.form-field input,\n.form-field select {\n display: block;\n width: 100%;\n border: 1px solid #dfe5d8;\n border-radius: 6px;\n padding: 11px;\n background: white;\n font-size: 12px;\n color: var(--ink);\n}\n.dialog-footer {\n display: flex;\n justify-content: flex-end;\n gap: 9px;\n margin-top: 24px;\n}\n.dialog-note {\n color: #849078;\n font-size: 13px;\n line-height: 1.7;\n}\n.shortcut-row {\n display: flex;\n justify-content: space-between;\n padding: 12px 0;\n font-size: 12px;\n border-bottom: 1px solid var(--line);\n}\n.toast {\n position: fixed;\n bottom: 25px;\n left: calc(50% + 112px);\n transform: translateX(-50%);\n max-width: calc(100vw - 40px);\n background: #273321;\n color: #e6f0de;\n padding: 13px 19px;\n border: 1px solid #4f6342;\n border-radius: 7px;\n font-size: 12px;\n box-shadow: 0 8px 30px #15220c20;\n z-index: 100;\n}\nbody.compact .run-table td {\n padding-top: 12px;\n padding-bottom: 12px;\n}\n\n/* Shared secondary text stays readable on white and tinted surfaces. */\n.metric-label,\n.metric-foot,\n.count,\n.run-subtitle,\n.run-table th,\n.run-table td,\n.table-footer,\n.table-hint,\n.attention-strip p span,\n.page-footer,\n.meta-label,\n.detail-tabs button,\n.steps-heading .mono,\n.step-button .mono,\n.step-count,\n.detail-bottom,\n.tag,\n.dialog-note,\nkbd {\n color: var(--muted);\n}\n.failure-banner p {\n color: #85634f;\n}\n.search-box input::placeholder {\n color: #69745f;\n}\n.subtitle,\n.button,\n.filter-tab,\n.meta-value,\n.settings-note {\n font-size: 12px;\n}\n.run-subtitle,\n.run-table th,\n.select-filter,\n.search-box input,\n.status,\n.step-button {\n font-size: 13px;\n}\n.log-lines {\n font-size: 13px;\n}\n.detail-meta {\n overflow-wrap: anywhere;\n}\n@media (min-width: 1500px) {\n main {\n padding-top: 50px;\n }\n .run-table td {\n padding-top: 22px;\n padding-bottom: 22px;\n }\n}\n@media (max-width: 1180px) {\n main {\n padding-inline: 28px;\n }\n .page-footer {\n margin-inline: 28px;\n }\n .metric {\n padding: 19px 16px;\n }\n .tiny-bars {\n display: none;\n }\n .metric-note {\n font-size: 10px;\n }\n .metric-value {\n font-size: 25px;\n }\n .run-table th:first-child {\n width: 40%;\n }\n .run-table th:nth-child(3) {\n width: 18%;\n }\n .run-table th:nth-child(2) {\n width: 18%;\n }\n .execution {\n grid-template-columns: 195px minmax(0, 1fr);\n }\n .toast {\n left: calc(50% + 96px);\n }\n}\n@media (max-width: 900px) {\n .metric {\n padding: 17px 14px;\n }\n .metric-bottom {\n gap: 6px;\n }\n .metric-note {\n display: none;\n }\n .filters .search-box {\n width: auto;\n flex: 1;\n }\n .select-filter {\n max-width: 150px;\n }\n .run-table th:nth-child(3),\n .run-table td:nth-child(3) {\n display: none;\n }\n .run-table th:first-child {\n width: 48%;\n }\n .run-table th:nth-child(2) {\n width: 21%;\n }\n .run-table th:nth-child(4) {\n width: 14%;\n }\n .run-table th:last-child {\n width: 17%;\n }\n .detail-meta {\n grid-template-columns: repeat(3, 1fr);\n row-gap: 22px;\n }\n .run-heading {\n align-items: flex-start;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .heading-actions {\n flex-direction: column;\n align-items: stretch;\n }\n .execution {\n grid-template-columns: 175px minmax(0, 1fr);\n }\n .step-button {\n gap: 6px;\n padding-inline: 6px;\n font-size: 10px;\n }\n .step-button .mono {\n font-size: 8px;\n }\n .log-time {\n display: none;\n }\n .attention-strip p span {\n display: none;\n }\n .toast {\n left: calc(50% + 36px);\n }\n}\n@media (max-width: 600px) {\n .demo-label {\n display: none;\n }\n main {\n padding: 28px 20px 20px;\n }\n .page-heading {\n margin-bottom: 24px;\n gap: 10px;\n }\n h1 {\n font-size: 27px;\n }\n .subtitle {\n font-size: 13px;\n max-width: 210px;\n }\n .button {\n font-size: 13px;\n padding: 9px 10px;\n }\n .overview {\n grid-template-columns: 1fr 1fr;\n margin-bottom: 28px;\n }\n .metric {\n min-height: 97px;\n padding: 16px;\n }\n .metric-label {\n font-size: 13px;\n }\n .metric-value {\n font-size: 25px;\n }\n .metric-bottom {\n margin-top: 9px;\n }\n .metric:nth-child(3),\n .metric:nth-child(4) {\n border-top: 1px solid var(--line);\n }\n .metric:nth-child(3)::before {\n display: none;\n }\n .metric + .metric::before {\n top: 16px;\n bottom: 16px;\n }\n .metric-note {\n display: inline;\n }\n .tiny-bars {\n display: flex;\n }\n .metric-foot {\n display: none;\n }\n .section-heading {\n margin-bottom: 14px;\n }\n .section-heading > span {\n font-size: 10px;\n }\n .filters {\n flex-wrap: wrap;\n gap: 7px;\n }\n .filters .search-box {\n flex: 1 0 100%;\n }\n .select-filter {\n flex: 1;\n max-width: none;\n }\n .filter-tabs {\n gap: 19px;\n margin-top: 19px;\n }\n .filter-tab {\n font-size: 13px;\n gap: 4px;\n }\n .filter-tab .count {\n font-size: 8px;\n }\n .run-table th {\n font-size: 10px;\n padding-inline: 5px;\n }\n .run-table th:first-child {\n width: 67%;\n padding-left: 2px;\n }\n .run-table th:nth-child(2) {\n width: 33%;\n text-align: right;\n padding-right: 3px;\n }\n .run-table td {\n padding: 18px 3px;\n }\n .run-table td:first-child {\n padding-left: 2px;\n }\n .run-table td:nth-child(2) {\n text-align: right;\n padding-right: 3px;\n }\n .run-table th:nth-child(4),\n .run-table td:nth-child(4),\n .run-table th:last-child,\n .run-table td:last-child {\n display: none;\n }\n .run-link {\n gap: 7px;\n }\n .run-title {\n font-size: 13px;\n }\n .run-subtitle {\n font-size: 10px;\n gap: 4px;\n }\n .run-subtitle .mono {\n font-size: 8px;\n }\n .status {\n font-size: 10px;\n padding: 4px 5px;\n }\n .table-footer {\n flex-wrap: wrap;\n font-size: 10px;\n }\n .pagination {\n gap: 9px;\n }\n .table-hint {\n display: none;\n }\n .attention-strip {\n padding: 12px;\n gap: 7px;\n }\n .attention-strip p {\n font-size: 13px;\n }\n .attention-strip .text-button {\n font-size: 10px;\n }\n .page-footer {\n margin-inline: 20px;\n font-size: 8px;\n flex-wrap: wrap;\n gap: 7px;\n padding-block: 19px;\n }\n .footer-mark {\n font-size: 15px;\n }\n .toast {\n left: 50%;\n bottom: 78px;\n width: max-content;\n text-align: center;\n font-size: 13px;\n }\n .back-link {\n margin-bottom: 20px;\n }\n .run-heading {\n flex-direction: column;\n gap: 18px;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .subtitle {\n margin-left: 0;\n max-width: none;\n font-size: 13px;\n }\n .run-heading .heading-actions {\n flex-direction: row;\n width: 100%;\n }\n .run-heading .heading-actions > * {\n flex: 1;\n max-width: none;\n }\n .detail-meta {\n grid-template-columns: repeat(2, 1fr);\n padding: 16px;\n gap: 20px 15px;\n }\n .detail-meta > div:last-child {\n grid-column: span 2;\n }\n .detail-tabs {\n gap: 20px;\n }\n .detail-tabs button {\n font-size: 13px;\n gap: 5px;\n }\n .detail-tabs button svg {\n width: 12px;\n }\n .failure-banner {\n padding: 12px;\n align-items: flex-start;\n flex-wrap: wrap;\n gap: 7px;\n }\n .failure-banner > div {\n flex: 1;\n }\n .failure-banner strong {\n font-size: 13px;\n }\n .failure-banner p {\n font-size: 10px;\n line-height: 1.6;\n }\n .failure-banner button {\n margin-left: 26px;\n font-size: 13px;\n }\n .execution {\n grid-template-columns: minmax(0, 1fr);\n }\n .steps-panel {\n border-right: 0;\n border-bottom: 1px solid var(--line);\n padding: 12px;\n }\n .steps-heading {\n padding: 3px 5px 10px;\n }\n .step-button {\n font-size: 13px;\n padding: 9px;\n margin-bottom: 1px;\n }\n .step-button .mono {\n font-size: 10px;\n }\n .step-count {\n display: none;\n }\n .log-heading {\n padding: 13px;\n }\n .log-heading h3 {\n font-size: 13px;\n }\n .log-heading h3 small {\n font-size: 8px;\n }\n .log-lines {\n font-size: 10px;\n min-height: 290px;\n }\n .log-search {\n padding-inline: 13px;\n }\n .line-number {\n width: 32px;\n padding-right: 10px;\n }\n .detail-bottom {\n gap: 10px;\n flex-wrap: wrap;\n font-size: 8px;\n }\n .setting-row {\n gap: 14px;\n }\n .settings-section {\n padding: 18px;\n }\n .setting-row p {\n font-size: 13px;\n }\n .source-view pre {\n font-size: 13px;\n padding: 17px;\n }\n}\n@media (prefers-reduced-motion: no-preference) {\n .status-icon.running svg {\n animation: spin 2.5s linear infinite;\n }\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n}\n";
|
|
12413
|
+
}, ig = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nexport async function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) {\n if (response.status === 401) {\n // When /api/auth/token returns 401, clear OIDC in-memory token state and redirect\n // to login so the user can re-authenticate. This even applies when there was\n // no prior cached token, fixing the reported symptom where 401 was silently\n // swallowed and settings APIs stayed broken.\n token = '';\n expiresAt = 0;\n if (sessionStorage.getItem('runner-api-token')) {\n sessionStorage.removeItem('runner-api-token');\n }\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n }\n return '';\n }\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n}\n\nexport async function apiFetch(input, init = {}) {\n const target = new URL(input, window.location.href);\n if (\n target.origin !== window.location.origin ||\n (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))\n ) {\n return rawFetch(input, init);\n }\n const headers = new Headers(init.headers);\n const accessToken = await loadToken();\n if (accessToken) headers.set('authorization', `Bearer ${accessToken}`);\n return rawFetch(input, { ...init, headers });\n}\n\nexport function setApiToken(value) {\n token = value || '';\n expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n if (token) sessionStorage.setItem('runner-api-token', token);\n else sessionStorage.removeItem('runner-api-token');\n}\n", ag = "import { onDestroy, onInit, ref } from \"@li3/web\";\n\nexport default function setup() {\n const activeSection = ref(\"runs\");\n const user = ref({ name: \"\", email: \"\", role: \"\" });\n const oidcProviderUrl = ref(\"\");\n const userAuthenticated = ref(false);\n const userInitial = ref(\"U\");\n\n const syncSection = () => {\n const path = window.location.pathname;\n activeSection.value = path.startsWith(\"/settings\")\n ? \"settings\"\n : path.startsWith(\"/workflows\")\n ? \"workflows\"\n : \"runs\";\n };\n\n const setActive = (section) => {\n activeSection.value = section;\n };\n\n onInit(() => {\n syncSection();\n window.addEventListener(\"popstate\", syncSection);\n void fetch(\"/api/auth/session\", { credentials: \"same-origin\", headers: { accept: \"application/json\" } })\n .then((response) => (response.ok ? response.json() : null))\n .then((session) => {\n if (!session?.authenticated || !session.user) return;\n user.value = session.user;\n oidcProviderUrl.value = session.providerUrl || \"\";\n userAuthenticated.value = true;\n userInitial.value = String(session.user.name || session.user.email || \"U\")\n .trim()\n .slice(0, 1)\n .toUpperCase();\n })\n .catch((error) => console.error(\"Unable to load the signed-in profile\", error));\n });\n\n onDestroy(() => window.removeEventListener(\"popstate\", syncSection));\n\n return { activeSection, user, userAuthenticated, userInitial, oidcProviderUrl, setActive };\n}\n", og = "import { defineProp, onInit, ref } from '@li3/web';\n\nexport default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = ref('');\n const userPhoto = ref('');\n onInit(async () => {\n try {\n const response = await fetch('/api/auth/session');\n if (!response.ok) return;\n const session = await response.json();\n authConfigured.value = session.configured;\n authenticated.value = session.authenticated;\n userName.value = session.user?.name || session.user?.email || '';\n userPhoto.value = session.user?.photo || '';\n } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName, userPhoto };\n}\n", sg = "import { getElement, load, onDestroy, onInit, templateRef } from \"@li3/web\";\n\nexport default function () {\n const outlet = templateRef(\"outlet\");\n let navigating = false;\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === \"/\" || path === \"/runs\")\n return { component: \"page-dashboard\", url: \"/pages/dashboard.html\", page: \"dashboard\" };\n if (path.match(/^\\/runs\\/\\d+$/))\n return { component: \"page-run\", url: `/pages/run.html?jobId=${path.split(\"/\").pop()}`, page: \"run\" };\n if (path === \"/help\") return { component: \"page-help\", url: \"/pages/help.html\", page: \"help\" };\n if (path === \"/settings\") return { component: \"page-settings\", url: \"/pages/settings.html\", page: \"settings\" };\n if (path === \"/workflows\")\n return { component: \"page-workflows\", url: \"/pages/workflows.html?page=workflows\", page: \"workflows\" };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor)\n return {\n component: \"page-workflow-editor\",\n url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get(\"revision\") ? `&revision=${url.searchParams.get(\"revision\")}` : \"\"}`,\n page: \"editor\",\n };\n return { component: \"page-not-found\", page: \"not-found\" };\n };\n const navigate = async (replace = false) => {\n if (navigating) return;\n navigating = true;\n try {\n const current = route();\n document.body.dataset.page = current.page;\n if (current.page === \"not-found\") {\n outlet.value.innerHTML =\n '<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold\">404 — Page not found</h1><p class=\"mt-2 text-sm\">The page you are looking for does not exist. <a href=\"/\">Go home</a> or <a href=\"/runs\">view runs</a>.</p></main>';\n } else {\n await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\n if (window.location.hash) {\n requestAnimationFrame(() =>\n requestAnimationFrame(() => document.querySelector(window.location.hash)?.scrollIntoView()),\n );\n }\n }\n if (replace) history.replaceState(null, \"\", window.location.href);\n } catch (error) {\n outlet.value.innerHTML = `<main class=\"mx-auto max-w-4xl p-8\"><h1 class=\"text-xl font-semibold text-white\">Unable to load page</h1><p class=\"mt-2 text-sm text-rose-300\">${String(error.message || error)}</p></main>`;\n } finally {\n navigating = false;\n }\n };\n const handleClick = (event) => {\n const anchor = event.target.closest?.(\"a\");\n if (!anchor || anchor.target || anchor.hasAttribute(\"download\")) return;\n const target = new URL(anchor.href, window.location.href);\n if (\n target.origin !== window.location.origin ||\n !target.pathname.startsWith(\"/\") ||\n target.pathname.startsWith(\"/auth/\")\n )\n return;\n if (target.pathname.startsWith(\"/api/\") || target.pathname.startsWith(\"/webhooks/\")) return;\n if (target.pathname === window.location.pathname && target.hash) return;\n event.preventDefault();\n history.pushState(null, \"\", `${target.pathname}${target.search}${target.hash}`);\n void navigate();\n };\n const element = getElement();\n onInit(() => {\n element.addEventListener(\"click\", handleClick);\n window.addEventListener(\"popstate\", navigate);\n void navigate(true);\n });\n onDestroy(() => {\n element.removeEventListener(\"click\", handleClick);\n window.removeEventListener(\"popstate\", navigate);\n });\n return {};\n}\n", cg = "import { onDestroy, onInit, ref } from \"@li3/web\";\nimport { apiFetch } from \"@app/api-client.mjs\";\n\nexport default function setup() {\n const jobs = ref([]),\n refreshError = ref(false),\n hasMore = ref(false),\n loadingMore = ref(false),\n page = ref(1);\n const pageSize = 8;\n const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\");\n const filter = ref(\"\"),\n activeFilter = ref(\"\"),\n pushConfigured = ref(false);\n const workflows = ref([]),\n workers = ref([]),\n workflowFilter = ref(\"\"),\n workerFilter = ref(\"\"),\n statusFilter = ref(\"all\");\n const notificationsSupported = ref(\"Notification\" in window && \"serviceWorker\" in navigator);\n const notificationsEnabled = ref(\n notificationsSupported.value &&\n Notification.permission === \"granted\" &&\n localStorage.getItem(\"runner-notifications\") === \"enabled\",\n );\n const notificationNotice = ref(\"\");\n let refreshTimer,\n eventSource,\n refreshing = false,\n refreshPending = false,\n searchInProgress = false,\n refreshGeneration = 0;\n const upper = (value) => String(value || \"\").toUpperCase();\n const workflowName = (id) => workflows.value.find((workflow) => workflow.id === id)?.name || \"Workflow\";\n const formatDate = (value) => {\n if (!value) return \"—\";\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n if (Number.isNaN(date.getTime())) return \"—\";\n return new Intl.DateTimeFormat(undefined, {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n timeZone: timezone.value,\n }).format(date);\n };\n const filteredJobs = () =>\n jobs.value.filter(\n (job) =>\n (!workerFilter.value || job.workerId === workerFilter.value) &&\n (statusFilter.value === \"all\" ||\n (statusFilter.value === \"running\"\n ? [\"pending\", \"running\"].includes(job.status)\n : job.status === statusFilter.value)) &&\n (!activeFilter.value ||\n /^[A-Za-z0-9_-]+:.+$/.test(activeFilter.value) ||\n `${workflowName(job.workflowId)} ${job.id} ${job.status} ${job.workerId || \"\"}`\n .toLowerCase()\n .includes(activeFilter.value.toLowerCase())),\n );\n const visibleJobs = () => filteredJobs().slice((page.value - 1) * pageSize, page.value * pageSize);\n const statusTabs = () => [\n { status: \"all\", label: \"All runs\", count: jobs.value.length },\n {\n status: \"running\",\n label: \"In progress\",\n count: jobs.value.filter((job) => [\"pending\", \"running\"].includes(job.status)).length,\n },\n {\n status: \"failed\",\n label: \"Failed\",\n count: jobs.value.filter((job) => job.status === \"failed\").length,\n },\n {\n status: \"success\",\n label: \"Passed\",\n count: jobs.value.filter((job) => job.status === \"success\").length,\n },\n ];\n const loadTimezone = async () => {\n const response = await apiFetch(\"/api/preferences\", { headers: { accept: \"application/json\" } });\n if (!response.ok) throw new Error(`Loading timezone preference failed: ${response.status}`);\n const preferences = await response.json();\n if (preferences.timezone) timezone.value = preferences.timezone;\n };\n const loadOptions = async () => {\n const load = async (url, key, target, getLabel) => {\n try {\n const response = await apiFetch(url, { headers: { accept: \"application/json\" } });\n if (!response.ok) return;\n const items = (await response.json())[key] || [];\n target.value = items.map((item) => ({ id: item.id || item.workerId, name: getLabel(item) }));\n } catch {\n /* Filters remain optional when scope is unavailable. */\n }\n };\n await Promise.all([\n load(\"/api/workflows\", \"workflows\", workflows, (item) => item.name || \"Workflow\"),\n load(\"/api/workers\", \"workers\", workers, (item) => item.workerId || \"Worker\"),\n ]);\n };\n const terminalStatuses = new Set([\"success\", \"failed\", \"cancelled\"]);\n const statusIcons = {\n success: \"circle-check\",\n failed: \"circle-x\",\n pending: \"clock\",\n running: \"loader-circle\",\n };\n const statusClass = (status) =>\n ({\n success: \"border-emerald-300 bg-emerald-100 text-emerald-800\",\n failed: \"border-rose-300 bg-rose-100 text-rose-800\",\n pending: \"border-amber-300 bg-amber-100 text-amber-900\",\n running: \"border-blue-300 bg-blue-100 text-blue-800\",\n cancelled: \"border-slate-300 bg-slate-100 text-slate-700\",\n })[status] || \"border-slate-300 bg-slate-100 text-slate-700\";\n const statusIcon = (status) => statusIcons[status] || \"circle-help\";\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || ![\"success\", \"failed\"].includes(job.status)) return;\n try {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n job.status === \"success\" ? `Job #${job.id} succeeded` : `Job #${job.id} failed`,\n {\n body: `${workflowName(job.workflowId)} finished with status ${job.status}.`,\n icon: \"/app-icon.svg\",\n badge: \"/app-icon.svg\",\n tag: `runner-job-${job.id}`,\n renotify: true,\n data: { url: `/runs/${job.id}` },\n },\n );\n } catch (error) {\n console.error(\"Unable to show job notification\", error);\n }\n };\n const urlBase64ToUint8Array = (value) => {\n const padding = \"=\".repeat((4 - (value.length % 4)) % 4);\n const raw = atob((value + padding).replace(/-/g, \"+\").replace(/_/g, \"/\"));\n return Uint8Array.from([...raw].map((character) => character.charCodeAt(0)));\n };\n const enablePushNotifications = async () => {\n try {\n if (!notificationsSupported.value) return;\n const response = await apiFetch(\"/api/push/public-key\", { headers: { accept: \"application/json\" } });\n if (!response.ok) return;\n const { publicKey } = await response.json();\n pushConfigured.value = true;\n const registration = await navigator.serviceWorker.ready;\n const subscription =\n (await registration.pushManager.getSubscription()) ||\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: urlBase64ToUint8Array(publicKey),\n }));\n const saved = await apiFetch(\"/api/push/subscriptions\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(subscription),\n });\n if (!saved.ok) throw new Error(`Push subscription failed: ${saved.status}`);\n } catch (error) {\n console.error(\"Unable to enable background notifications\", error);\n }\n };\n const disablePushNotifications = async () => {\n try {\n const subscription = await (await navigator.serviceWorker.ready).pushManager.getSubscription();\n if (!subscription) return;\n await apiFetch(\"/api/push/subscriptions\", {\n method: \"DELETE\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ endpoint: subscription.endpoint }),\n });\n await subscription.unsubscribe();\n } catch (error) {\n console.error(\"Unable to disable background notifications\", error);\n }\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem(\"runner-notifications\");\n await disablePushNotifications();\n notificationNotice.value = \"Job notifications are paused on this device.\";\n return;\n }\n if (Notification.permission === \"denied\") {\n notificationNotice.value = \"Notifications are blocked. Allow them in this site’s Android browser settings.\";\n return;\n }\n notificationsEnabled.value = (await Notification.requestPermission()) === \"granted\";\n if (notificationsEnabled.value) {\n localStorage.setItem(\"runner-notifications\", \"enabled\");\n await enablePushNotifications();\n notificationNotice.value =\n \"Notifications enabled. Background delivery is available when server push is configured.\";\n } else notificationNotice.value = \"Notification permission was not granted.\";\n };\n const refreshJobs = async (isSearch = false) => {\n if (searchInProgress && !isSearch) return;\n if (refreshing && !isSearch) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n const generation = refreshGeneration;\n try {\n const activeIds = jobs.value.filter((job) => !terminalStatuses.has(job.status)).map((job) => Number(job.id));\n const knownIds = jobs.value.map((job) => Number(job.id));\n const afterId = activeIds.length\n ? Math.max(0, Math.min(...activeIds) - 1)\n : knownIds.length\n ? Math.max(...knownIds)\n : null;\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : \"\";\n const workflowQuery = workflowFilter.value ? `&workflowId=${encodeURIComponent(workflowFilter.value)}` : \"\";\n const url =\n afterId === null\n ? `/api/jobs?limit=100${filterQuery}${workflowQuery}`\n : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}${workflowQuery}`;\n const response = await apiFetch(url, { headers: { accept: \"application/json\" } });\n if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n const data = await response.json();\n if (generation !== refreshGeneration) return;\n hasMore.value = data.hasMore;\n const previousStatuses = new Map(jobs.value.map((job) => [job.id, job.status]));\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) {\n const previousStatus = previousStatuses.get(job.id);\n merged.set(job.id, job);\n if (previousStatus && !terminalStatuses.has(previousStatus) && terminalStatuses.has(job.status))\n void notifyCompletedJob(job);\n }\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n refreshError.value = false;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n refreshing = false;\n if (refreshPending && !searchInProgress) {\n refreshPending = false;\n void refreshJobs();\n }\n }\n };\n const loadMore = async () => {\n if (loadingMore.value || !hasMore.value || !jobs.value.length) return;\n loadingMore.value = true;\n try {\n const beforeId = Math.min(...jobs.value.map((job) => Number(job.id)));\n const filterQuery = activeFilter.value ? `&filter=${encodeURIComponent(activeFilter.value)}` : \"\";\n const workflowQuery = workflowFilter.value ? `&workflowId=${encodeURIComponent(workflowFilter.value)}` : \"\";\n const response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}${workflowQuery}`, {\n headers: { accept: \"application/json\" },\n });\n if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`);\n const data = await response.json();\n const merged = new Map(jobs.value.map((job) => [job.id, job]));\n for (const job of data.jobs) merged.set(job.id, job);\n jobs.value = Array.from(merged.values()).sort((a, b) => Number(b.id) - Number(a.id));\n hasMore.value = data.hasMore;\n } catch (error) {\n console.error(error);\n refreshError.value = true;\n } finally {\n loadingMore.value = false;\n }\n };\n const updateSearchUrl = (value) => {\n const params = new URLSearchParams(window.location.search);\n if (value) params.set(\"search\", value);\n else params.delete(\"search\");\n if (workflowFilter.value) params.set(\"workflow\", workflowFilter.value);\n else params.delete(\"workflow\");\n const query = params.toString();\n history.pushState(null, \"\", `${window.location.pathname}${query ? `?${query}` : \"\"}`);\n };\n const applyFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\n page.value = 1;\n activeFilter.value = filter.value.trim();\n updateSearchUrl(activeFilter.value);\n jobs.value = [];\n hasMore.value = false;\n try {\n await refreshJobs(true);\n } finally {\n searchInProgress = false;\n refreshPending = false;\n }\n };\n const clearFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\n page.value = 1;\n filter.value = \"\";\n activeFilter.value = \"\";\n updateSearchUrl(\"\");\n jobs.value = [];\n hasMore.value = false;\n try {\n await refreshJobs(true);\n } finally {\n searchInProgress = false;\n refreshPending = false;\n }\n };\n const setFilter = (event) => {\n filter.value = event.target.value;\n };\n const setWorkflowFilter = async (event) => {\n searchInProgress = true;\n refreshGeneration++;\n page.value = 1;\n workflowFilter.value = event.target.value;\n updateSearchUrl(activeFilter.value);\n jobs.value = [];\n hasMore.value = false;\n try {\n await refreshJobs(true);\n } finally {\n searchInProgress = false;\n refreshPending = false;\n }\n };\n const setWorkerFilter = (event) => {\n page.value = 1;\n workerFilter.value = event.target.value;\n };\n const setStatusFilter = (status) => {\n page.value = 1;\n statusFilter.value = status;\n };\n const previousPage = () => {\n page.value = Math.max(1, page.value - 1);\n };\n const nextPage = async () => {\n const pageCount = Math.max(1, Math.ceil(filteredJobs().length / pageSize));\n if (page.value < pageCount) {\n page.value += 1;\n return;\n }\n if (!hasMore.value || loadingMore.value) return;\n const previousLength = jobs.value.length;\n await loadMore();\n if (jobs.value.length > previousLength) page.value += 1;\n };\n const hasNextPage = () => page.value < Math.ceil(filteredJobs().length / pageSize) || hasMore.value;\n onInit(() => {\n const params = new URLSearchParams(window.location.search);\n const initialSearch = params.get(\"search\")?.trim() || \"\";\n filter.value = initialSearch;\n activeFilter.value = initialSearch;\n workflowFilter.value = params.get(\"workflow\") || \"\";\n void loadOptions();\n void loadTimezone().catch((error) => console.error(\"Unable to load timezone preference\", error));\n if (notificationsSupported.value)\n navigator.serviceWorker\n .register(\"/service-worker.js\")\n .then(() => {\n if (notificationsEnabled.value) void enablePushNotifications();\n })\n .catch((error) => console.error(\"Service worker registration failed\", error));\n eventSource = new EventSource(\"/api/events\");\n eventSource.addEventListener(\"jobs.available\", refreshJobs);\n eventSource.addEventListener(\"jobs.changed\", refreshJobs);\n eventSource.onerror = () => {\n refreshError.value = true;\n };\n void refreshJobs(true);\n refreshTimer = setInterval(refreshJobs, 60000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n });\n return {\n jobs,\n visibleJobs,\n statusTabs,\n workflows,\n workers,\n workflowFilter,\n workerFilter,\n statusFilter,\n page,\n hasNextPage,\n refreshError,\n hasMore,\n loadingMore,\n filter,\n activeFilter,\n pushConfigured,\n setFilter,\n setWorkflowFilter,\n setWorkerFilter,\n setStatusFilter,\n workflowName,\n formatDate,\n statusClass,\n statusIcon,\n loadMore,\n previousPage,\n nextPage,\n applyFilter,\n clearFilter,\n upper,\n notificationsSupported,\n notificationsEnabled,\n notificationNotice,\n toggleNotifications,\n };\n}\n", lg = "\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '@app/api-client.mjs';\n import { formatTimestampedLogLine } from '@app/timezone-format.mjs';\n\n export default function setup() {\n const jobId = window.location.pathname.split('/').filter(Boolean).pop() || '';\n const report = ref({ jobId });\n const previousRuns = ref([]);\n const aiMessages = ref([]);\n const aiStepId = ref('');\n const aiQuestion = ref('');\n const aiError = ref('');\n const aiLoading = ref(false);\n const manualRestartOpen = ref(false);\n const manualRestartLoading = ref(false);\n const manualInputs = ref([]);\n let previousRunsLoaded = false;\n const step = ref(0);\n const detailTab = ref('logs');\n const now = ref(Date.now());\n const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');\n const ansiUp = new AnsiUp();\n ansiUp.use_classes = false;\n let eventSource;\n let refreshTimer;\n let clockTimer;\n let refreshing = false;\n let refreshPending = false;\n const cancelling = ref(false);\n\n const active = computed(() => ['pending', 'running'].includes(report.value.status));\n const inputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const originalInputsJson = computed(() => JSON.stringify(report.value.inputs || {}, null, 2));\n const timing = computed(() => {\n if (report.value.status === 'pending') return 'Waiting for a worker';\n if (report.value.status === 'running') {\n return `Running for ${Math.max(0, now.value - Date.parse(report.value.startedAt))}ms`;\n }\n return `Finished in ${report.value.durationMs}ms`;\n });\n\n const upper = (value) => String(value || '').toUpperCase();\n const formatDate = (value) => {\n if (!value) return '—';\n const text = String(value);\n const date = new Date(text.includes('T') ? text : `${text.replace(' ', 'T')}Z`);\n if (Number.isNaN(date.getTime())) return '—';\n return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short', timeZone: timezone.value }).format(date);\n };\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) return '—';\n const seconds = Math.floor(value / 1000);\n return `${seconds > 0 ? `${seconds}m ` : ''}${seconds % 60}s`;\n };\n const formatStepCount = () => {\n const successful = report.steps?.filter((s) => s.status === 'success').length || 0;\n const total = report.steps?.length || 0;\n return `${successful} / ${total}`;\n };\n const loadTimezone = async () => {\n const response = await apiFetch('/api/preferences', { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Loading timezone preference failed: ${response.status}`);\n const preferences = await response.json();\n if (preferences.timezone) timezone.value = preferences.timezone;\n };\n const artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await apiFetch(artifactUrl(path));\n if (!response.ok) return;\n const link = document.createElement('a');\n link.href = URL.createObjectURL(await response.blob());\n link.download = path.split('/').pop() || 'artifact';\n link.click();\n URL.revokeObjectURL(link.href);\n };\n const loadPreviousRuns = async () => {\n if (previousRunsLoaded || !report.value.parentId) return;\n const lineage = [];\n const visited = new Set();\n let parentId = report.value.parentId;\n while (parentId && !visited.has(String(parentId)) && lineage.length < 50) {\n visited.add(String(parentId));\n const response = await apiFetch(`/api/runs/${parentId}`, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Previous run request failed: ${response.status}`);\n const parent = await response.json();\n lineage.push({ id: parent.jobId, status: parent.status, startedAt: parent.startedAt });\n parentId = parent.parentId;\n }\n previousRuns.value = lineage;\n previousRunsLoaded = true;\n };\n const stepLog = (step) => {\n if (step.status === 'skipped') return '';\n if (step.status === 'running')\n return '<span class=\"text-indigo-400\">Step is running. Logs will appear after it finishes.</span>';\n if (step.status === 'pending') return '<span class=\"text-gray-500\">Waiting to run.</span>';\n if (step.logContent) {\n const localized = step.logContent.split('\\n').map((line) => formatTimestampedLogLine(line, timezone.value)).join('\\n');\n return ansiUp.ansi_to_html(localized);\n }\n return '<span class=\"text-gray-500\">(No terminal log output recorded for this step)</span>';\n };\n const sanitizeAiHtml = (html) => {\n const parsed = new DOMParser().parseFromString(html, 'text/html');\n parsed.querySelectorAll('script,style,iframe,object,embed,form').forEach((node) => node.remove());\n parsed.querySelectorAll('*').forEach((element) => {\n [...element.attributes].forEach((attribute) => {\n if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name);\n if (['href', 'src'].includes(attribute.name.toLowerCase()) && /^(javascript|data):/i.test(attribute.value)) element.removeAttribute(attribute.name);\n });\n });\n return parsed.body.innerHTML;\n };\n const renderAiMarkdown = (content) => sanitizeAiHtml(marked.parse(content));\n const askAi = async (step, event) => {\n event.stopPropagation();\n aiStepId.value = step.id;\n aiMessages.value = [];\n aiQuestion.value = '';\n await requestAiHelp(step.id, 'Please explain why this step failed and what I should do next.');\n };\n const requestAiHelp = async (stepId, question) => {\n aiLoading.value = true;\n aiError.value = '';\n try {\n const response = await apiFetch(`/api/runs/${report.value.jobId}/ai-help`, {\n method: 'POST',\n headers: { accept: 'application/json', 'content-type': 'application/json' },\n body: JSON.stringify({ stepId, question, conversation: aiMessages.value }),\n });\n if (!response.ok) {\n const data = await response.json().catch(() => ({}));\n throw new Error(data.error || `AI help failed: ${response.status}`);\n }\n aiMessages.value = [...aiMessages.value, { role: 'user', content: question }, { role: 'assistant', content: '' }];\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split('\\n\\n');\n buffer = events.pop() || '';\n for (const event of events) {\n const line = event.split('\\n').find((value) => value.startsWith('data: '));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.delta) {\n const messages = [...aiMessages.value];\n messages[messages.length - 1] = { ...messages[messages.length - 1], content: messages[messages.length - 1].content + data.delta };\n aiMessages.value = messages;\n }\n if (data.error) throw new Error(data.error);\n }\n }\n const messages = [...aiMessages.value];\n const answer = messages.at(-1);\n if (answer) messages[messages.length - 1] = { ...answer, html: renderAiMarkdown(answer.content) };\n aiMessages.value = messages;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const askFollowup = async () => {\n const question = aiQuestion.value.trim();\n if (!question || !aiStepId.value) return;\n aiQuestion.value = '';\n await requestAiHelp(aiStepId.value, question);\n };\n const restartJob = async () => {\n const response = await apiFetch(`/restart/${report.value.jobId}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ inputs: report.value.inputs || {} }),\n });\n if (response.ok) {\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n }\n };\n const openManualRestart = (event) => {\n event.preventDefault();\n event.stopPropagation();\n manualInputs.value = [{ key: '', value: '' }];\n manualRestartOpen.value = true;\n };\n const closeManualRestart = () => { manualRestartOpen.value = false; };\n const addManualInput = () => { manualInputs.value = [...manualInputs.value, { key: '', value: '' }]; };\n const removeManualInput = (entry) => { manualInputs.value = manualInputs.value.filter((item) => item !== entry); };\n const setManualKey = (entry, event) => { entry.key = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const setManualValue = (entry, event) => { entry.value = event.target.value; manualInputs.value = [...manualInputs.value]; };\n const restartWithInputs = async () => {\n const inputs = Object.fromEntries(manualInputs.value.filter((entry) => entry.key.trim()).map((entry) => [entry.key.trim(), entry.value]));\n manualRestartLoading.value = true;\n try {\n const response = await apiFetch(`/restart/${report.value.jobId}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ inputs }) });\n if (!response.ok) throw new Error(`Restart failed: ${response.status}`);\n const { id } = await response.json();\n location.href = `/runs/${id}`;\n } catch (error) {\n aiError.value = error.message;\n } finally {\n manualRestartLoading.value = false;\n }\n };\n const cancelJob = async () => {\n if (cancelling.value || report.value.status !== 'running') return;\n cancelling.value = true;\n try {\n const response = await apiFetch(`/api/jobs/${report.value.jobId}/cancel`, { method: 'POST' });\n if (!response.ok) throw new Error(`Cancel failed: ${response.status}`);\n await refreshRun();\n } catch (error) {\n console.error(error);\n } finally {\n cancelling.value = false;\n }\n };\n const refreshRun = async () => {\n if (refreshing) {\n refreshPending = true;\n return;\n }\n refreshing = true;\n\n try {\n const response = await apiFetch(`/api/runs/${jobId}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Run refresh failed: ${response.status}`);\n const previousStatus = report.value.status;\n const nextReport = await response.json();\n report.value = nextReport;\n document.title = `Run #${nextReport.jobId} - ${nextReport.workflowName}`;\n void loadPreviousRuns();\n if (\n ['pending', 'running'].includes(previousStatus) &&\n ['success', 'failed'].includes(nextReport.status) &&\n 'Notification' in window &&\n 'serviceWorker' in navigator &&\n Notification.permission === 'granted' &&\n localStorage.getItem('runner-notifications') === 'enabled'\n ) {\n const registration = await navigator.serviceWorker.ready;\n await registration.showNotification(\n nextReport.status === 'success'\n ? `Job #${nextReport.jobId} succeeded`\n : `Job #${nextReport.jobId} failed`,\n {\n body: `${nextReport.workflowName} finished with status ${nextReport.status}.`,\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n tag: `runner-job-${nextReport.jobId}`,\n renotify: true,\n data: { url: `/runs/${nextReport.jobId}` },\n },\n );\n }\n } catch (error) {\n console.error(error);\n } finally {\n refreshing = false;\n if (refreshPending) {\n refreshPending = false;\n void refreshRun();\n void loadTimezone().catch((error) => console.error('Unable to load timezone preference', error));\n }\n }\n };\n const handleJobChange = (event) => {\n const data = JSON.parse(event.data || '{}');\n if (!data.jobId || String(data.jobId) === String(report.value.jobId)) void refreshRun();\n };\n\n onInit(() => {\n void refreshRun();\n if ('serviceWorker' in navigator) {\n navigator.serviceWorker.register('/service-worker.js').catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\n eventSource = new EventSource('/api/events');\n eventSource.addEventListener('jobs.changed', handleJobChange);\n refreshTimer = setInterval(() => {\n if (active.value) void refreshRun();\n }, 60000);\n clockTimer = setInterval(() => {\n now.value = Date.now();\n }, 1000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n clearInterval(clockTimer);\n });\n\n return { report, previousRuns, manualRestartOpen, manualRestartLoading, manualInputs, aiMessages, aiStepId, aiQuestion, aiError, aiLoading, inputsJson, originalInputsJson, timing, upper, timezone, formatDate, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling,\n step,\n detailTab };\n }\n \n", ug = "import { onInit, ref } from \"@li3/web\";\nimport { apiFetch } from \"@app/api-client.mjs\";\n\nexport default function () {\n const page = \"settings\";\n const keys = ref([]);\n const workers = ref([]);\n const workersForbidden = ref(false);\n const users = ref([]);\n const usersForbidden = ref(false);\n const keyName = ref(\"\");\n const newToken = ref(\"\");\n const error = ref(\"\");\n const timezone = ref(Intl.DateTimeFormat().resolvedOptions().timeZone || \"UTC\");\n const preferenceMessage = ref(\"\");\n const timezones = ref(\n typeof Intl.supportedValuesOf === \"function\"\n ? [\"UTC\", ...Intl.supportedValuesOf(\"timeZone\")]\n : [\"UTC\", timezone.value],\n );\n const notificationsSupported = ref(\"Notification\" in window && \"serviceWorker\" in navigator);\n const notificationsEnabled = ref(\n notificationsSupported.value &&\n Notification.permission === \"granted\" &&\n localStorage.getItem(\"runner-notifications\") === \"enabled\",\n );\n const notificationMessage = ref(\"\");\n const scopeOptions = ref([\n { name: \"workflows:read\", label: \"Read workflows\", selected: true },\n { name: \"workflows:write\", label: \"Modify workflows\", selected: false },\n { name: \"logs:read\", label: \"Read logs\", selected: false },\n { name: \"artifacts:read\", label: \"Read artifacts\", selected: false },\n { name: \"runs:control\", label: \"Control runs\", selected: false },\n { name: \"runs:dispatch\", label: \"Dispatch runs\", selected: false },\n { name: \"workers:read\", label: \"Read workers\", selected: false },\n { name: \"secrets:read\", label: \"Read secret names\", selected: false },\n { name: \"secrets:write\", label: \"Manage secrets\", selected: false },\n ]);\n const secrets = ref([]);\n const secretName = ref(\"\");\n const secretValue = ref(\"\");\n const fileMode = ref(false);\n const secretFileName = ref(\"\");\n const secretFileData = ref(\"\");\n const busy = ref(false);\n const api = async (url, options = {}) => {\n const response = await apiFetch(url, {\n headers: { accept: \"application/json\", ...(options.body ? { \"content-type\": \"application/json\" } : {}) },\n ...options,\n });\n const body = await response.json().catch(() => ({}));\n if (!response.ok) {\n const reason = new Error(body.error || `Request failed: ${response.status}`);\n reason.status = response.status;\n throw reason;\n }\n return body;\n };\n const load = async () => {\n keys.value = (await api(\"/api/api-keys\")).keys;\n };\n const loadSecrets = async () => {\n secrets.value = (await api(\"/api/secrets\")).secrets;\n };\n const runSecretAction = async (action) => {\n if (busy.value) return;\n busy.value = true;\n error.value = \"\";\n try {\n await action();\n } catch (reason) {\n error.value = reason.message;\n } finally {\n busy.value = false;\n }\n };\n const saveSecret = () =>\n runSecretAction(async () => {\n const name = secretName.value.trim().toUpperCase();\n if (!/^[A-Z][A-Z0-9_]*$/.test(name))\n throw new Error(\"Secret names must start with a letter and contain only letters, numbers, and underscores.\");\n if (fileMode.value ? !secretFileData.value : !secretValue.value)\n throw new Error(fileMode.value ? \"Choose a file first.\" : \"Set a secret value first.\");\n await api(`/api/secrets/${encodeURIComponent(name)}`, {\n method: \"PUT\",\n body: JSON.stringify(\n fileMode.value\n ? { value: secretFileData.value, encoding: \"base64\", originalName: secretFileName.value }\n : { value: secretValue.value, encoding: \"utf8\" },\n ),\n });\n secretName.value = \"\";\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n await loadSecrets();\n });\n const removeSecret = (name) => {\n if (!confirm(`Delete ${name}?`)) return;\n return runSecretAction(async () => {\n await api(`/api/secrets/${encodeURIComponent(name)}`, { method: \"DELETE\" });\n await loadSecrets();\n });\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = \"\";\n for (let index = 0; index < bytes.length; index += 0x8000)\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n };\n const selectSecret = (name) => {\n secretName.value = name;\n fileMode.value = false;\n secretValue.value = \"\";\n };\n const loadWorkers = async () => {\n try {\n workers.value = (await api(\"/api/workers\")).workers;\n } catch (reason) {\n if (reason.status === 403) {\n workersForbidden.value = true;\n return;\n }\n throw reason;\n }\n };\n const loadUsers = async () => {\n try {\n users.value = (await api(\"/api/users\")).users;\n } catch (reason) {\n if (reason.status === 403) {\n usersForbidden.value = true;\n return;\n }\n throw reason;\n }\n };\n const loadPreferences = async () => {\n const preferences = await api(\"/api/preferences\");\n if (preferences.timezone) {\n timezone.value = preferences.timezone;\n if (!timezones.value.includes(preferences.timezone)) {\n timezones.value = [...timezones.value, preferences.timezone];\n }\n }\n };\n const formatTimestamp = (value) => {\n if (!value) {\n return \"—\";\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n if (Number.isNaN(date.getTime())) {\n return value;\n }\n return new Intl.DateTimeFormat(undefined, {\n dateStyle: \"medium\",\n timeStyle: \"short\",\n timeZone: timezone.value,\n }).format(date);\n };\n const saveTimezone = async (event) => {\n const previousTimezone = timezone.value;\n timezone.value = event.target.value;\n preferenceMessage.value = \"\";\n try {\n await api(\"/api/preferences\", { method: \"PUT\", body: JSON.stringify({ timezone: timezone.value }) });\n preferenceMessage.value = \"Timezone saved.\";\n } catch (reason) {\n timezone.value = previousTimezone;\n error.value = reason.message;\n }\n };\n const issueKey = async () => {\n try {\n error.value = \"\";\n const result = await api(\"/api/api-keys\", {\n method: \"POST\",\n body: JSON.stringify({\n name: keyName.value,\n scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name),\n }),\n });\n newToken.value = result.token;\n keyName.value = \"\";\n await load();\n } catch (reason) {\n error.value = reason.message;\n }\n };\n const revokeKey = async (key) => {\n if (!confirm(`Revoke ${key.name}?`)) {\n return;\n }\n try {\n await api(`/api/api-keys/${key.id}`, { method: \"DELETE\" });\n await load();\n } catch (reason) {\n error.value = reason.message;\n }\n };\n const toggleRole = async (user) => {\n const role = user.role === \"admin\" ? \"user\" : \"admin\";\n if (!confirm(`Change ${user.name || user.email || user.id} to ${role}?`)) {\n return;\n }\n error.value = \"\";\n try {\n await api(`/api/users/${encodeURIComponent(user.id)}/role`, { method: \"PUT\", body: JSON.stringify({ role }) });\n await loadUsers();\n } catch (reason) {\n error.value = reason.status === 403 ? \"Only administrators can change user roles.\" : reason.message;\n }\n };\n const setKeyName = (event) => {\n keyName.value = event.target.value;\n };\n const toggleScope = (scope) => {\n scope.selected = !scope.selected;\n };\n const toggleNotifications = async () => {\n if (notificationsEnabled.value) {\n notificationsEnabled.value = false;\n localStorage.removeItem(\"runner-notifications\");\n notificationMessage.value = \"Notifications disabled on this device.\";\n return;\n }\n if (!notificationsSupported.value) {\n notificationMessage.value = \"Notifications are not supported by this browser.\";\n return;\n }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === \"granted\";\n if (notificationsEnabled.value) {\n localStorage.setItem(\"runner-notifications\", \"enabled\");\n notificationMessage.value = \"Notifications enabled.\";\n } else {\n notificationMessage.value = \"Notification permission was not granted.\";\n }\n };\n onInit(() => {\n loadPreferences().catch((reason) => {\n error.value = reason.message;\n });\n load().catch((reason) => {\n error.value = reason.message;\n });\n loadSecrets().catch((reason) => {\n error.value = reason.message;\n });\n loadWorkers().catch((reason) => {\n error.value = reason.message;\n });\n loadUsers().catch((reason) => {\n error.value = reason.message;\n });\n });\n return {\n page,\n secrets,\n secretName,\n secretValue,\n fileMode,\n busy,\n saveSecret,\n removeSecret,\n setSecretFile,\n setSecretName,\n setSecretValue,\n setFileMode,\n selectSecret,\n keys,\n workers,\n workersForbidden,\n users,\n usersForbidden,\n keyName,\n newToken,\n error,\n timezone,\n timezones,\n preferenceMessage,\n formatTimestamp,\n saveTimezone,\n scopeOptions,\n issueKey,\n revokeKey,\n setKeyName,\n toggleScope,\n toggleRole,\n notificationsEnabled,\n notificationMessage,\n toggleNotifications,\n };\n}\n", dg = "import { onDestroy, onInit, ref, templateRef } from \"@li3/web\";\nimport { diffLines } from \"diff\";\n\nexport default function () {\n const page = document.body.dataset.page;\n const params = new URLSearchParams(window.location.search);\n const workflowPath = window.location.pathname.match(/^\\/(?:settings\\/)?workflows\\/(new|[a-z0-9-]+)$/);\n const pathWorkflowId = workflowPath && workflowPath[1] !== \"new\" ? workflowPath[1] : \"\";\n const initialId = params.get(\"id\") || pathWorkflowId;\n const initialRevision = params.get(\"revision\") ? Number(params.get(\"revision\")) : null;\n const readOnly = Boolean(initialRevision);\n const workflows = ref([]);\n const secrets = ref([]);\n const source = ref(\"\");\n const validation = ref({ valid: true });\n const savedSource = ref(\"\");\n const revision = ref(null);\n const revisionDialogOpen = ref(false);\n const revisionNumber = ref(0);\n const revisionDiffHtml = ref(\"\");\n const revisionError = ref(\"\");\n const revisionLoading = ref(false);\n const aiDialogOpen = ref(false);\n const aiRequest = ref(\"\");\n const aiLoading = ref(false);\n const aiError = ref(\"\");\n const aiSuggestion = ref(\"\");\n const aiDiffHtml = ref(\"\");\n const workflowId = ref(initialId || `wf-${crypto.randomUUID()}`);\n const enabled = ref(true);\n const savedEnabled = ref(true);\n const selectedId = ref(\"\");\n const secretName = ref(\"\");\n const secretValue = ref(\"\");\n const fileMode = ref(false);\n const secretFileName = ref(\"\");\n const secretFileData = ref(\"\");\n const notice = ref(\"\");\n const noticeError = ref(false);\n const busy = ref(false);\n const secretValueInput = templateRef(\"secretValueInput\");\n const secretFileInput = templateRef(\"secretFileInput\");\n const secretForm = templateRef(\"secretForm\");\n const helpContent = templateRef(\"helpContent\");\n const helpLoaded = ref(false);\n const helpLoading = ref(false);\n let validationTimer;\n\n const showNotice = (message, error = false) => {\n notice.value = message;\n noticeError.value = error;\n };\n\n const redirectToLogin = () => {\n const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;\n window.location.assign(`/auth/login?url=${encodeURIComponent(returnTo)}`);\n };\n\n const api = async (url, options = {}) => {\n const response = await fetch(url, {\n ...options,\n credentials: \"same-origin\",\n headers: {\n accept: \"application/json\",\n ...(options.body ? { \"content-type\": \"application/json\" } : {}),\n ...(options.headers || {}),\n },\n });\n if (response.status === 401) redirectToLogin();\n const body = response.status === 204 ? null : await response.json().catch(() => ({}));\n if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`);\n return body;\n };\n\n const run = async (action) => {\n if (busy.value) return;\n\n busy.value = true;\n\n try {\n await action();\n } catch (error) {\n showNotice(error.message, true);\n } finally {\n busy.value = false;\n }\n };\n\n const loadWorkflows = async () => {\n workflows.value = (await api(\"/api/workflows\")).workflows;\n };\n\n const loadSecrets = async () => {\n secrets.value = (await api(\"/api/secrets\")).secrets;\n };\n\n const openWorkflow = async (id) => {\n const workflowUrl = initialRevision ? `/api/workflows/${id}?revision=${initialRevision}` : `/api/workflows/${id}`;\n const workflow = await api(workflowUrl);\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n source.value = workflow.sourceYaml;\n savedSource.value = workflow.sourceYaml;\n enabled.value = workflow.enabled;\n savedEnabled.value = workflow.enabled;\n revision.value = workflow.revision || null;\n };\n\n const validateSource = async () => {\n return api(\"/api/workflows/validate\", {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source.value }),\n });\n };\n\n const validate = (ms = 1000) => {\n if (page !== \"editor\") return;\n\n clearTimeout(validationTimer);\n validationTimer = setTimeout(() => {\n validation.value = { ...validation.value, running: true, error: \"\" };\n void validateSource()\n .then((result) => {\n validation.value = { ...result, running: false };\n })\n .catch((error) => {\n validation.value = { valid: false, running: false, error: error.message };\n });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = selectedId.value || workflowId.value;\n if (\n initialId &&\n id === selectedId.value &&\n source.value === savedSource.value &&\n enabled.value === savedEnabled.value\n ) {\n showNotice(\"No changes to save.\");\n return;\n }\n const workflow = await api(`/api/workflows/${id}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source.value, enabled: enabled.value }),\n });\n selectedId.value = workflow.id;\n workflowId.value = workflow.id;\n savedSource.value = source.value;\n savedEnabled.value = enabled.value;\n revision.value = workflow.revision;\n showNotice(`Saved ${workflow.id} as draft revision ${workflow.revision}.`);\n if (!initialId) history.replaceState(null, \"\", `/workflows/${workflow.id}`);\n });\n const publish = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error(\"Save a workflow before publishing it.\");\n const workflow = await api(`/api/workflows/${id}/publish`, { method: \"POST\" });\n selectedId.value = workflow.id;\n revision.value = workflow.revision;\n showNotice(`Published ${workflow.id} revision ${workflow.revision}.`);\n });\n const runNow = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id) throw new Error(\"Save the workflow before running it.\");\n if (source.value !== savedSource.value) throw new Error(\"Save the latest draft before running it.\");\n const result = await api(`/api/workflows/${id}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showNotice(\n `Queued ${result.jobs.length} job${result.jobs.length === 1 ? \"\" : \"s\"} from revision ${result.revision}.`,\n );\n });\n const remove = () =>\n run(async () => {\n const id = workflowId.value.trim() || selectedId.value;\n if (!id || !confirm(`Delete ${id} and all of its revisions?`)) return;\n await api(`/api/workflows/${id}`, { method: \"DELETE\" });\n window.location.href = \"/workflows\";\n });\n const saveSecret = () =>\n run(async () => {\n const name = secretName.value.trim().toUpperCase();\n if (!/^[A-Z][A-Z0-9_]*$/.test(name))\n throw new Error(\"Secret names must start with a letter and contain only letters, numbers, and underscores.\");\n secretName.value = name;\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value))\n throw new Error(fileMode.value ? \"Choose a file first.\" : \"Set a secret value first.\");\n await api(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify(\n fileMode.value\n ? { value: secretFileData.value, encoding: \"base64\", originalName: secretFileName.value }\n : { value: secretValue.value, encoding: \"utf8\" },\n ),\n });\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n if (secretForm.value) secretForm.value.open = false;\n await loadSecrets();\n showNotice(`Saved ${name}.`);\n });\n const removeSecret = (requestedName = \"\") =>\n run(async () => {\n const name = (requestedName || secretName.value.trim()).toUpperCase();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: \"DELETE\" });\n if (secretName.value.trim().toUpperCase() === name) {\n secretName.value = \"\";\n secretValue.value = \"\";\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n fileMode.value = false;\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n }\n await loadSecrets();\n showNotice(`Deleted ${name}.`);\n });\n const setSource = (value) => {\n source.value = value;\n validate();\n };\n const setEnabled = (event) => {\n enabled.value = event.target.checked;\n };\n const setSecretName = (event) => {\n secretName.value = event.target.value;\n };\n const setSecretValue = (event) => {\n secretValue.value = event.target.value;\n fileMode.value = false;\n if (secretValue.value) {\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n }\n };\n const setFileMode = (event) => {\n fileMode.value = event.target.checked;\n if (!fileMode.value) {\n secretFileName.value = \"\";\n secretFileData.value = \"\";\n if (secretFileInput.value) secretFileInput.value.value = \"\";\n } else {\n secretValue.value = \"\";\n }\n };\n const setSecretFile = async (event) => {\n const file = event.target.files?.[0];\n if (!file) return;\n const bytes = new Uint8Array(await file.arrayBuffer());\n let binary = \"\";\n for (let index = 0; index < bytes.length; index += 0x8000) {\n binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));\n }\n secretFileName.value = file.name;\n secretFileData.value = btoa(binary);\n secretValue.value = \"\";\n };\n const selectSecret = (name) => {\n secretName.value = name;\n secretValueInput.value?.focus();\n };\n const visitWorkflow = (id) => {\n window.location.href = `/workflows/${id}`;\n };\n const loadHelp = async (event) => {\n if (!event.target.open || helpLoaded.value || helpLoading.value || !helpContent.value) return;\n helpLoading.value = true;\n helpContent.value.textContent = \"Loading workflow syntax help...\";\n try {\n const response = await fetch(\"/help?embed=1\", { headers: { accept: \"text/html\" } });\n if (!response.ok) throw new Error(`Help request failed: ${response.status}`);\n const html = await response.text();\n const parsed = new DOMParser().parseFromString(html, \"text/html\");\n const sourceNodes = [...Array.from(parsed.head.querySelectorAll(\"style\")), ...Array.from(parsed.body.childNodes)];\n const nodes = sourceNodes.map((node) => document.importNode(node, true));\n helpContent.value.replaceChildren(...nodes);\n helpLoaded.value = true;\n } catch (error) {\n helpContent.value.textContent = \"Unable to load workflow syntax help.\";\n console.error(error);\n } finally {\n helpLoading.value = false;\n }\n };\n\n const escapeRevisionHtml = (value) =>\n value.replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" })[character] || \"\",\n );\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null)\n return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource)\n .map((part) => {\n const className = part.added\n ? \"text-emerald-300 bg-emerald-500/10\"\n : part.removed\n ? \"text-rose-300 bg-rose-500/10\"\n : \"text-gray-300\";\n const prefix = part.added ? \"+ \" : part.removed ? \"- \" : \" \";\n return part.value\n .split(\"\\n\")\n .filter((line, index, lines) => index < lines.length - 1 || line)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`)\n .join(\"\");\n })\n .join(\"\");\n };\n const openRevisionDialog = async () => {\n if (!revision.value) return;\n revisionNumber.value = revision.value;\n revisionDiffHtml.value = \"\";\n revisionError.value = \"\";\n revisionDialogOpen.value = true;\n await loadRevisionDiff(revision.value);\n };\n const closeRevisionDialog = () => {\n revisionDialogOpen.value = false;\n };\n const loadRevisionDiff = async (number) => {\n revisionLoading.value = true;\n revisionError.value = \"\";\n try {\n const current = await api(`/api/workflows/${selectedId.value}?revision=${number}`);\n const previous = number > 1 ? await api(`/api/workflows/${selectedId.value}?revision=${number - 1}`) : null;\n revisionDiffHtml.value = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n } catch (error) {\n revisionError.value = error.message;\n } finally {\n revisionLoading.value = false;\n }\n };\n const navigateRevision = async (offset) => {\n if (!selectedId.value || revisionLoading.value) return;\n const target = revisionNumber.value + offset;\n if (target < 1 || target > revision.value) return;\n revisionNumber.value = target;\n await loadRevisionDiff(target);\n };\n const openAiHelp = () => {\n aiRequest.value = \"\";\n aiError.value = \"\";\n aiSuggestion.value = \"\";\n aiDiffHtml.value = \"\";\n aiDialogOpen.value = true;\n };\n const closeAiHelp = () => {\n if (!aiLoading.value) aiDialogOpen.value = false;\n };\n const requestAiHelp = async () => {\n if (!aiRequest.value.trim() || aiLoading.value) return;\n aiLoading.value = true;\n aiError.value = \"\";\n aiDiffHtml.value = \"\";\n try {\n const response = await fetch(\"/api/ai/workflow-help\", {\n method: \"POST\",\n credentials: \"same-origin\",\n headers: { accept: \"text/event-stream\", \"content-type\": \"application/json\" },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (response.status === 401) redirectToLogin();\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error || `AI help failed: ${response.status}`);\n }\n const reader = response.body.getReader();\n const decoder = new TextDecoder();\n let buffer = \"\";\n let suggestion = \"\";\n while (true) {\n const chunk = await reader.read();\n if (chunk.done) break;\n buffer += decoder.decode(chunk.value, { stream: true });\n const events = buffer.split(\"\\n\\n\");\n buffer = events.pop() || \"\";\n for (const event of events) {\n const line = event.split(\"\\n\").find((value) => value.startsWith(\"data: \"));\n if (!line) continue;\n const data = JSON.parse(line.slice(6));\n if (data.error) throw new Error(data.error);\n if (data.delta) suggestion += data.delta;\n }\n }\n suggestion = suggestion\n .replace(/^```(?:yaml|yml)?\\s*/i, \"\")\n .replace(/\\s*```$/i, \"\")\n .trim();\n if (!source.value.trim()) {\n source.value = suggestion;\n aiDialogOpen.value = false;\n validate();\n } else {\n aiSuggestion.value = suggestion;\n aiDiffHtml.value = renderRevisionDiff(source.value, suggestion);\n }\n } catch (error) {\n aiError.value = error.message;\n } finally {\n aiLoading.value = false;\n }\n };\n const acceptAiSuggestion = () => {\n source.value = aiSuggestion.value;\n aiDialogOpen.value = false;\n validate();\n };\n const rejectAiSuggestion = () => {\n aiSuggestion.value = \"\";\n aiDiffHtml.value = \"\";\n };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage =\n page === \"editor\"\n ? initialId\n ? openWorkflow(initialId)\n : Promise.resolve()\n : page === \"secrets\"\n ? loadSecrets()\n : loadWorkflows();\n loadPage.catch((error) => showNotice(error.message, true));\n });\n\n return {\n page,\n workflows,\n secrets,\n source,\n validation,\n revision,\n revisionDialogOpen,\n revisionNumber,\n revisionDiffHtml,\n revisionError,\n revisionLoading,\n aiDialogOpen,\n aiRequest,\n aiLoading,\n aiError,\n aiDiffHtml,\n workflowId,\n enabled,\n readOnly,\n selectedId,\n secretName,\n secretValue,\n fileMode,\n secretFileName,\n secretFileData,\n notice,\n noticeError,\n busy,\n validate,\n save,\n publish,\n runNow,\n remove,\n saveSecret,\n removeSecret,\n setSource,\n setEnabled,\n setSecretName,\n setSecretValue,\n setFileMode,\n setSecretFile,\n selectSecret,\n visitWorkflow,\n loadHelp,\n openRevisionDialog,\n closeRevisionDialog,\n navigateRevision,\n openAiHelp,\n closeAiHelp,\n requestAiHelp,\n acceptAiSuggestion,\n rejectAiSuggestion,\n };\n}\n", fg = "/* global document, window, localStorage, navigator, URL, Blob, setTimeout, clearTimeout */\n\nconst paths = {\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 chevron: '<path d=\"m9 6 6 6-6 6\"/>',\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 edit: '<path d=\"m16 5 3 3M4 20l4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10Z\"/><path d=\"M13.5 7.5 17 11\"/>',\n external: '<path d=\"M14 4h6v6m-11 3L20 4\"/><path d=\"M18 13v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h5\"/>',\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 keyboard:\n '<rect x=\"2\" y=\"5\" width=\"20\" height=\"14\" rx=\"2\"/><path d=\"M6 9h.01M10 9h.01M14 9h.01M18 9h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M7 15h10\"/>',\n close: '<path d=\"m6 6 12 12M6 18 18 6\"/>',\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 info: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 11v6m0-10h.01\"/>',\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 paths[name] || paths.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 ${status}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst badge = (status) => `<span class=\"status ${status}\">${statusIcon(status)}${labels[status]}</span>`;\nconst workflows = [\n {\n id: \"build-docker-image\",\n name: \"Build Docker image\",\n description: \"Build, test, and publish a container image to the registry.\",\n labels: [\"docker\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build image\", \"Push to registry\"],\n },\n {\n id: \"build-publish-npm\",\n name: \"Build & publish npm\",\n description: \"Validate packages and publish a new release to npm.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Build package\", \"Publish to npm\"],\n },\n {\n id: \"deploy-production\",\n name: \"Deploy production\",\n description: \"Release the latest application and verify service health.\",\n labels: [\"main-server\", \"linux\"],\n steps: [\"Checkout repository\", \"Pull image\", \"Verify configuration\", \"Deploy service\", \"Health check\"],\n },\n {\n id: \"quality-checks\",\n name: \"Quality checks\",\n description: \"Type checking, linting, and tests for every pull request.\",\n labels: [\"node\", \"linux\"],\n steps: [\"Checkout repository\", \"Install dependencies\", \"Run tests\", \"Type check\", \"Lint\"],\n },\n];\nconst seed = [\n [\"running\", 0, \"feat: add multi-platform builds\", \"feat/multi-platform\", \"a8f3c12\", 93, \"Just now\", \"on\"],\n [\"pending\", 1, \"chore: prepare September release\", \"main\", \"d72e8b1\", 0, \"1 min ago\", \"lithium\"],\n [\"failed\", 0, \"fix: resolve image build context\", \"main\", \"7bc91e4\", 142, \"4 min ago\", \"on\"],\n [\"success\", 2, \"feat: improve workflow navigation\", \"main\", \"f38ac02\", 68, \"12 min ago\", \"on\"],\n [\"success\", 1, \"fix: handle reconnect gracefully\", \"main\", \"c52d190\", 104, \"18 min ago\", \"sodium\"],\n [\"success\", 3, \"feat: add keyboard navigation\", \"feat/keyboard-nav\", \"8a12e6f\", 46, \"26 min ago\", \"lithium\"],\n [\"failed\", 1, \"chore: update dependency versions\", \"main\", \"e90b721\", 38, \"34 min ago\", \"lithium\"],\n [\"success\", 0, \"fix: restore health check endpoint\", \"main\", \"2a19d63\", 127, \"42 min ago\", \"auth\"],\n [\"success\", 1, \"feat: expose workflow metadata\", \"main\", \"b94d512\", 88, \"1 hr ago\", \"on\"],\n [\"success\", 3, \"test: cover retry boundaries\", \"fix/retries\", \"6fb841a\", 59, \"1 hr ago\", \"on\"],\n [\"cancelled\", 0, \"chore: rebuild base image\", \"main\", \"51ea273\", 17, \"2 hrs ago\", \"auth\"],\n [\"success\", 2, \"fix: improve session recovery\", \"main\", \"832df1a\", 62, \"2 hrs ago\", \"auth\"],\n [\"success\", 3, \"refactor: simplify component lifecycle\", \"main\", \"27d1c98\", 71, \"3 hrs ago\", \"lithium\"],\n [\"success\", 1, \"chore: release patch version\", \"main\", \"92dc128\", 96, \"3 hrs ago\", \"sodium\"],\n [\"success\", 0, \"feat: cache dependency layers\", \"main\", \"bd5201c\", 112, \"4 hrs ago\", \"on\"],\n [\"success\", 2, \"fix: persist worker labels\", \"main\", \"876da42\", 65, \"5 hrs ago\", \"on\"],\n];\nlet jobs = seed.map(([status, workflow, message, branch, commit, seconds, time, repo], index) => ({\n id: 2856 - index,\n status,\n workflow: workflows[workflow],\n message,\n branch,\n commit,\n seconds,\n time,\n repo,\n worker: status === \"pending\" ? null : \"alpha\",\n attempt: 1,\n parent: null,\n}));\nconst state = {\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n branch: \"all\",\n page: 1,\n step: null,\n detailTab: \"logs\",\n logQuery: \"\",\n wrap: false,\n};\nconst main = document.querySelector(\"#main\");\nconst dialog = document.querySelector(\"#dialog\");\nlet toastTimer;\nlet previousRoute = \"\";\n\nfunction duration(seconds) {\n if (!seconds) {\n return \"—\";\n }\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n}\nfunction toast(message) {\n const element = document.querySelector(\"#toast\");\n clearTimeout(toastTimer);\n element.textContent = message;\n element.hidden = false;\n toastTimer = setTimeout(() => {\n element.hidden = true;\n }, 4500);\n}\nfunction route() {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URL(`https://example.test/?${query}`).searchParams };\n}\nfunction currentJob() {\n return jobs.find((job) => job.id === Number(route().path.split(\"/\")[2]));\n}\nfunction updateFilterUrl() {\n const params = new URL(\"https://example.test\").searchParams;\n for (const key of [\"search\", \"status\", \"workflow\", \"branch\", \"page\"]) {\n if (state[key] !== \"\" && state[key] !== \"all\" && state[key] !== 1) {\n params.set(key, state[key]);\n }\n }\n window.history.replaceState(\n null,\n \"\",\n `${window.location.pathname}${window.location.search}#/runs${params.size ? `?${params}` : \"\"}`,\n );\n}\nfunction readFilters() {\n const { query } = route();\n state.search = query.get(\"search\") || \"\";\n state.status = [\"all\", \"running\", \"failed\", \"success\"].includes(query.get(\"status\")) ? query.get(\"status\") : \"all\";\n state.workflow = workflows.some((workflow) => workflow.id === query.get(\"workflow\")) ? query.get(\"workflow\") : \"all\";\n state.branch = [\"main\", \"feature\"].includes(query.get(\"branch\")) ? query.get(\"branch\") : \"all\";\n const page = Number(query.get(\"page\"));\n state.page = Number.isSafeInteger(page) && page > 0 ? page : 1;\n}\nfunction navigation(page) {\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, symbol]) =>\n `<a href=\"#/${id}\" class=\"nav-item flex items-center gap-[10px] rounded-md px-[14px] py-3 text-xs font-medium text-flow-muted hover:bg-flow-subtle max-[900px]:justify-center max-[900px]:px-[10px] max-[900px]:py-[13px] max-[600px]:flex-1 max-[600px]:flex-col max-[600px]:gap-1 max-[600px]:rounded-none max-[600px]:px-[6px] max-[600px]:pt-[9px] max-[600px]:pb-[6px] max-[600px]:text-[10px] ${id === page ? \"active bg-[#e8efdf] text-[#344d29] max-[600px]:bg-transparent max-[600px]:text-[#41602f]\" : \"\"}\" ${\n id === page ? 'aria-current=\"page\"' : \"\"\n } aria-label=\"${title}\">${icon(symbol).replace(\"<svg \", '<svg class=\"h-[17px] w-[17px] shrink-0 max-[600px]:h-[18px] max-[600px]:w-[18px]\" ')}<span class=\"nav-label max-[900px]:hidden max-[600px]:block\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count ml-auto rounded bg-[#e2e8dc] px-1.5 py-0.5 text-[13px] max-[900px]:hidden\">${\n jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`,\n )\n .join(\"\");\n document.title = `${page[0].toUpperCase() + page.slice(1)} · Flow`;\n}\nfunction runsPage() {\n main.innerHTML = `<div class=\"mb-7 flex justify-end\"><button class=\"button primary\" data-action=\"new-run\">${icon(\n \"plus\",\n )}Run workflow</button></div>\n <section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Runs <span class=\"count\">${\n jobs.length\n }</span></h2></div>\n <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, commits, or repositories…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label>\n <select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${workflows\n .map(\n (workflow) =>\n `<option value=\"${workflow.id}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${workflow.name}</option>`,\n )\n .join(\"\")}</select>\n <select id=\"branch-filter\" class=\"select-filter\" aria-label=\"Filter by branch\"><option value=\"all\">All branches</option><option value=\"main\" ${\n state.branch === \"main\" ? \"selected\" : \"\"\n }>main</option><option value=\"feature\" ${\n state.branch === \"feature\" ? \"selected\" : \"\"\n }>Feature branches</option></select></div>\n <div id=\"run-results\"></div></section>\n <div class=\"attention-strip\">${icon(\"alert\")}<p><strong>${\n jobs.filter((job) => job.status === \"failed\").length\n } failed runs</strong><span>A little attention goes a long way.</span></p><button class=\"text-button\" data-action=\"filter-failed\">Review failures ${icon(\n \"arrow\",\n )}</button></div>`;\n renderResults();\n}\nfunction filteredJobs(includeStatus = true) {\n return jobs.filter((job) => {\n const search = state.search.trim().toLowerCase();\n const qualifier = search.match(/^([a-z0-9_-]+):(.+)$/);\n const text = [job.id, job.message, job.workflow.id, job.workflow.name, job.repo, job.commit, job.branch]\n .join(\" \")\n .toLowerCase();\n const repository = String(job.repo || \"\").toLowerCase();\n const fullRepository = repository.includes(\"/\") ? repository : `cloud-cli/${repository}`;\n const owner = fullRepository.split(\"/\")[0];\n const qualifiedValue = qualifier\n ? qualifier[1] === \"owner\"\n ? owner\n : qualifier[1] === \"repo\"\n ? fullRepository\n : String(job.inputs?.[qualifier[1]] ?? job[qualifier[1]] ?? \"\").toLowerCase()\n : \"\";\n return (\n (qualifier ? qualifiedValue.includes(qualifier[2]) : text.includes(search)) &&\n (state.workflow === \"all\" || job.workflow.id === state.workflow) &&\n (state.branch === \"all\" || (state.branch === \"main\" ? job.branch === \"main\" : job.branch !== \"main\")) &&\n (!includeStatus ||\n state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status))\n );\n });\n}\nfunction renderResults() {\n const filtered = filteredJobs();\n const all = filteredJobs(false);\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(pages, state.page);\n const visible = filtered.slice((state.page - 1) * 8, state.page * 8);\n document.querySelector(\"#run-results\").innerHTML =\n `<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 all.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 visible.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Workflow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / commit</th><th scope=\"col\">Status</th><th scope=\"col\">Branch</th><th scope=\"col\">Duration</th><th scope=\"col\">Started</th></tr></thead><tbody>${visible\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${job.id}\" aria-label=\"Run ${job.id}: ${escape(\n job.workflow.name,\n )}, ${labels[job.status]}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${\n job.workflow.name\n }</span><span class=\"run-subtitle\"><span class=\"mono\">#${\n job.id\n }</span><span class=\"separator\">·</span><span>cloud-cli/${\n job.repo\n }</span><span class=\"separator\">·</span><span class=\"mono\">${\n job.commit\n }</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"branch\",\n )}${escape(job.branch)}</span></td><td><span class=\"mono duration\">${duration(\n job.seconds,\n )}</span></td><td>${job.time}</td></tr>`,\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\n \"search\",\n )}<h3>No runs found</h3><p>Try a different search or clear your filters.</p><button class=\"button\" data-action=\"clear-filters\">Clear filters</button></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 ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div><div class=\"table-hint\">${icon(\n \"info\",\n )}Open a run to explore its steps, logs, and execution history.</div>`;\n}\nfunction stepsFor(job) {\n return job.workflow.steps.map((name, index) => {\n let status = \"success\";\n if (job.status === \"failed\") {\n status = index < 2 ? \"success\" : index === 2 ? \"failed\" : \"skipped\";\n }\n if (job.status === \"running\") {\n status = index < 3 ? \"success\" : index === 3 ? \"running\" : \"pending\";\n }\n if (job.status === \"pending\") {\n status = \"pending\";\n }\n if (job.status === \"cancelled\") {\n status = index === 0 ? \"success\" : index === 1 ? \"cancelled\" : \"skipped\";\n }\n const seconds = [\"skipped\", \"pending\"].includes(status)\n ? 0\n : Math.round(job.seconds * [0.03, 0.24, 0.45, 0.2, 0.08][index]);\n return { name, status, seconds, index };\n });\n}\nfunction detailPage(job) {\n const steps = stepsFor(job);\n if (state.step === null) {\n state.step = Math.max(\n 0,\n steps.findIndex((step) => [\"failed\", \"running\"].includes(step.status)),\n );\n }\n const active = [\"running\", \"pending\"].includes(job.status);\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\n \"back\",\n )}All runs</a><div class=\"page-heading run-heading\"><div><h1>${statusIcon(job.status)}${\n job.workflow.name\n }</h1><p class=\"subtitle\"><span class=\"mono\">#${job.id}</span><span>·</span><span>${escape(\n job.message,\n )}</span></p></div><div class=\"heading-actions\"><button class=\"button\" data-action=\"download-logs\">${icon(\n \"download\",\n )}Download logs</button><button class=\"button ${active ? \"danger\" : \"primary\"}\" data-action=\"${\n active ? \"cancel-run\" : \"rerun\"\n }\">${icon(active ? \"stop\" : \"refresh\")}${active ? \"Cancel run\" : \"Re-run workflow\"}</button></div></div>\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n job.status,\n )}</span></div><div><span class=\"meta-label\">REPOSITORY / BRANCH</span><span class=\"meta-value\">${icon(\n \"branch\",\n )}${escape(job.branch)}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\n \"commit\",\n )}${job.commit}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\",\n )}${duration(job.seconds)}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\n \"server\",\n )}${job.worker || \"Awaiting worker\"}${job.worker ? '<span class=\"tag\">linux · x64</span>' : \"\"}</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}${\n value === \"history\" ? `<span class=\"count\">${job.attempt}</span>` : \"\"\n }</button>`,\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n renderDetailContent(job);\n}\nfunction renderDetailContent(job) {\n const content = document.querySelector(\"#detail-content\");\n if (state.detailTab === \"inputs\") {\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs <span class=\"tag\">${\n job.parent ? \"manual restart\" : \"push event\"\n }</span></h3><pre>${escape(\n JSON.stringify(\n {\n repository: `cloud-cli/${job.repo}`,\n ref: `refs/heads/${job.branch}`,\n commit: job.commit,\n workflow: job.workflow.id,\n triggered_by: job.parent ? \"user:jamie\" : \"webhook:github\",\n },\n null,\n 2,\n ),\n )}</pre></section><section class=\"source-view\" style=\"margin-top:20px\"><h3>Workflow source · revision 12</h3><pre>${escape(\n workflowSource(job.workflow),\n )}</pre></section>`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n content.innerHTML =\n job.status === \"success\"\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">1</span></h2><div class=\"setting-row\"><div><strong>${icon(\n \"box\",\n )} build-manifest.json</strong><p>Sample build metadata · JSON</p></div><button class=\"button\" data-action=\"artifact\">${icon(\n \"download\",\n )}Download</button></div></section>`\n : `<div class=\"empty-state\">${icon(\"box\")}<h3>No artifacts yet</h3><p>${\n activeStatus(job)\n ? \"Artifacts appear here when the run completes.\"\n : \"This run did not produce any artifacts.\"\n }</p></div>`;\n return;\n }\n if (state.detailTab === \"history\") {\n const history = [];\n let current = job;\n while (current) {\n history.push(current);\n current = jobs.find((candidate) => candidate.id === current.parent);\n }\n content.innerHTML = `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${entry.id}\">Run #${entry.id} · Attempt ${\n entry.attempt\n }</a></strong><p>${\n entry.parent ? `Re-run of #${entry.parent} · Triggered by Jamie` : \"Original run · Triggered by GitHub\"\n } · ${entry.time}</p></div>${badge(entry.status)}</div>`,\n )\n .join(\"\")}</section>`;\n return;\n }\n const steps = stepsFor(job);\n const selected = steps[state.step];\n content.innerHTML = `${\n job.status === \"failed\"\n ? `<div class=\"failure-banner\">${icon(\n \"alert\",\n )}<div><strong>Run tests failed with exit code 1</strong><p>One test failed. The remaining steps were skipped.</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\",\n )}</button></div>`\n : \"\"\n }\n <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) =>\n `<button class=\"step-button ${state.step === step.index ? \"active\" : \"\"}\" data-action=\"step\" data-value=\"${\n step.index\n }\" aria-pressed=\"${state.step === step.index}\" aria-label=\"${step.name}, ${labels[step.status]}\">${statusIcon(\n step.status,\n )}<span>${step.name}</span><span class=\"mono\">${duration(step.seconds)}</span></button>`,\n )\n .join(\"\")}<div class=\"step-count\">${icon(\"clock\")}Total duration ${duration(job.seconds)}</div></aside>\n <div class=\"log-panel\"><div class=\"log-heading\"><h3>${selected.name}<small>${\n selected.status === \"failed\" ? \"exit code 1\" : labels[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 }\" title=\"Wrap log lines\">${icon(\n \"wrap\",\n )}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\" title=\"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.logQuery,\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 ? \"Process exited with code 1\"\n : activeStatus(job)\n ? \"Sample execution output\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>\n <div class=\"detail-bottom\"><span>${icon(\"lock\")}Logs are visible to workspace members.</span><span>cloud-cli/${\n job.repo\n }<span>·</span>Workflow revision 12<span>·</span>Attempt ${job.attempt}</span></div>`;\n renderLogs(job);\n}\nfunction activeStatus(job) {\n return [\"pending\", \"running\"].includes(job.status);\n}\nfunction logsFor(job, index) {\n const step = stepsFor(job)[index];\n if (step.status === \"skipped\") {\n return [{ text: \"Step skipped because an earlier step did not complete.\", type: \"dim\" }];\n }\n if (step.status === \"pending\") {\n return [{ text: \"Waiting for a worker…\", type: \"dim\" }];\n }\n if (step.status === \"failed\") {\n return [\n [\"$ pnpm test\", \"\"],\n [\"> @cloud-cli/on test\", \"dim\"],\n [\"> vitest run\", \"dim\"],\n [\"\", \"\"],\n [\" RUN v4.1.11 /workspace/cloud-cli/on\", \"\"],\n [\"\", \"\"],\n [\" ✓ src/config.spec.ts (8 tests) 14ms\", \"pass\"],\n [\" ✓ src/queue.spec.ts (12 tests) 31ms\", \"pass\"],\n [\" ❯ src/worker.spec.ts (6 tests | 1 failed) 89ms\", \"error\"],\n [\" × worker > retries a failed job\", \"error\"],\n [\"\", \"\"],\n [\" FAIL src/worker.spec.ts > worker > retries a failed job\", \"error\"],\n [\" AssertionError: expected 2 to equal 3\", \"error\"],\n [\"\", \"\"],\n [\" Expected: 3\", \"pass\"],\n [\" Received: 2\", \"error\"],\n [\"\", \"\"],\n [\" ❯ src/worker.spec.ts:142:28\", \"\"],\n [\" 140| await worker.retry(job);\", \"dim\"],\n [\" 141|\", \"dim\"],\n [\" 142| expect(job.attempts).toBe(3);\", \"error\"],\n [\" | ^\", \"error\"],\n [\"\", \"\"],\n [\" Test Files 1 failed | 2 passed (3)\", \"\"],\n [\" Tests 1 failed | 25 passed (26)\", \"\"],\n [\" Duration 1.42s\", \"dim\"],\n [\"\", \"\"],\n [\" ELIFECYCLE Test failed. See above for more details.\", \"error\"],\n ].map(([text, type]) => ({ text, type }));\n }\n const logs =\n index === 0\n ? [\n `$ git clone https://github.com/cloud-cli/${job.repo}.git .`,\n \"Cloning into '.'...\",\n \"remote: Enumerating objects: 184, done.\",\n \"remote: Compressing objects: 100% (92/92), done.\",\n \"Receiving objects: 100% (184/184), 148.2 KiB | 3.1 MiB/s, done.\",\n `$ git checkout ${job.commit}`,\n `HEAD is now at ${job.commit} ${job.message}`,\n \"\",\n \"✓ Repository ready.\",\n ]\n : index === 1\n ? [\n \"$ pnpm install --frozen-lockfile\",\n \"Lockfile is up to date, resolution step is skipped\",\n \"Progress: resolved 248, reused 248, downloaded 0, added 248\",\n \"\",\n \"✓ Dependencies installed from cache.\",\n \"Done in 2.4s.\",\n ]\n : index === 2\n ? [\n \"$ pnpm test\",\n \" RUN v4.1.11 /workspace\",\n \"\",\n \" ✓ src/config.spec.ts (8 tests)\",\n \" ✓ src/queue.spec.ts (12 tests)\",\n \" ✓ src/worker.spec.ts (6 tests)\",\n \"\",\n \" Test Files 3 passed (3)\",\n \" Tests 26 passed (26)\",\n \"\",\n \"✓ All tests passed.\",\n ]\n : [\n `$ ${job.workflow.id === \"build-docker-image\" ? \"docker build --tag app:latest .\" : \"pnpm build\"}`,\n \"#1 Loading build configuration\",\n \"#1 DONE 0.1s\",\n \"#2 Resolving dependencies\",\n \"#2 CACHED\",\n \"#3 Compiling source files\",\n \"#3 DONE 1.3s\",\n \"#4 Creating production bundle\",\n ...(step.status === \"running\"\n ? [\"#4 Building…\"]\n : [\"#4 DONE 0.8s\", \"\", \"✓ Step completed successfully.\"]),\n ];\n if (step.status === \"cancelled\") {\n logs.push(\"Run cancelled by Jamie.\");\n }\n return logs.map((text) => ({ text, type: text.startsWith(\"✓\") || text.includes(\" passed\") ? \"pass\" : \"\" }));\n}\nfunction renderLogs(job) {\n const logs = logsFor(job, state.step);\n const query = state.logQuery.toLowerCase();\n let matches = 0;\n document.querySelector(\"#log-lines\").innerHTML = logs\n .map((line, index) => {\n let text = escape(line.text);\n const position = query ? line.text.toLowerCase().indexOf(query) : -1;\n if (position >= 0) {\n matches++;\n text = `${escape(line.text.slice(0, position))}<mark>${escape(\n line.text.slice(position, position + query.length),\n )}</mark>${escape(line.text.slice(position + query.length))}`;\n }\n return `<div class=\"log-line ${line.type}\" ${\n line.type === \"error\" ? 'data-error=\"true\"' : \"\"\n }><span class=\"line-number\">${index + 1}</span><span class=\"log-time\">12:04:${String(\n 16 + Math.floor(index / 3),\n ).padStart(2, \"0\")}</span><span class=\"line-text\">${text || \" \"}</span></div>`;\n })\n .join(\"\");\n document.querySelector(\"#log-matches\").textContent = query ? `${matches} matching lines` : \"\";\n}\nfunction workflowSource(workflow) {\n return `name: ${workflow.name}\\nruns-on: [${workflow.labels.join(\n \", \",\n )}]\\n\\n# Illustrative workflow definition\\nsteps:\\n${workflow.steps\n .map(\n (name, index) =>\n ` - name: ${name}\\n run: ${\n [\n 'git checkout \"$COMMIT\"',\n \"pnpm install --frozen-lockfile\",\n \"pnpm test\",\n \"pnpm build\",\n 'echo \"Release complete\"',\n ][index]\n }`,\n )\n .join(\"\\n\")}`;\n}\nfunction workflowsPage() {\n main.innerHTML = `<div class=\"workflow-list overflow-hidden rounded-[7px] border border-[#cbd3c8] bg-white\">${workflows\n .map(\n (workflow, index) =>\n `<article class=\"workflow-item grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-4 gap-y-[5px] border-b border-flow-border px-4 py-[13px] last:border-b-0 max-[900px]:grid-cols-1 max-[900px]:gap-2\"><div class=\"workflow-header col-start-1 flex min-w-0 items-center gap-[10px]\"><span class=\"workflow-name overflow-hidden text-ellipsis whitespace-nowrap font-semibold\">${workflow.name}</span><span class=\"workflow-status success rounded bg-[#edf5e8] px-[7px] py-[3px] text-[11px] capitalize text-[#4d7040]\">Enabled</span></div><div class=\"workflow-meta col-start-1 flex min-w-0 items-center gap-[10px] pl-[27px] text-[11px] text-[#7e8874]\"><span>v${workflow.revision || 1}</span><span>${workflow.steps.length} steps</span></div><div class=\"workflow-actions col-start-2 row-span-2 row-start-1 flex min-w-0 flex-wrap items-center justify-end gap-[10px] max-[900px]:col-start-1 max-[900px]:row-start-auto max-[900px]:justify-start max-[900px]:pl-[27px]\"><a class=\"button !px-[9px] !py-[6px] !text-[11px]\" href=\"#/runs?workflow=${workflow.id}\" aria-label=\"View runs for ${workflow.name}\" title=\"View runs\">${icon(\n \"runs\",\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\" aria-label=\"View source for ${workflow.name}\" title=\"View source\">${icon(\n \"code\",\n )}</button></div></article>`,\n )\n .join(\"\")}</div>`;\n}\nfunction workerCards() {\n return `<div class=\"cards grid grid-cols-2 gap-4 max-[600px]:grid-cols-1\">${[\n [\"alpha\", \"main-server, docker\", 36, 42, \"1 / 4\"],\n [\"bravo\", \"node, linux\", 12, 28, \"0 / 4\"],\n [\"charlie\", \"docker, linux\", 8, 21, \"0 / 2\"],\n ]\n .map(\n ([name, tags, cpu, memory, slots]) =>\n `<article class=\"worker-card rounded-lg border border-flow-border bg-white p-5\"><div class=\"card-heading mb-[19px] flex items-center gap-[10px]\"><span class=\"text-[19px] text-[#7e9468]\">${icon(\n \"server\",\n )}</span><h2 class=\"text-[13px]\">${name}</h2><span class=\"status success ml-auto\">Online</span></div><p class=\"card-description text-[13px] leading-[1.8] text-[#7e8874]\">Linux x64 · systemd runner</p><div class=\"card-details mt-[14px] flex flex-wrap gap-[7px]\">${tags\n .split(\", \")\n .map(\n (tag) =>\n `<span class=\"tag mono rounded bg-[#f2f4ee] px-[7px] py-1 text-[10px] text-[#6b795e]\">${tag}</span>`,\n )\n .join(\n \"\",\n )}</div><div class=\"worker-stats mt-[23px] grid grid-cols-2 gap-5\"><div><span class=\"meta-label flex justify-between\">CPU <span>${cpu}%</span></span><div class=\"progress-track mt-[10px] h-1 overflow-hidden rounded-[3px] bg-[#edf0e7]\"><div class=\"progress-fill h-full rounded-[3px] bg-[#a1bf85]\" style=\"width:${cpu}%\"></div></div></div><div><span class=\"meta-label flex justify-between\">Memory <span>${memory}%</span></span><div class=\"progress-track mt-[10px] h-1 overflow-hidden rounded-[3px] bg-[#edf0e7]\"><div class=\"progress-fill h-full rounded-[3px] bg-[#a1bf85]\" style=\"width:${memory}%\"></div></div></div></div><div class=\"card-footer mt-[23px] flex items-center justify-between border-t border-flow-border pt-4\"><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">${slots} slots in use</span><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Sample snapshot</span></div></article>`,\n )\n .join(\"\")}</div>`;\n}\nfunction getPreference(key) {\n try {\n return localStorage.getItem(`flow-concept-${key}`) === \"true\";\n } catch {\n return false;\n }\n}\nfunction settingsPage() {\n const timezone = localStorage.getItem(\"flow-concept-timezone\") || \"UTC\";\n main.innerHTML = `<div class=\"page-heading\"><div><h1>Settings</h1><p class=\"subtitle\">The essentials for your workspace.</p></div></div><section class=\"settings-section mb-4 rounded-[7px] border border-flow-border bg-white px-5 py-[18px]\"><h2 class=\"mb-[15px]\">Preferences</h2><label class=\"setting-row flex items-center justify-between gap-4 py-3\"><span><strong class=\"block text-xs font-medium\">Compact run list</strong><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n getPreference(\"compact\") ? \"checked\" : \"\"\n }/></label><label class=\"setting-row flex items-center justify-between gap-4 border-t border-flow-border py-3\"><span><strong class=\"block text-xs font-medium\">Wrap log lines</strong><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label><label class=\"setting-row flex items-center justify-between gap-4 border-t border-flow-border py-3 max-[600px]:gap-[14px]\"><span><strong class=\"block text-xs font-medium\">Timezone</strong><span class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Use this timezone for displayed dates.</span></span><select id=\"timezone-setting\"><option ${timezone === \"UTC\" ? \"selected\" : \"\"}>UTC</option><option ${timezone === \"America/Los_Angeles\" ? \"selected\" : \"\"}>America/Los_Angeles</option><option ${timezone === \"Europe/London\" ? \"selected\" : \"\"}>Europe/London</option></select></label></section><section class=\"settings-section mb-4 rounded-[7px] border border-flow-border bg-white px-5 py-[18px] max-[600px]:px-[18px]\"><h2 class=\"mb-[15px]\">Workers</h2><p class=\"settings-note text-[12px] leading-[1.8] text-flow-muted\">Execution capacity available to workflows.</p>${workerCards()}</section><section class=\"settings-section mb-4 rounded-[7px] border border-flow-border bg-white px-5 py-[18px] max-[600px]:px-[18px]\"><h2 class=\"mb-[15px]\">${icon(\n \"lock\",\n )} Secrets</h2><p class=\"settings-note\">Available to workflows. Values are never displayed in the dashboard.</p>${[\n \"NPM_TOKEN\",\n \"REGISTRY_PASSWORD\",\n \"DEPLOY_KEY\",\n ]\n .map(\n (name) =>\n `<div class=\"setting-row flex items-center justify-between gap-4 border-t border-flow-border py-3 max-[600px]:gap-[14px]\"><div class=\"min-w-0\"><strong class=\"mono block max-w-[min(58vw,480px)] truncate text-xs font-medium\" title=\"${name}\">${name}</strong><p class=\"mt-1 text-[11px] text-flow-muted max-[600px]:text-[13px]\">Workspace secret · Example</p></div><span class=\"secret-value shrink-0 text-xs text-flow-muted\" aria-label=\"Value hidden\">••••••••••••</span></div>`,\n )\n .join(\n \"\",\n )}</section><section class=\"settings-section\"><h2>Tokens</h2><p class=\"settings-note\">Access tokens are hidden after creation.</p><div class=\"setting-row\"><span>CI deploy token</span><span class=\"secret-value\" aria-label=\"Token value hidden\">••••••••••••</span></div></section><section class=\"settings-section\"><h2>Admin roster</h2><div class=\"setting-row\"><div><strong>Jamie Davis</strong><p>jamie@example.com</p></div><span class=\"workflow-status success\">Admin</span></div></section><section class=\"settings-section\"><h2>About</h2><p class=\"settings-note\">An interactive design prototype based on Flow’s job list and run details. Run controls update this browser session. Display preferences are saved on this device.</p><div class=\"setting-row\"><span class=\"settings-note\">Explore without setup.</span><button class=\"button\" data-action=\"shortcuts\">${icon(\n \"keyboard\",\n )}Keyboard shortcuts</button></div></section>`;\n}\nfunction openDialog(title, body) {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${title}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\",\n )}</button></div>${body}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n}\nfunction newRunDialog() {\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">Choose what to run. We’ll take it from here.</p><form id=\"new-run-form\"><label class=\"form-field\"><span>Workflow</span><select name=\"workflow\">${workflows\n .map((workflow) => `<option value=\"${workflow.id}\">${workflow.name}</option>`)\n .join(\n \"\",\n )}</select></label><label class=\"form-field\"><span>Branch</span><input name=\"branch\" value=\"main\" required maxlength=\"100\" pattern=\"[a-zA-Z0-9._\\\\x2f\\\\x2d]+\"/></label><p class=\"dialog-note\">Prototype mode: creates a sample queued run in this session.</p><div class=\"dialog-footer\"><button class=\"button\" type=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" type=\"submit\">${icon(\n \"runs\",\n )}Run workflow</button></div></form>`,\n );\n}\nfunction createRun(original, workflow, branch) {\n const job = {\n id: Math.max(...jobs.map((entry) => entry.id)) + 1,\n workflow,\n branch,\n status: \"pending\",\n message: original?.message || \"Manually triggered workflow\",\n commit: original?.commit || \"a8f3c12\",\n seconds: 0,\n time: \"Just now\",\n repo: original?.repo || \"on\",\n worker: null,\n parent: original?.id || null,\n attempt: original ? original.attempt + 1 : 1,\n };\n jobs.unshift(job);\n dialog.close();\n window.location.hash = `/runs/${job.id}`;\n toast(`Sample run #${job.id} queued.`);\n}\nfunction download(name, text) {\n const url = URL.createObjectURL(new Blob([text], { type: \"text/plain;charset=utf-8\" }));\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = name;\n anchor.click();\n setTimeout(() => {\n URL.revokeObjectURL(url);\n }, 1000);\n}\nfunction render() {\n const { path } = route();\n const page = path.split(\"/\")[1] || \"runs\";\n const changed = previousRoute !== path;\n if (changed) {\n state.step = null;\n state.detailTab = \"logs\";\n state.logQuery = \"\";\n }\n previousRoute = path;\n navigation([\"runs\", \"workflows\", \"settings\"].includes(page) ? page : page === \"workers\" ? \"settings\" : \"runs\");\n if (path === \"/runs\") {\n readFilters();\n runsPage();\n } else if (page === \"runs\" && currentJob()) {\n detailPage(currentJob());\n } else if (page === \"workflows\") {\n workflowsPage();\n } else if (page === \"workers\") {\n settingsPage();\n } else if (page === \"settings\") {\n settingsPage();\n } else {\n main.innerHTML = `<div class=\"empty-state\">${icon(\n \"search\",\n )}<h1>Run not found</h1><p>This run is not part of the sample workspace.</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n if (changed) {\n window.scrollTo(0, 0);\n main.focus({ preventScroll: true });\n }\n}\ndocument.addEventListener(\"click\", (event) => {\n if (event.target.closest('#profile-link[aria-disabled=\"true\"]')) {\n event.preventDefault();\n }\n});\ndocument.addEventListener(\"click\", async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return;\n }\n const { action, value } = button.dataset;\n const job = currentJob();\n if (action === \"new-run\") {\n newRunDialog();\n }\n if (action === \"close-dialog\") {\n dialog.close();\n }\n if (action === \"filter-failed\" || action === \"status-filter\") {\n state.status = action === \"filter-failed\" ? \"failed\" : value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`.filter-tab[data-value=\"${state.status}\"]`).focus({ preventScroll: true });\n }\n if (action === \"clear-filters\") {\n Object.assign(state, { search: \"\", status: \"all\", workflow: \"all\", branch: \"all\", page: 1 });\n updateFilterUrl();\n runsPage();\n document.querySelector(\"#run-search\").focus();\n }\n if (action === \"prev-page\" || action === \"next-page\") {\n state.page += action === \"next-page\" ? 1 : -1;\n updateFilterUrl();\n renderResults();\n document.querySelector(`button[data-action=\"${action}\"]:not(:disabled)`)?.focus({ preventScroll: true });\n }\n if (action === \"detail-tab\") {\n state.detailTab = value;\n detailPage(job);\n document.querySelector(`[data-action=\"detail-tab\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"step\") {\n state.step = Number(value);\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(`[data-action=\"step\"][data-value=\"${value}\"]`).focus({ preventScroll: true });\n }\n if (action === \"jump-error\") {\n state.step = 2;\n state.logQuery = \"\";\n renderDetailContent(job);\n document.querySelector(\"[data-error]\")?.scrollIntoView({ block: \"nearest\" });\n document.querySelector(\"#log-lines\").focus({ preventScroll: true });\n }\n if (action === \"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 }\n if (action === \"copy-log\") {\n try {\n await navigator.clipboard.writeText(\n logsFor(job, state.step)\n .map((line) => line.text)\n .join(\"\\n\"),\n );\n toast(\"Step log copied.\");\n } catch {\n toast(\"Clipboard unavailable. Use Download logs to save the output.\");\n }\n }\n if (action === \"download-logs\") {\n download(\n `flow-run-${job.id}.log`,\n job.workflow.steps\n .map(\n (name, index) =>\n `=== ${name} ===\\n${logsFor(job, index)\n .map((line) => line.text)\n .join(\"\\n\")}`,\n )\n .join(\"\\n\\n\"),\n );\n toast(\"Run log downloaded.\");\n }\n if (action === \"artifact\") {\n download(\n `run-${job.id}-build-manifest.json`,\n JSON.stringify({ sample: true, run: job.id, commit: job.commit, workflow: job.workflow.id }, null, 2),\n );\n }\n if (action === \"rerun\") {\n createRun(job, job.workflow, job.branch);\n }\n if (action === \"cancel-run\") {\n job.status = \"cancelled\";\n state.step = null;\n navigation(\"runs\");\n detailPage(job);\n toast(`Sample run #${job.id} cancelled.`);\n }\n if (action === \"workflow-source\") {\n const workflow = workflows[Number(value)];\n openDialog(\n workflow.name,\n `<p class=\"dialog-description\">Illustrative YAML · revision 12</p><div class=\"source-view\"><pre>${escape(\n workflowSource(workflow),\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`,\n );\n }\n if (action === \"preferences\") {\n window.location.hash = \"/settings\";\n }\n if (action === \"shortcuts\") {\n openDialog(\n \"A few useful shortcuts\",\n `<p class=\"dialog-description\">Less clicking. More getting things done.</p><div class=\"shortcut-row\"><span>Search runs</span><kbd>/</kbd></div><div class=\"shortcut-row\"><span>Run a workflow</span><kbd>N</kbd></div><div class=\"shortcut-row\"><span>Show shortcuts</span><kbd>?</kbd></div><div class=\"shortcut-row\"><span>Close dialog</span><kbd>Esc</kbd></div><div class=\"dialog-footer\"><button class=\"button primary\" data-action=\"close-dialog\">Got it</button></div>`,\n );\n }\n});\ndocument.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if (event.target.id === \"log-search\") {\n state.logQuery = event.target.value;\n renderLogs(currentJob());\n }\n});\ndocument.addEventListener(\"change\", (event) => {\n if ([\"workflow-filter\", \"branch-filter\"].includes(event.target.id)) {\n state[event.target.id === \"workflow-filter\" ? \"workflow\" : \"branch\"] = event.target.value;\n state.page = 1;\n updateFilterUrl();\n renderResults();\n }\n if ([\"compact-setting\", \"wrap-setting\"].includes(event.target.id)) {\n const key = event.target.id === \"compact-setting\" ? \"compact\" : \"wrap\";\n if (key === \"compact\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n } else {\n state.wrap = event.target.checked;\n }\n try {\n localStorage.setItem(`flow-concept-${key}`, event.target.checked);\n toast(\"Preference saved on this device.\");\n } catch {\n toast(\"Preference applied for this session.\");\n }\n }\n if (event.target.id === \"timezone-setting\") {\n localStorage.setItem(\"flow-concept-timezone\", event.target.value);\n }\n});\ndocument.addEventListener(\"submit\", (event) => {\n if (event.target.id === \"new-run-form\") {\n event.preventDefault();\n createRun(\n null,\n workflows.find((workflow) => workflow.id === event.target.elements.workflow.value),\n event.target.elements.branch.value.trim(),\n );\n }\n});\ndocument.addEventListener(\"keydown\", (event) => {\n if (\n event.ctrlKey ||\n event.metaKey ||\n event.altKey ||\n event.target.closest('input,select,textarea,[contenteditable=\"true\"]') ||\n dialog.open\n ) {\n return;\n }\n if (event.key === \"/\" && route().path === \"/runs\") {\n event.preventDefault();\n document.querySelector(\"#run-search\").focus();\n }\n if (event.key.toLowerCase() === \"n\") {\n event.preventDefault();\n newRunDialog();\n }\n if (event.key === \"?\") {\n event.preventDefault();\n document.querySelector('[data-action=\"shortcuts\"]').click();\n }\n});\nwindow.addEventListener(\"hashchange\", render);\ndocument.querySelectorAll(\"[data-icon]\").forEach((element) => {\n element.innerHTML = icon(element.dataset.icon);\n});\ndocument.body.classList.toggle(\"compact\", getPreference(\"compact\"));\nstate.wrap = getPreference(\"wrap\");\nrender();\n", pg = "<!doctype html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n <meta name=\"theme-color\" content=\"#f7f8f6\" />\n <meta name=\"description\" content=\"Flow — a quieter place to ship. An interactive CI dashboard design concept.\" />\n <title>Runs · Flow</title>\n <link\n rel=\"icon\"\n href=\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%23244336'/%3E%3Cpath d='M9 10h14M9 16h10M9 22h6' stroke='%23c4efad' stroke-width='3'/%3E%3C/svg%3E\"\n />\n <link rel=\"stylesheet\" href=\"./style.css\" />\n <script src=\"./app.js\" defer><\/script>\n </head>\n <body>\n <a href=\"#main\" class=\"skip-link\">Skip to content</a>\n <aside\n class=\"sidebar fixed inset-y-0 left-0 z-20 flex w-56 flex-col border-r border-flow-border bg-flow-sidebar px-[17px] pt-8 pb-[18px] max-[1180px]:w-48 max-[1180px]:px-3 max-[900px]:w-[72px] max-[900px]:items-center max-[900px]:px-[9px] max-[900px]:pt-[27px] max-[900px]:pb-[15px] max-[600px]:inset-x-0 max-[600px]:inset-y-auto max-[600px]:bottom-0 max-[600px]:h-[62px] max-[600px]:w-full max-[600px]:flex-row max-[600px]:items-center max-[600px]:border-r-0 max-[600px]:border-t max-[600px]:bg-[#fafbf8f5] max-[600px]:px-3 max-[600px]:pt-0 max-[600px]:pb-[env(safe-area-inset-bottom)] max-[600px]:backdrop-blur-[15px]\"\n aria-label=\"Workspace navigation\"\n >\n <a\n class=\"brand flex w-fit items-center px-[13px] text-[33px] font-bold tracking-[-1.9px] max-[900px]:p-0 max-[900px]:text-[0px] max-[600px]:hidden\"\n href=\"#/runs\"\n aria-label=\"Flow home\"\n ><span\n class=\"brand-mark mr-[10px] flex h-[34px] w-[31px] -skew-y-[13deg] flex-col justify-center gap-[5px] max-[900px]:m-0\"\n ><i class=\"block h-[5px] w-[27px] rounded-[2px] bg-[#547d42]\"></i\n ><i class=\"block h-[5px] w-5 rounded-[2px] bg-[#547d42]\"></i\n ><i class=\"block h-[5px] w-[13px] rounded-[2px] bg-[#547d42]\"></i></span\n >flow<span class=\"brand-period text-[#729b5b]\">.</span></a\n >\n <div\n class=\"workspace mx-[2px] my-[38px] flex items-center gap-[10px] rounded-[7px] border border-flow-border bg-flow-subtle px-[10px] py-3 max-[1180px]:gap-[7px] max-[900px]:hidden\"\n >\n <span\n class=\"workspace-icon grid h-[31px] w-[30px] place-items-center rounded-[5px] border border-[#e2e7dc] bg-[#edf1e7] text-[19px] font-bold\"\n >a</span\n >\n <div>\n <strong class=\"block text-[13px] font-semibold\">Apphor</strong\n ><span class=\"mt-[3px] block text-[13px] text-flow-muted\">Engineering workspace</span>\n </div>\n <span\n class=\"workspace-label ml-auto rounded-[3px] border border-flow-border px-1 py-[2px] text-[13px] text-flow-muted max-[1180px]:hidden\"\n >PRO</span\n >\n </div>\n <div\n class=\"nav-caption mb-3 px-[13px] text-[13px] font-semibold tracking-[1.4px] text-flow-muted max-[900px]:hidden\"\n >\n WORKSPACE\n </div>\n <nav\n class=\"grid gap-[5px] max-[900px]:mt-10 max-[900px]:w-full max-[600px]:mt-0 max-[600px]:flex max-[600px]:h-full max-[600px]:justify-around\"\n id=\"navigation\"\n ></nav>\n <div class=\"sidebar-bottom mt-auto max-[600px]:hidden\">\n <div class=\"runner-health flex items-center gap-[7px] px-2 py-[18px] text-[13px] text-flow-muted\">\n <span class=\"health-dot h-[6px] w-[6px] shrink-0 rounded-full bg-[#699d55]\"></span\n ><span>Runners operational</span><span class=\"mono ml-auto text-[10px]\">3/3</span>\n </div>\n <a\n id=\"profile-link\"\n class=\"profile flex w-full items-center gap-[10px] border-t border-flow-border px-[5px] pt-[19px] text-left text-flow-foreground no-underline hover:bg-flow-subtle\"\n href=\"#\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n aria-disabled=\"true\"\n aria-label=\"Open identity profile in a new tab\"\n >\n <span\n class=\"avatar grid h-[33px] w-[33px] shrink-0 place-items-center rounded-full bg-[#ebe6d9] text-[13px] font-semibold text-[#675c44]\"\n id=\"profile-avatar\"\n >JD</span\n ><span class=\"min-w-0 max-w-[138px] max-[900px]:hidden\"\n ><strong id=\"profile-name\" class=\"block truncate text-[13px] font-medium\">Jamie Davis</strong\n ><small id=\"profile-email\" class=\"mt-[3px] block truncate text-[12px] text-flow-muted\">jamie@example.com</small\n ><span\n id=\"profile-role\"\n class=\"mt-1 inline-flex w-fit rounded-full bg-flow-subtle px-1.5 py-0.5 text-[10px] font-medium text-flow-muted\"\n >Admin</span\n ></span\n ><span class=\"ml-auto text-flow-muted max-[900px]:hidden\" data-icon=\"external\"></span>\n </a>\n </div>\n </aside>\n <div\n class=\"app-shell ml-56 flex min-h-screen flex-col max-[1180px]:ml-48 max-[900px]:ml-[72px] max-[600px]:ml-0 max-[600px]:pb-[62px]\"\n >\n <main id=\"main\" tabindex=\"-1\"></main>\n </div>\n <dialog id=\"dialog\" aria-labelledby=\"dialog-title\"></dialog>\n <div id=\"toast\" role=\"status\" class=\"toast\" hidden></div>\n </body>\n</html>\n", mg = "@import url(\"https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;450;500;550;600;650;700&family=IBM+Plex+Mono:wght@400;500&display=swap\");\n\n:root {\n --bg: #f3f5f1;\n --sidebar: #e9eee6;\n --ink: #1a1e22;\n --muted: #505b52;\n --line: #cbd3c8;\n --subtle: #fafbf8;\n --green: #387045;\n --green-bg: #e8f0e5;\n --accent: #c8e6a8;\n --red: #b54138;\n --red-bg: #fee2e2;\n --blue: #4a66a0;\n --blue-bg: #e8eef6;\n --amber: #845e1c;\n font-family:\n \"DM Sans\",\n -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--ink);\n font-size: 15px;\n font-synthesis: none;\n}\n* {\n box-sizing: border-box;\n}\nbody {\n margin: 0;\n background: var(--bg);\n}\nbutton,\ninput,\nselect {\n font: inherit;\n}\nbutton,\na,\ninput,\nselect,\nsummary {\n -webkit-tap-highlight-color: transparent;\n}\nbutton,\nselect,\nsummary {\n cursor: pointer;\n}\nbutton {\n color: inherit;\n}\na {\n color: inherit;\n text-decoration: none;\n}\nbutton {\n border: 0;\n background: none;\n}\nbutton:disabled {\n cursor: not-allowed;\n opacity: 0.45;\n}\n:focus-visible {\n outline: 2px solid #467b40;\n outline-offset: 4px;\n}\ninput:focus-visible,\nselect:focus-visible {\n outline-offset: 1px;\n}\nbutton,\na {\n touch-action: manipulation;\n}\nsvg {\n width: 18px;\n height: 18px;\n flex-shrink: 0;\n vertical-align: middle;\n}\nh1,\nh2,\nh3,\np {\n margin: 0;\n}\n.mono,\ncode,\nkbd,\npre {\n font-family: \"IBM Plex Mono\", monospace;\n}\n.sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n}\n.skip-link {\n position: fixed;\n top: -60px;\n left: 20px;\n z-index: 100;\n background: var(--ink);\n color: white;\n padding: 12px;\n}\n.skip-link:focus {\n top: 10px;\n}\n.demo-label {\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: var(--muted);\n}\n.demo-label > span {\n width: 5px;\n height: 5px;\n border-radius: 50%;\n background: #8c9c7f;\n}\n.icon-button {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n min-width: 32px;\n min-height: 32px;\n border-radius: 5px;\n color: var(--muted);\n}\n.icon-button:hover {\n background: #edf0e8;\n color: var(--ink);\n}\nmain {\n width: 100%;\n max-width: 1600px;\n margin: 0 auto;\n padding: 43px 42px 30px;\n}\nmain:focus-visible {\n outline: none;\n}\n.page-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n margin-bottom: 32px;\n}\nh1 {\n font-size: 29px;\n letter-spacing: -1px;\n font-weight: 550;\n line-height: 1.25;\n}\n.subtitle {\n color: var(--muted);\n font-size: 12px;\n margin-top: 9px;\n line-height: 1.7;\n}\n.heading-actions {\n display: flex;\n align-items: center;\n gap: 10px;\n}\n.button {\n display: inline-flex;\n justify-content: center;\n align-items: center;\n gap: 7px;\n padding: 10px 13px;\n border: 1px solid #dfe4d9;\n border-radius: 6px;\n font-size: 13px;\n font-weight: 550;\n min-height: 36px;\n background: var(--bg);\n white-space: nowrap;\n}\n.button svg {\n width: 14px;\n height: 14px;\n}\n.button:hover {\n background: var(--subtle);\n border-color: #c8d0bf;\n}\n.button.primary {\n background: var(--accent);\n border-color: #b6d79d;\n color: #2c4320;\n}\n.button.primary:hover {\n background: #b7e198;\n}\n.button.danger {\n color: var(--red);\n border-color: #edcdc8;\n}\n.text-button {\n color: #52644b;\n font-size: 13px;\n padding: 6px 0;\n display: inline-flex;\n align-items: center;\n gap: 6px;\n}\n.text-button:hover {\n text-decoration: underline;\n}\n.text-button svg {\n width: 13px;\n}\n.overview {\n display: grid;\n grid-template-columns: 1.08fr 1.08fr 1fr 1fr;\n border: 1px solid var(--line);\n border-radius: 8px;\n margin-bottom: 31px;\n background: linear-gradient(110deg, #fcfdf9, #fff);\n overflow: hidden;\n}\n.metric {\n position: relative;\n padding: 22px 24px;\n min-height: 117px;\n}\n.metric + .metric::before {\n content: \"\";\n position: absolute;\n width: 1px;\n background: var(--line);\n top: 23px;\n bottom: 23px;\n left: 0;\n}\n.metric-label {\n display: flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: #70786a;\n}\n.metric-label svg {\n width: 13px;\n height: 13px;\n color: #8a9482;\n}\n.metric-bottom {\n display: flex;\n align-items: baseline;\n gap: 10px;\n margin-top: 13px;\n}\n.metric-value {\n font-size: 27px;\n letter-spacing: -1px;\n font-weight: 500;\n}\n.metric-value small {\n font-size: 15px;\n letter-spacing: 0;\n}\n.metric-note {\n font-size: 13px;\n color: #69765f;\n}\n.metric-note span {\n color: var(--green);\n}\n.tiny-bars {\n display: flex;\n gap: 3px;\n align-items: end;\n height: 25px;\n margin-left: auto;\n}\n.tiny-bars i {\n width: 5px;\n background: #c4d9b2;\n border-radius: 2px;\n}\n.metric-foot {\n margin-top: 7px;\n font-size: 10px;\n color: #858b7f;\n}\n.section-heading {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 15px;\n margin-bottom: 17px;\n}\nh2 {\n font-size: 14px;\n font-weight: 550;\n letter-spacing: -0.2px;\n}\n.section-heading h2 {\n display: flex;\n gap: 9px;\n align-items: center;\n}\n.count {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 10px;\n font-weight: 400;\n background: #f0f2ed;\n color: #747d6d;\n border: 1px solid #e7ebe1;\n border-radius: 4px;\n padding: 2px 5px;\n}\n.section-heading > span {\n color: var(--muted);\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.section-heading > span svg {\n width: 12px;\n height: 12px;\n}\n.filters {\n display: flex;\n gap: 9px;\n align-items: center;\n margin-bottom: 15px;\n}\n.search-box {\n display: flex;\n align-items: center;\n gap: 9px;\n border: 1px solid var(--line);\n border-radius: 6px;\n height: 35px;\n padding: 0 10px;\n min-width: 0;\n background: white;\n}\n.search-box svg {\n width: 14px;\n height: 14px;\n color: #8a9184;\n}\n.search-box input {\n border: 0;\n min-width: 0;\n width: 100%;\n outline: 0;\n background: transparent;\n color: var(--ink);\n font-size: 13px;\n}\n.search-box:focus-within {\n border-color: #729859;\n box-shadow: 0 0 0 2px #edf5e8;\n}\n.search-box input::placeholder {\n color: #899080;\n}\n.filters .search-box {\n width: 300px;\n margin-right: auto;\n}\nkbd {\n font-size: 10px;\n border: 1px solid #e6e9e1;\n padding: 1px 4px;\n color: #838b7a;\n border-radius: 3px;\n}\n.select-filter {\n border: 1px solid var(--line);\n background: white;\n border-radius: 6px;\n padding: 8px 28px 8px 11px;\n color: #666f5e;\n font-size: 13px;\n height: 35px;\n max-width: 170px;\n}\n.filter-tabs {\n display: flex;\n gap: 23px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 0;\n}\n.filter-tab {\n padding: 1px 0 13px;\n font-size: 13px;\n color: var(--muted);\n border-bottom: 2px solid transparent;\n display: flex;\n align-items: center;\n gap: 7px;\n margin-bottom: -1px;\n}\n.filter-tab.active {\n color: var(--ink);\n border-bottom-color: #5c7a46;\n font-weight: 550;\n}\n.filter-tab .count {\n border: 0;\n background: #f0f2ec;\n padding: 1px 5px;\n font-size: 10px;\n}\n.filter-tab.active .count {\n background: #eaf1e1;\n color: #536d40;\n}\n.run-table {\n width: 100%;\n border-collapse: collapse;\n text-align: left;\n table-layout: fixed;\n}\n.run-table th {\n font-weight: 400;\n font-size: 12px;\n color: #808877;\n padding: 14px 10px;\n background: #fafbf8;\n border-bottom: 1px solid var(--line);\n}\n.run-table th:first-child {\n padding-left: 15px;\n width: 44%;\n}\n.run-table th:nth-child(2) {\n width: 16%;\n}\n.run-table th:nth-child(3) {\n width: 17%;\n}\n.run-table th:nth-child(4) {\n width: 11%;\n}\n.run-table th:last-child {\n width: 12%;\n text-align: right;\n padding-right: 17px;\n}\n.run-table td {\n padding: 18px 10px;\n border-bottom: 1px solid var(--line);\n font-size: 13px;\n color: #747c6c;\n}\n.run-table td:first-child {\n padding-left: 15px;\n}\n.run-table td:last-child {\n text-align: right;\n padding-right: 17px;\n}\n.run-table tr:hover td {\n background: #fafbf8;\n}\n.run-link {\n display: flex;\n align-items: center;\n gap: 12px;\n min-width: 0;\n}\n.run-title {\n display: block;\n font-size: 13px;\n color: #30382c;\n font-weight: 550;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.run-copy {\n min-width: 0;\n}\n.run-subtitle {\n font-size: 13px;\n color: #818878;\n margin-top: 5px;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.run-subtitle .mono {\n font-size: 10px;\n}\n.run-subtitle .separator {\n color: #bdc4b5;\n}\n.status-icon {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n width: 21px;\n height: 21px;\n}\n.status-icon svg {\n width: 18px;\n height: 18px;\n}\n.status-icon.success {\n color: #6b9355;\n}\n.status-icon.failed {\n color: #cc7064;\n}\n.status-icon.running {\n color: #647fab;\n}\n.status-icon.pending {\n color: #af955e;\n}\n.status-icon.cancelled,\n.status-icon.skipped {\n color: #91998a;\n}\n.status {\n display: inline-flex;\n align-items: center;\n gap: 6px;\n font-size: 13px;\n border-radius: 5px;\n padding: 4px 7px;\n white-space: nowrap;\n}\n.status .status-icon {\n width: 11px;\n height: 11px;\n}\n.status .status-icon svg {\n width: 11px;\n height: 11px;\n}\n.status.success {\n background: var(--green-bg);\n color: var(--green);\n}\n.status.failed {\n background: var(--red-bg);\n color: var(--red);\n}\n.status.running {\n background: var(--blue-bg);\n color: var(--blue);\n}\n.status.pending {\n background: #faf4e6;\n color: var(--amber);\n}\n.status.cancelled,\n.status.skipped {\n background: #f0f2ed;\n color: #737c68;\n}\n.branch {\n display: inline-flex;\n align-items: center;\n gap: 5px;\n font-size: 13px;\n}\n.branch svg {\n width: 12px;\n height: 12px;\n}\n.duration {\n font-size: 13px;\n color: var(--muted);\n}\n.table-footer {\n padding: 18px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 10px;\n font-size: 13px;\n color: #838b7b;\n}\n.pagination {\n display: flex;\n align-items: center;\n gap: 14px;\n}\n.pagination .button {\n padding: 6px 9px;\n min-height: 28px;\n font-size: 13px;\n}\n.table-hint {\n display: flex;\n align-items: center;\n gap: 5px;\n color: #8b9186;\n font-size: 10px;\n}\n.table-hint svg {\n width: 11px;\n height: 11px;\n}\n.attention-strip {\n display: flex;\n align-items: center;\n gap: 12px;\n border: 1px solid #e8e4d8;\n border-radius: 7px;\n padding: 15px 17px;\n margin-top: 18px;\n background: #fcfbf7;\n}\n.attention-strip > svg {\n color: #aa8c50;\n width: 17px;\n}\n.attention-strip p {\n font-size: 13px;\n color: #5f6153;\n}\n.attention-strip p span {\n color: #89907f;\n margin-left: 5px;\n}\n.attention-strip .text-button {\n margin-left: auto;\n white-space: nowrap;\n}\n.page-footer {\n margin: auto 42px 0;\n padding: 25px 0;\n display: flex;\n align-items: center;\n justify-content: space-between;\n color: #8b9382;\n font-size: 10px;\n border-top: 1px solid var(--line);\n}\n.footer-mark {\n font-size: 19px;\n color: #45643c;\n margin-right: 5px;\n vertical-align: middle;\n}\n.footer-dot {\n margin: 0 7px;\n color: #b4bdae;\n}\n.back-link {\n display: inline-flex;\n gap: 7px;\n align-items: center;\n font-size: 13px;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.back-link svg {\n width: 13px;\n height: 13px;\n}\n.run-heading h1 {\n display: flex;\n align-items: center;\n gap: 10px;\n font-size: 26px;\n}\n.run-heading h1 .status-icon {\n width: 27px;\n height: 27px;\n}\n.run-heading h1 .status-icon svg {\n width: 25px;\n height: 25px;\n}\n.run-heading .subtitle {\n display: flex;\n flex-wrap: wrap;\n gap: 7px;\n align-items: center;\n margin-left: 38px;\n font-size: 13px;\n}\n.run-heading .subtitle .mono {\n font-size: 13px;\n}\n.detail-meta {\n display: grid;\n grid-template-columns: repeat(5, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n padding: 19px 22px;\n gap: 20px;\n margin-bottom: 26px;\n}\n.meta-label {\n display: block;\n font-size: 10px;\n color: #7b8571;\n margin-bottom: 10px;\n}\n.meta-value {\n font-size: 13px;\n display: flex;\n align-items: center;\n gap: 6px;\n}\n.meta-value svg {\n width: 13px;\n height: 13px;\n color: #939c88;\n}\n.detail-tabs {\n display: flex;\n gap: 25px;\n border-bottom: 1px solid var(--line);\n margin-bottom: 22px;\n}\n.detail-tabs button {\n padding: 0 1px 13px;\n display: flex;\n align-items: center;\n gap: 7px;\n font-size: 13px;\n color: #7b8373;\n border-bottom: 2px solid transparent;\n}\n.detail-tabs button svg {\n width: 14px;\n height: 14px;\n}\n.detail-tabs button.active {\n color: #34422b;\n border-bottom-color: #657d4f;\n}\n.failure-banner {\n padding: 14px 17px;\n background: #fdf6f3;\n border: 1px solid #f0ddd5;\n display: flex;\n gap: 12px;\n align-items: center;\n border-radius: 6px;\n margin-bottom: 20px;\n}\n.failure-banner > svg {\n color: #bf7864;\n}\n.failure-banner strong {\n font-size: 13px;\n font-weight: 550;\n color: #8e5746;\n}\n.failure-banner p {\n font-size: 13px;\n color: #9b7d6c;\n margin-top: 4px;\n}\n.failure-banner button {\n margin-left: auto;\n color: #9f5e4b;\n white-space: nowrap;\n}\n.execution {\n display: grid;\n grid-template-columns: 226px minmax(0, 1fr);\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n min-height: 385px;\n}\n.steps-panel {\n background: #fafbf8;\n border-right: 1px solid var(--line);\n padding: 17px 10px;\n}\n.steps-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 2px 10px 16px;\n font-size: 13px;\n color: #656e5d;\n}\n.steps-heading .mono {\n font-size: 10px;\n color: #8e9685;\n}\n.step-button {\n display: flex;\n align-items: center;\n width: 100%;\n gap: 9px;\n padding: 12px 10px;\n border: 1px solid transparent;\n border-radius: 5px;\n text-align: left;\n margin-bottom: 3px;\n font-size: 13px;\n color: #626c58;\n}\n.step-button .status-icon,\n.step-button .status-icon svg {\n width: 14px;\n height: 14px;\n}\n.step-button .mono {\n margin-left: auto;\n font-size: 10px;\n color: #87907c;\n}\n.step-button.active {\n border-color: #e2e5dc;\n background: white;\n box-shadow: 0 1px 3px #23351204;\n color: #303e25;\n}\n.step-button:hover {\n background: #f0f3eb;\n}\n.step-count {\n border-top: 1px solid var(--line);\n padding: 16px 10px 0;\n margin-top: 18px;\n font-size: 10px;\n color: #7d8672;\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.step-count svg {\n width: 12px;\n height: 12px;\n}\n.log-panel {\n min-width: 0;\n background: #1c211e;\n color: #c9d0c5;\n display: flex;\n flex-direction: column;\n}\n.log-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 16px 19px;\n border-bottom: 1px solid #343b31;\n background: #252b26;\n gap: 10px;\n}\n.log-heading h3 {\n font-size: 13px;\n font-weight: 500;\n color: #dce2d7;\n}\n.log-heading h3 small {\n margin-left: 8px;\n color: #adba9f;\n font-size: 10px;\n font-weight: 400;\n}\n.log-actions {\n display: flex;\n gap: 6px;\n align-items: center;\n}\n.log-actions .icon-button {\n color: #a6b09e;\n min-height: 25px;\n min-width: 25px;\n}\n.log-actions .icon-button:hover {\n background: #3d453a;\n color: white;\n}\n.log-actions svg {\n width: 14px;\n height: 14px;\n}\n.log-search {\n border-bottom: 1px solid #343b31;\n padding: 9px 17px;\n display: flex;\n align-items: center;\n gap: 7px;\n}\n.log-search svg {\n color: #8b9983;\n width: 13px;\n}\n.log-search input {\n width: 100%;\n min-width: 0;\n background: none;\n border: 0;\n color: #d6dfce;\n outline: none;\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n}\n.log-search input::placeholder {\n color: #8d9b85;\n}\n.log-search:focus-within {\n box-shadow: inset 0 0 0 1px #a0c983;\n}\n.log-search span {\n font-size: 10px;\n white-space: nowrap;\n color: #a6b299;\n}\n.log-lines {\n font-family: \"IBM Plex Mono\", monospace;\n font-size: 13px;\n line-height: 2.15;\n overflow: auto;\n padding: 15px 0;\n flex: 1;\n min-height: 260px;\n max-height: 490px;\n}\n.log-line {\n display: flex;\n white-space: pre;\n padding: 0 18px 0 0;\n min-width: max-content;\n}\n.line-number {\n color: #86927d;\n width: 44px;\n padding-right: 15px;\n text-align: right;\n user-select: none;\n flex-shrink: 0;\n}\n.log-time {\n color: #8a9782;\n margin-right: 16px;\n}\n.log-line.error {\n background: #59332c4a;\n color: #f0a28e;\n}\n.log-line.pass {\n color: #b9d49e;\n}\n.log-line.dim {\n color: #98a48f;\n}\n.log-line mark {\n background: #bdcb92;\n color: #202923;\n}\n.log-lines.wrap .log-line {\n white-space: pre-wrap;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.log-lines.wrap .line-text {\n min-width: 0;\n}\n.log-bottom {\n font-size: 10px;\n color: #a1af95;\n padding: 11px 17px;\n border-top: 1px solid #343b31;\n display: flex;\n justify-content: space-between;\n gap: 12px;\n}\n.detail-bottom {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 16px 0 7px;\n color: #838d78;\n font-size: 10px;\n}\n.detail-bottom span {\n display: flex;\n align-items: center;\n gap: 5px;\n}\n.detail-bottom svg {\n width: 12px;\n height: 12px;\n}\n.source-view {\n border: 1px solid var(--line);\n border-radius: 7px;\n overflow: hidden;\n}\n.source-view h3 {\n font-size: 13px;\n font-weight: 500;\n background: var(--subtle);\n padding: 15px 20px;\n border-bottom: 1px solid var(--line);\n}\n.source-view pre {\n margin: 0;\n background: #1c211e;\n color: #c2d3b5;\n padding: 25px;\n font-size: 12px;\n line-height: 1.9;\n overflow: auto;\n}\n.empty-state {\n text-align: center;\n padding: 55px 20px;\n color: var(--muted);\n}\n.empty-state svg {\n width: 27px;\n height: 27px;\n color: #99aa8a;\n margin-bottom: 15px;\n}\n.empty-state h3 {\n font-size: 14px;\n color: var(--ink);\n font-weight: 500;\n margin-bottom: 8px;\n}\n.empty-state p {\n font-size: 13px;\n line-height: 1.8;\n}\n.empty-state .button {\n margin-top: 14px;\n}\n.tag {\n background: #f2f4ee;\n padding: 4px 7px;\n border-radius: 4px;\n font-size: 10px;\n color: #6b795e;\n}\n.status-tabs {\n display: flex;\n gap: 6px;\n overflow-x: auto;\n border-bottom: 1px solid var(--line);\n margin: 20px 0 0;\n}\n.status-tab {\n display: inline-flex;\n align-items: center;\n gap: 7px;\n padding: 10px 12px;\n color: #727b6e;\n white-space: nowrap;\n border-bottom: 2px solid transparent;\n}\n.status-tab:hover,\n.status-tab.active {\n color: #334334;\n}\n.status-tab.active {\n border-bottom-color: #718a62;\n}\n.status-tab .count {\n min-width: 18px;\n text-align: center;\n}\n.workflow-status {\n padding: 3px 7px;\n border-radius: 4px;\n background: #f2f4ee;\n color: #68745d;\n font-size: 11px;\n text-transform: capitalize;\n}\n.workflow-status.success {\n background: #edf5e8;\n color: #4d7040;\n}\n.settings-section {\n border: 1px solid var(--line);\n border-radius: 7px;\n margin-bottom: 16px;\n padding: 18px 20px;\n background: #fff;\n}\n.settings-section h2 {\n margin-bottom: 15px;\n}\n.setting-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 0;\n gap: 16px;\n}\n.setting-row + .setting-row {\n border-top: 1px solid var(--line);\n}\n.setting-row strong {\n display: block;\n font-size: 12px;\n font-weight: 550;\n}\n.setting-row p {\n font-size: 11px;\n color: var(--muted);\n margin-top: 4px;\n}\n.setting-row input[type=\"checkbox\"] {\n accent-color: #678951;\n width: 17px;\n height: 17px;\n}\n.secret-value {\n color: var(--muted);\n font-size: 12px;\n}\n.settings-note {\n color: var(--muted);\n font-size: 13px;\n line-height: 1.8;\n}\ndialog {\n width: min(470px, calc(100% - 32px));\n padding: 27px;\n border: 1px solid #dde4d5;\n border-radius: 11px;\n box-shadow: 0 20px 80px #17261530;\n color: var(--ink);\n}\ndialog::backdrop {\n background: #17211755;\n backdrop-filter: blur(3px);\n}\n.dialog-heading {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 10px;\n}\n.dialog-heading h2 {\n font-size: 19px;\n letter-spacing: -0.6px;\n}\n.dialog-description {\n font-size: 12px;\n line-height: 1.8;\n color: var(--muted);\n margin-bottom: 22px;\n}\n.form-field {\n display: block;\n margin: 17px 0;\n}\n.form-field > span {\n display: block;\n font-size: 13px;\n font-weight: 500;\n margin-bottom: 8px;\n}\n.form-field input,\n.form-field select {\n display: block;\n width: 100%;\n border: 1px solid #dfe5d8;\n border-radius: 6px;\n padding: 11px;\n background: white;\n font-size: 12px;\n color: var(--ink);\n}\n.dialog-footer {\n display: flex;\n justify-content: flex-end;\n gap: 9px;\n margin-top: 24px;\n}\n.dialog-note {\n color: #849078;\n font-size: 13px;\n line-height: 1.7;\n}\n.shortcut-row {\n display: flex;\n justify-content: space-between;\n padding: 12px 0;\n font-size: 12px;\n border-bottom: 1px solid var(--line);\n}\n.toast {\n position: fixed;\n bottom: 25px;\n left: calc(50% + 112px);\n transform: translateX(-50%);\n max-width: calc(100vw - 40px);\n background: #273321;\n color: #e6f0de;\n padding: 13px 19px;\n border: 1px solid #4f6342;\n border-radius: 7px;\n font-size: 12px;\n box-shadow: 0 8px 30px #15220c20;\n z-index: 100;\n}\nbody.compact .run-table td {\n padding-top: 12px;\n padding-bottom: 12px;\n}\n\n/* Shared secondary text stays readable on white and tinted surfaces. */\n.metric-label,\n.metric-foot,\n.count,\n.run-subtitle,\n.run-table th,\n.run-table td,\n.table-footer,\n.table-hint,\n.attention-strip p span,\n.page-footer,\n.meta-label,\n.detail-tabs button,\n.steps-heading .mono,\n.step-button .mono,\n.step-count,\n.detail-bottom,\n.tag,\n.dialog-note,\nkbd {\n color: var(--muted);\n}\n.failure-banner p {\n color: #85634f;\n}\n.search-box input::placeholder {\n color: #69745f;\n}\n.subtitle,\n.button,\n.filter-tab,\n.meta-value,\n.settings-note {\n font-size: 12px;\n}\n.run-subtitle,\n.run-table th,\n.select-filter,\n.search-box input,\n.status,\n.step-button {\n font-size: 13px;\n}\n.log-lines {\n font-size: 13px;\n}\n.detail-meta {\n overflow-wrap: anywhere;\n}\n@media (min-width: 1500px) {\n main {\n padding-top: 50px;\n }\n .run-table td {\n padding-top: 22px;\n padding-bottom: 22px;\n }\n}\n@media (max-width: 1180px) {\n main {\n padding-inline: 28px;\n }\n .page-footer {\n margin-inline: 28px;\n }\n .metric {\n padding: 19px 16px;\n }\n .tiny-bars {\n display: none;\n }\n .metric-note {\n font-size: 10px;\n }\n .metric-value {\n font-size: 25px;\n }\n .run-table th:first-child {\n width: 40%;\n }\n .run-table th:nth-child(3) {\n width: 18%;\n }\n .run-table th:nth-child(2) {\n width: 18%;\n }\n .execution {\n grid-template-columns: 195px minmax(0, 1fr);\n }\n .toast {\n left: calc(50% + 96px);\n }\n}\n@media (max-width: 900px) {\n .metric {\n padding: 17px 14px;\n }\n .metric-bottom {\n gap: 6px;\n }\n .metric-note {\n display: none;\n }\n .filters .search-box {\n width: auto;\n flex: 1;\n }\n .select-filter {\n max-width: 150px;\n }\n .run-table th:nth-child(3),\n .run-table td:nth-child(3) {\n display: none;\n }\n .run-table th:first-child {\n width: 48%;\n }\n .run-table th:nth-child(2) {\n width: 21%;\n }\n .run-table th:nth-child(4) {\n width: 14%;\n }\n .run-table th:last-child {\n width: 17%;\n }\n .detail-meta {\n grid-template-columns: repeat(3, 1fr);\n row-gap: 22px;\n }\n .run-heading {\n align-items: flex-start;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .heading-actions {\n flex-direction: column;\n align-items: stretch;\n }\n .execution {\n grid-template-columns: 175px minmax(0, 1fr);\n }\n .step-button {\n gap: 6px;\n padding-inline: 6px;\n font-size: 10px;\n }\n .step-button .mono {\n font-size: 8px;\n }\n .log-time {\n display: none;\n }\n .attention-strip p span {\n display: none;\n }\n .toast {\n left: calc(50% + 36px);\n }\n}\n@media (max-width: 600px) {\n .demo-label {\n display: none;\n }\n main {\n padding: 28px 20px 20px;\n }\n .page-heading {\n margin-bottom: 24px;\n gap: 10px;\n }\n h1 {\n font-size: 27px;\n }\n .subtitle {\n font-size: 13px;\n max-width: 210px;\n }\n .button {\n font-size: 13px;\n padding: 9px 10px;\n }\n .overview {\n grid-template-columns: 1fr 1fr;\n margin-bottom: 28px;\n }\n .metric {\n min-height: 97px;\n padding: 16px;\n }\n .metric-label {\n font-size: 13px;\n }\n .metric-value {\n font-size: 25px;\n }\n .metric-bottom {\n margin-top: 9px;\n }\n .metric:nth-child(3),\n .metric:nth-child(4) {\n border-top: 1px solid var(--line);\n }\n .metric:nth-child(3)::before {\n display: none;\n }\n .metric + .metric::before {\n top: 16px;\n bottom: 16px;\n }\n .metric-note {\n display: inline;\n }\n .tiny-bars {\n display: flex;\n }\n .metric-foot {\n display: none;\n }\n .section-heading {\n margin-bottom: 14px;\n }\n .section-heading > span {\n font-size: 10px;\n }\n .filters {\n flex-wrap: wrap;\n gap: 7px;\n }\n .filters .search-box {\n flex: 1 0 100%;\n }\n .select-filter {\n flex: 1;\n max-width: none;\n }\n .filter-tabs {\n gap: 19px;\n margin-top: 19px;\n }\n .filter-tab {\n font-size: 13px;\n gap: 4px;\n }\n .filter-tab .count {\n font-size: 8px;\n }\n .run-table th {\n font-size: 10px;\n padding-inline: 5px;\n }\n .run-table th:first-child {\n width: 67%;\n padding-left: 2px;\n }\n .run-table th:nth-child(2) {\n width: 33%;\n text-align: right;\n padding-right: 3px;\n }\n .run-table td {\n padding: 18px 3px;\n }\n .run-table td:first-child {\n padding-left: 2px;\n }\n .run-table td:nth-child(2) {\n text-align: right;\n padding-right: 3px;\n }\n .run-table th:nth-child(4),\n .run-table td:nth-child(4),\n .run-table th:last-child,\n .run-table td:last-child {\n display: none;\n }\n .run-link {\n gap: 7px;\n }\n .run-title {\n font-size: 13px;\n }\n .run-subtitle {\n font-size: 10px;\n gap: 4px;\n }\n .run-subtitle .mono {\n font-size: 8px;\n }\n .status {\n font-size: 10px;\n padding: 4px 5px;\n }\n .table-footer {\n flex-wrap: wrap;\n font-size: 10px;\n }\n .pagination {\n gap: 9px;\n }\n .table-hint {\n display: none;\n }\n .attention-strip {\n padding: 12px;\n gap: 7px;\n }\n .attention-strip p {\n font-size: 13px;\n }\n .attention-strip .text-button {\n font-size: 10px;\n }\n .page-footer {\n margin-inline: 20px;\n font-size: 8px;\n flex-wrap: wrap;\n gap: 7px;\n padding-block: 19px;\n }\n .footer-mark {\n font-size: 15px;\n }\n .toast {\n left: 50%;\n bottom: 78px;\n width: max-content;\n text-align: center;\n font-size: 13px;\n }\n .back-link {\n margin-bottom: 20px;\n }\n .run-heading {\n flex-direction: column;\n gap: 18px;\n }\n .run-heading h1 {\n font-size: 23px;\n }\n .run-heading .subtitle {\n margin-left: 0;\n max-width: none;\n font-size: 13px;\n }\n .run-heading .heading-actions {\n flex-direction: row;\n width: 100%;\n }\n .run-heading .heading-actions > * {\n flex: 1;\n max-width: none;\n }\n .detail-meta {\n grid-template-columns: repeat(2, 1fr);\n padding: 16px;\n gap: 20px 15px;\n }\n .detail-meta > div:last-child {\n grid-column: span 2;\n }\n .detail-tabs {\n gap: 20px;\n }\n .detail-tabs button {\n font-size: 13px;\n gap: 5px;\n }\n .detail-tabs button svg {\n width: 12px;\n }\n .failure-banner {\n padding: 12px;\n align-items: flex-start;\n flex-wrap: wrap;\n gap: 7px;\n }\n .failure-banner > div {\n flex: 1;\n }\n .failure-banner strong {\n font-size: 13px;\n }\n .failure-banner p {\n font-size: 10px;\n line-height: 1.6;\n }\n .failure-banner button {\n margin-left: 26px;\n font-size: 13px;\n }\n .execution {\n grid-template-columns: minmax(0, 1fr);\n }\n .steps-panel {\n border-right: 0;\n border-bottom: 1px solid var(--line);\n padding: 12px;\n }\n .steps-heading {\n padding: 3px 5px 10px;\n }\n .step-button {\n font-size: 13px;\n padding: 9px;\n margin-bottom: 1px;\n }\n .step-button .mono {\n font-size: 10px;\n }\n .step-count {\n display: none;\n }\n .log-heading {\n padding: 13px;\n }\n .log-heading h3 {\n font-size: 13px;\n }\n .log-heading h3 small {\n font-size: 8px;\n }\n .log-lines {\n font-size: 10px;\n min-height: 290px;\n }\n .log-search {\n padding-inline: 13px;\n }\n .line-number {\n width: 32px;\n padding-right: 10px;\n }\n .detail-bottom {\n gap: 10px;\n flex-wrap: wrap;\n font-size: 8px;\n }\n .setting-row {\n gap: 14px;\n }\n .settings-section {\n padding: 18px;\n }\n .setting-row p {\n font-size: 13px;\n }\n .source-view pre {\n font-size: 13px;\n padding: 17px;\n }\n}\n@media (prefers-reduced-motion: no-preference) {\n .status-icon.running svg {\n animation: spin 2.5s linear infinite;\n }\n @keyframes spin {\n to {\n transform: rotate(360deg);\n }\n }\n}\n";
|
|
12414
12414
|
//#endregion
|
|
12415
12415
|
//#region src/ui-concept.ts
|
|
12416
12416
|
function hg(e, t, n) {
|
|
@@ -12593,26 +12593,13 @@ var gg = hg(pg, mg, fg), _g = "/* global document, window, localStorage, navigat
|
|
|
12593
12593
|
let r = n.searchParams.get("page") === "secrets" ? "secrets" : n.searchParams.get("page") === "editor" ? "editor" : "workflows", i = n.searchParams.get("id") || "", a = Number(n.searchParams.get("revision"));
|
|
12594
12594
|
return this.renderPageComponent(t, r === "editor" ? "page-workflow-editor" : r === "secrets" ? "page-secrets" : "page-workflows", Xh(r, i, Number.isSafeInteger(a) && a > 0 ? a : void 0));
|
|
12595
12595
|
}
|
|
12596
|
-
if (e.method === "GET" && n.pathname === "/pages/settings.html")
|
|
12597
|
-
let r = n.searchParams.get("page"), i = r === "settings" ? "settings" : r === "notifications" ? "notifications" : r === "workers" ? "workers" : r === "timezone" ? "timezone" : "tokens";
|
|
12598
|
-
return i === "settings" && !this.requireAuthenticatedUser(e, t) || i !== "settings" && i !== "tokens" && !this.requireAdmin(e, t) ? void 0 : i === "tokens" && !this.isAdmin(e) && !this.oidc?.userFromCookie(e.headers.cookie) ? this.requireAdmin(e, t) : this.renderPageComponent(t, "page-settings", sn(i));
|
|
12599
|
-
}
|
|
12596
|
+
if (e.method === "GET" && n.pathname === "/pages/settings.html") return this.requireAuthenticatedUser(e, t) ? this.renderPageComponent(t, "page-settings", sn()) : void 0;
|
|
12600
12597
|
if (e.method === "GET" && n.pathname === "/workflows" || e.method === "GET" && n.pathname === "/settings") return this.requireAuthenticatedUser(e, t) ? this.renderAppShell(t) : void 0;
|
|
12601
12598
|
if (e.method === "GET" && /^\/settings\/workflows(?:\/|$)/.test(n.pathname)) {
|
|
12602
12599
|
if (!this.requireAuthenticatedUser(e, t)) return;
|
|
12603
12600
|
let r = n.pathname.replace(/^\/settings\/workflows/, "/workflows") + n.search;
|
|
12604
12601
|
return t.writeHead(302, { Location: r }), t.end();
|
|
12605
12602
|
}
|
|
12606
|
-
let i = n.pathname.match(/^\/settings\/(secrets|tokens|notifications|workers|timezone)$/);
|
|
12607
|
-
if (e.method === "GET" && i) {
|
|
12608
|
-
let n = i[1];
|
|
12609
|
-
if (n === "secrets") {
|
|
12610
|
-
if (!this.requireAuthenticatedUser(e, t)) return;
|
|
12611
|
-
} else if (n === "tokens") {
|
|
12612
|
-
if (!this.oidc?.userFromCookie(e.headers.cookie) && !this.isAdmin(e)) return this.requireAdmin(e, t);
|
|
12613
|
-
} else if (!this.requireAdmin(e, t)) return;
|
|
12614
|
-
return this.renderAppShell(t);
|
|
12615
|
-
}
|
|
12616
12603
|
if (n.pathname === "/api/session" && e.method === "GET") {
|
|
12617
12604
|
if (!this.requireAuthenticatedUser(e, t)) return;
|
|
12618
12605
|
let n = this.oidc?.userFromCookie(e.headers.cookie);
|
|
@@ -12663,21 +12650,21 @@ var gg = hg(pg, mg, fg), _g = "/* global document, window, localStorage, navigat
|
|
|
12663
12650
|
"Cache-Control": "no-store",
|
|
12664
12651
|
"Content-Type": "application/json; charset=utf-8"
|
|
12665
12652
|
}), t.end(JSON.stringify({ users: await this.oidcUsers.list() }))) : void 0;
|
|
12666
|
-
let
|
|
12667
|
-
if (e.method === "PUT" &&
|
|
12653
|
+
let i = n.pathname.match(/^\/api\/users\/([^/]+)\/role$/);
|
|
12654
|
+
if (e.method === "PUT" && i) {
|
|
12668
12655
|
if (!this.requireAdminSession(e, t)) return;
|
|
12669
12656
|
let n = await this.readJson(e, t);
|
|
12670
12657
|
if (!n || t.headersSent) return;
|
|
12671
12658
|
if (n.role !== "user" && n.role !== "admin") return t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "role must be either \"user\" or \"admin\"" }));
|
|
12672
12659
|
let r;
|
|
12673
12660
|
try {
|
|
12674
|
-
r = decodeURIComponent(
|
|
12661
|
+
r = decodeURIComponent(i[1]);
|
|
12675
12662
|
} catch {
|
|
12676
12663
|
return t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "Invalid user subject" }));
|
|
12677
12664
|
}
|
|
12678
|
-
let
|
|
12679
|
-
if (
|
|
12680
|
-
if (
|
|
12665
|
+
let a = await this.oidcUsers.setRole(r, n.role);
|
|
12666
|
+
if (a === "not-found") return t.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "User not found" }));
|
|
12667
|
+
if (a === "last-admin") return t.writeHead(409, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "Cannot demote the last administrator" }));
|
|
12681
12668
|
this.oidc?.setRoleForUser(r, n.role);
|
|
12682
12669
|
let o = (await this.oidcUsers.list()).find((e) => e.id === r);
|
|
12683
12670
|
return t.writeHead(200, {
|
|
@@ -12705,46 +12692,46 @@ var gg = hg(pg, mg, fg), _g = "/* global document, window, localStorage, navigat
|
|
|
12705
12692
|
}
|
|
12706
12693
|
}
|
|
12707
12694
|
}
|
|
12708
|
-
let
|
|
12709
|
-
if (
|
|
12695
|
+
let a = n.pathname.match(/^\/api\/api-keys\/([^/]+)$/);
|
|
12696
|
+
if (a && e.method === "DELETE") {
|
|
12710
12697
|
if (!this.isAdmin(e) && !this.oidc?.userFromCookie(e.headers.cookie) && !this.oidcBearer(e)) return this.requireAdmin(e, t);
|
|
12711
12698
|
if (this.oidc?.userFromCookie(e.headers.cookie) || this.oidcBearer(e)) {
|
|
12712
|
-
let n = await this.revokeOidcToken(e,
|
|
12699
|
+
let n = await this.revokeOidcToken(e, a[1]);
|
|
12713
12700
|
if (n) return t.writeHead(n.status).end();
|
|
12714
12701
|
}
|
|
12715
|
-
let n = await this.apiKeys.revoke(
|
|
12702
|
+
let n = await this.apiKeys.revoke(a[1]);
|
|
12716
12703
|
t.writeHead(n ? 204 : 404).end();
|
|
12717
12704
|
return;
|
|
12718
12705
|
}
|
|
12719
|
-
let
|
|
12720
|
-
if (e.method === "GET" &&
|
|
12706
|
+
let o = n.pathname.match(/^\/workflows\/(new|[a-z0-9-]+)$/);
|
|
12707
|
+
if (e.method === "GET" && o) return this.requireAuthenticatedUser(e, t) ? this.renderAppShell(t) : void 0;
|
|
12721
12708
|
if (e.method === "GET" && n.pathname === "/api/jobs") {
|
|
12722
12709
|
let e = n.searchParams.get("afterId"), r = n.searchParams.get("beforeId"), i = n.searchParams.get("limit"), a = n.searchParams.get("filter")?.trim() || void 0, o = n.searchParams.get("workflowId")?.trim() || void 0, s = e === null ? void 0 : Number(e), c = r === null ? void 0 : Number(r), l = i === null ? yg : Number(i);
|
|
12723
12710
|
return s !== void 0 && (!Number.isSafeInteger(s) || s < 0) ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "afterId must be a non-negative integer" }))) : c !== void 0 && (!Number.isSafeInteger(c) || c < 1) ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "beforeId must be a positive integer" }))) : !Number.isSafeInteger(l) || l < 1 || l > bg ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: `limit must be an integer from 1 to ${bg}` }))) : a && a.length > 200 ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "filter must be at most 200 characters" }))) : this.renderDashboardJobs(t, l, s, c, a, o);
|
|
12724
12711
|
}
|
|
12725
|
-
let
|
|
12726
|
-
if (e.method === "POST" &&
|
|
12727
|
-
let
|
|
12728
|
-
if (e.method === "POST" &&
|
|
12729
|
-
let
|
|
12730
|
-
if (e.method === "GET" &&
|
|
12712
|
+
let s = n.pathname.match(/^\/api\/dispatch\/([a-z0-9-]+)$/);
|
|
12713
|
+
if (e.method === "POST" && s) return this.handleDispatch(e, t, s[1]);
|
|
12714
|
+
let c = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)\/run$/);
|
|
12715
|
+
if (e.method === "POST" && c) return this.handleWorkflowRun(e, t, c[1]);
|
|
12716
|
+
let l = n.pathname.match(/^\/api\/jobs\/(\d+)\/wait$/);
|
|
12717
|
+
if (e.method === "GET" && l) return this.handleJobWait(l[1], n.searchParams.get("timeout"), t);
|
|
12731
12718
|
if (e.method === "GET" && n.pathname === "/api/events") return this.events.subscribe(e, t);
|
|
12732
12719
|
if (e.method === "GET" && n.pathname === "/api/push/public-key") return this.handlePushPublicKey(t);
|
|
12733
12720
|
if (e.method === "POST" && n.pathname === "/api/push/subscriptions") return this.handlePushSubscribe(e, t);
|
|
12734
12721
|
if (e.method === "DELETE" && n.pathname === "/api/push/subscriptions") return this.handlePushUnsubscribe(e, t);
|
|
12735
12722
|
if (n.pathname === "/api/workflows/validate" && e.method === "POST") return this.handleWorkflowValidation(e, t);
|
|
12736
12723
|
if (n.pathname === "/api/secrets" && e.method === "GET") return this.handleSecretList(e, t);
|
|
12737
|
-
let
|
|
12738
|
-
if (
|
|
12739
|
-
if (
|
|
12740
|
-
let
|
|
12741
|
-
if (
|
|
12742
|
-
let
|
|
12743
|
-
if (
|
|
12724
|
+
let u = n.pathname.match(/^\/api\/secrets\/([A-Z][A-Z0-9_]*)$/);
|
|
12725
|
+
if (u && e.method === "PUT") return this.handleSecretSave(e, t, u[1]);
|
|
12726
|
+
if (u && e.method === "DELETE") return this.handleSecretDelete(e, t, u[1]);
|
|
12727
|
+
let d = n.pathname.match(/^\/api\/jobs\/(\d+)\/secrets$/);
|
|
12728
|
+
if (d && e.method === "GET") return this.handleJobSecrets(e, t, d[1]);
|
|
12729
|
+
let p = n.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
|
|
12730
|
+
if (p && e.method === "POST") return this.handleCancelJob(e, t, p[1]);
|
|
12744
12731
|
if (n.pathname === "/api/workflows" && e.method === "GET") return this.handleWorkflowList(e, t);
|
|
12745
|
-
let
|
|
12746
|
-
if (
|
|
12747
|
-
let [, r, i] =
|
|
12732
|
+
let m = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)(\/publish)?$/);
|
|
12733
|
+
if (m) {
|
|
12734
|
+
let [, r, i] = m;
|
|
12748
12735
|
if (e.method === "GET" && !i) return this.handleWorkflowGet(e, t, r, n.searchParams.get("revision"));
|
|
12749
12736
|
if (e.method === "PUT" && !i) return this.handleWorkflowSave(e, t, r);
|
|
12750
12737
|
if (e.method === "DELETE" && !i) return this.handleWorkflowDelete(e, t, r);
|
|
@@ -12756,8 +12743,8 @@ var gg = hg(pg, mg, fg), _g = "/* global document, window, localStorage, navigat
|
|
|
12756
12743
|
let r = n.pathname.replace("/runs/", "");
|
|
12757
12744
|
return this.renderRunDetails(r, t, "html", await this.hasScope(e, "logs:read"));
|
|
12758
12745
|
}
|
|
12759
|
-
let
|
|
12760
|
-
if (e.method === "POST" &&
|
|
12746
|
+
let h = n.pathname.match(/^\/api\/runs\/(\d+)\/ai-help$/);
|
|
12747
|
+
if (e.method === "POST" && h) return this.handleAiHelp(e, t, h[1]);
|
|
12761
12748
|
if (e.method === "GET" && n.pathname.startsWith("/api/runs/")) {
|
|
12762
12749
|
let r = n.pathname.match(/^\/api\/runs\/(\d+)\/diagnostics$/);
|
|
12763
12750
|
if (r) return this.handleDiagnostics(e, t, r[1]);
|
|
@@ -13109,7 +13096,7 @@ var gg = hg(pg, mg, fg), _g = "/* global document, window, localStorage, navigat
|
|
|
13109
13096
|
if (this.isAdmin(e)) return !0;
|
|
13110
13097
|
let n = (e.url || "/").split("?")[0];
|
|
13111
13098
|
if (e.method === "GET" && !n.startsWith("/api/")) {
|
|
13112
|
-
if (this.oidc?.userFromCookie(e.headers.cookie)) return t.writeHead(302, { Location: "/settings
|
|
13099
|
+
if (this.oidc?.userFromCookie(e.headers.cookie)) return t.writeHead(302, { Location: "/settings" }), t.end(), !1;
|
|
13113
13100
|
let n = encodeURIComponent(e.url || "/settings");
|
|
13114
13101
|
return t.writeHead(302, { Location: `/auth/login?url=${n}` }), t.end(), !1;
|
|
13115
13102
|
}
|