@cloud-cli/on 1.60.0 → 1.60.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/on.js
CHANGED
|
@@ -102,7 +102,7 @@ async function m() {
|
|
|
102
102
|
let [{ QueueManager: n }, { SecretStore: r }, { WebhookServer: i }, { startWorkers: a }, { WorkflowRepository: o }, { WorkflowScheduler: s }] = await Promise.all([
|
|
103
103
|
import("./queue-DhXXzxm3.js"),
|
|
104
104
|
import("./secrets-DhkqmawU.js"),
|
|
105
|
-
import("./server-
|
|
105
|
+
import("./server-INE0Izbm.js"),
|
|
106
106
|
import("./worker-riimPLUr.js"),
|
|
107
107
|
import("./workflows-BF2K4GpM.js"),
|
|
108
108
|
import("./scheduler-2d1UPcxM.js")
|
|
@@ -10,7 +10,7 @@ import p from "node:http";
|
|
|
10
10
|
import { URL as m } from "node:url";
|
|
11
11
|
import h from "web-push";
|
|
12
12
|
//#region src/dashboard.html?raw
|
|
13
|
-
var g = "<!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 <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <title>Workflow Engine Dashboard</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n </head>\n <body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <main class=\"max-w-6xl mx-auto space-y-6 py-6\">\n <app-header title=\"Runner Engine Status\" subtitle=\"Live Job Queue & Execution Traces\"></app-header>\n <div class=\"flex justify-end gap-2 text-xs font-mono\">\n <template if=\"refreshError\">\n <span class=\"rounded border border-rose-500/20 bg-rose-500/10 px-3 py-1 text-rose-400\"\n >Refresh failed</span\n >\n </template>\n </div>\n\n <form class=\"flex items-center gap-2\" on-submit.prevent=\"applyFilter()\">\n <div class=\"relative min-w-0 flex-1\">\n <label class=\"sr-only\" for=\"job-filter\">Filter jobs</label>\n <input\n id=\"job-filter\"\n bind-value=\"filter\"\n on-input=\"setFilter($event)\"\n class=\"w-full rounded-lg border border-gray-800 bg-gray-900 px-3 py-2 pr-10 text-sm text-gray-200 outline-none placeholder:text-gray-600 focus:border-indigo-500\"\n placeholder=\"Filter: name:cloud-cli/* or text search\"\n />\n <template if=\"filter\">\n <button\n type=\"button\"\n on-click=\"clearFilter()\"\n class=\"absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-200\"\n >\n <lucide-icon icon=\"x\" size=\"15\"></lucide-icon>\n <span class=\"sr-only\">Clear job filter</span>\n </button>\n </template>\n </div>\n <button\n type=\"submit\"\n aria-label=\"Search jobs\"\n class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-indigo-500/40 bg-indigo-500/10 text-indigo-200 hover:bg-indigo-500/20\"\n >\n <lucide-icon icon=\"search\" size=\"16\"></lucide-icon>\n </button>\n </form>\n\n <template if=\"activeFilter\">\n <p class=\"flex items-center gap-1\">\n <span class=\"text-xs text-gray-400\">Showing {{ jobs.length }} jobs matching </span\n ><span class=\"font-mono text-indigo-300\">{{ activeFilter }}</span>\n </p>\n </template>\n\n <template if=\"notificationNotice\">\n <button\n class=\"w-full rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-left text-sm text-gray-300\"\n on-click=\"notificationNotice = ''\"\n >\n {{ notificationNotice }}\n </button>\n </template>\n\n <section class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden hidden md:block\">\n <table class=\"w-full text-left text-sm\">\n <thead class=\"bg-gray-800/50 text-gray-400 text-xs uppercase font-mono border-b border-gray-800\">\n <tr>\n <th class=\"py-3 px-4\">Job ID</th>\n <th class=\"py-3 px-4\">Workflow</th>\n <th class=\"py-3 px-4\">Status</th>\n <th class=\"py-3 px-4 hidden xl:table-cell\">Worker</th>\n <th class=\"py-3 px-4 hidden lg:table-cell\">Created At</th>\n <th class=\"py-3 px-4 text-right\">Action</th>\n </tr>\n </thead>\n <tbody>\n <template for=\"job of jobs\">\n <tr class=\"border-b border-gray-800 hover:bg-gray-800/40 transition\">\n <td class=\"py-3 px-4 font-mono text-indigo-400\">\n <a attr-href=\"'/runs/' + job.id\" class=\"hover:underline\">#{{ job.id }}</a>\n </td>\n <td class=\"py-3 px-4 font-medium text-white\">{{ job.workflowId }}</td>\n <td class=\"py-3 px-4\">\n <span\n class=\"inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400': job.status === 'success',\n 'bg-rose-500/10.text-rose-400': job.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.animate-pulse': job.status === 'running' || job.status === 'pending',\n 'bg-gray-500/10.text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n >\n <lucide-icon\n class=\"shrink-0\"\n bind-class=\"{\n 'text-emerald-400': job.status === 'success',\n 'text-rose-400': job.status === 'failed',\n 'text-indigo-400.animate-spin': job.status === 'running' || job.status === 'pending',\n 'text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n bind-icon=\"\n job.status === 'success'\n ? 'circle-check'\n : job.status === 'failed'\n ? 'circle-x'\n : job.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"14\"\n ></lucide-icon>\n {{ upper(job.status) }}\n </span>\n </td>\n <td class=\"py-3 px-4 hidden xl:table-cell text-xs font-mono text-gray-400\">\n {{ job.workerId || '-' }}\n </td>\n <td class=\"py-3 px-4 hidden lg:table-cell text-xs text-gray-400\">{{ job.createdAt }}</td>\n <td class=\"py-3 px-4 text-right whitespace-nowrap\">\n <a\n attr-href=\"'/runs/' + job.id\"\n class=\"text-xs bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1 rounded border border-gray-700\"\n >View Trace</a\n >\n </td>\n </tr>\n </template>\n </tbody>\n </table>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">\n {{ activeFilter ? 'No jobs match the current filter.' : 'No jobs recorded yet.' }}\n </div>\n </template>\n </section>\n\n <section\n class=\"md:hidden bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800 overflow-hidden\"\n >\n <template for=\"job of jobs\">\n <a\n attr-href=\"'/runs/' + job.id\"\n class=\"block p-4 bg-gray-900/60 hover:bg-gray-800/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-400\"\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-2\">\n <lucide-icon\n class=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-700/70\"\n bind-class=\"{\n 'text-emerald-400': job.status === 'success',\n 'text-rose-400': job.status === 'failed',\n 'text-indigo-400.animate-spin': job.status === 'running' || job.status === 'pending',\n 'text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n bind-icon=\"\n job.status === 'success'\n ? 'circle-check'\n : job.status === 'failed'\n ? 'circle-x'\n : job.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"15\"\n ></lucide-icon>\n <h2 class=\"font-medium text-white truncate\">{{ job.workflowId }}</h2>\n </div>\n <p class=\"mt-1 pl-[36px] text-xs font-mono text-gray-400\">#{{ job.id }} · {{ upper(job.status) }}</p>\n </div>\n </div>\n </a>\n </template>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">\n {{ activeFilter ? 'No jobs match the current filter.' : 'No jobs recorded yet.' }}\n </div>\n </template>\n </section>\n\n <template if=\"hasMore\">\n <div class=\"flex justify-center\">\n <button\n class=\"px-4 py-2 text-sm text-gray-200 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg\"\n on-click=\"loadMore()\"\n >\n {{ loadingMore ? 'Loading...' : 'Load more' }}\n </button>\n </div>\n </template>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref } from '@li3/web';\n\n export default function setup() {\n const jobs = ref([]);\n const refreshError = ref(false);\n const hasMore = ref(false);\n const loadingMore = ref(false);\n const filter = ref('');\n const activeFilter = ref('');\n const pushConfigured = ref(false);\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 let eventSource;\n let refreshing = false;\n let refreshPending = false;\n let searchInProgress = false;\n let refreshGeneration = 0;\n\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || !['success', 'failed'].includes(job.status))\n 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: `${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 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 =\n 'Notifications are blocked. Allow them in this site’s Android browser settings.';\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 await enablePushNotifications();\n notificationNotice.value =\n 'Notifications enabled. Background delivery is available when server push is configured.';\n } else {\n notificationNotice.value = 'Notification permission was not granted.';\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 window.runnerApiFetch('/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 saveResponse = await window.runnerApiFetch('/api/push/subscriptions', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(subscription),\n });\n if (!saveResponse.ok) throw new Error(`Push subscription failed: ${saveResponse.status}`);\n } catch (error) {\n console.error('Unable to enable background notifications', error);\n }\n };\n const disablePushNotifications = async () => {\n try {\n const registration = await navigator.serviceWorker.ready;\n const subscription = await registration.pushManager.getSubscription();\n if (!subscription) return;\n await window.runnerApiFetch('/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 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\n try {\n const activeIds = jobs.value\n .filter((job) => !terminalStatuses.has(job.status))\n .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 url =\n afterId === null\n ? `/api/jobs?limit=100${filterQuery}`\n : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}`;\n const response = await window.runnerApiFetch(url, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n\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 }\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\n const loadMore = async () => {\n if (loadingMore.value || !hasMore.value || !jobs.value.length) return;\n loadingMore.value = true;\n\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 response = await window.runnerApiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`);\n\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 applyFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\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 setFilter = (event) => {\n filter.value = event.target.value;\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 const query = params.toString();\n history.pushState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`);\n };\n const clearFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\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\n onInit(() => {\n const initialSearch = new URLSearchParams(window.location.search).get('search')?.trim() || '';\n filter.value = initialSearch;\n activeFilter.value = initialSearch;\n if (notificationsSupported.value) {\n navigator.serviceWorker\n .register('/service-worker.js')\n .then(() => {\n if (notificationsEnabled.value) void enablePushNotifications();\n })\n .catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\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();\n refreshTimer = setInterval(refreshJobs, 60000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n });\n\n return {\n jobs,\n refreshError,\n hasMore,\n loadingMore,\n filter,\n activeFilter,\n pushConfigured,\n setFilter,\n loadMore,\n applyFilter,\n clearFilter,\n upper,\n notificationsSupported,\n notificationsEnabled,\n notificationNotice,\n toggleNotifications,\n };\n }\n <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
13
|
+
var g = "<!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 <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <title>Workflow Engine Dashboard</title>\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n </head>\n <body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <main class=\"max-w-6xl mx-auto space-y-6 py-6\">\n <app-header title=\"Runner Engine Status\" subtitle=\"Live Job Queue & Execution Traces\"></app-header>\n <div class=\"flex justify-end gap-2 text-xs font-mono\">\n <template if=\"refreshError\">\n <span class=\"rounded border border-rose-500/20 bg-rose-500/10 px-3 py-1 text-rose-400\"\n >Refresh failed</span\n >\n </template>\n </div>\n\n <form class=\"flex items-center gap-2\" on-submit.prevent=\"applyFilter()\">\n <div class=\"relative min-w-0 flex-1\">\n <label class=\"sr-only\" for=\"job-filter\">Filter jobs</label>\n <input\n id=\"job-filter\"\n bind-value=\"filter\"\n on-input=\"setFilter($event)\"\n class=\"w-full rounded-lg border border-gray-800 bg-gray-900 px-3 py-2 pr-10 text-sm text-gray-200 outline-none placeholder:text-gray-600 focus:border-indigo-500\"\n placeholder=\"Filter: name:cloud-cli/* or text search\"\n />\n <template if=\"filter\">\n <button\n type=\"button\"\n on-click=\"clearFilter()\"\n class=\"absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-200\"\n >\n <lucide-icon icon=\"x\" size=\"15\"></lucide-icon>\n <span class=\"sr-only\">Clear job filter</span>\n </button>\n </template>\n </div>\n <button\n type=\"submit\"\n aria-label=\"Search jobs\"\n class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-indigo-500/40 bg-indigo-500/10 text-indigo-200 hover:bg-indigo-500/20\"\n >\n <lucide-icon icon=\"search\" size=\"16\"></lucide-icon>\n </button>\n </form>\n\n <template if=\"activeFilter\">\n <p class=\"flex items-center gap-1\">\n <span class=\"text-xs text-gray-400\">Showing {{ jobs.length }} jobs matching </span\n ><span class=\"font-mono text-indigo-300\">{{ activeFilter }}</span>\n </p>\n </template>\n\n <template if=\"notificationNotice\">\n <button\n class=\"w-full rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-left text-sm text-gray-300\"\n on-click=\"notificationNotice = ''\"\n >\n {{ notificationNotice }}\n </button>\n </template>\n\n <section class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden hidden md:block\">\n <table class=\"w-full text-left text-sm\">\n <thead class=\"bg-gray-800/50 text-gray-400 text-xs uppercase font-mono border-b border-gray-800\">\n <tr>\n <th class=\"py-3 px-4\">Job ID</th>\n <th class=\"py-3 px-4\">Workflow</th>\n <th class=\"py-3 px-4\">Status</th>\n <th class=\"py-3 px-4 hidden xl:table-cell\">Worker</th>\n <th class=\"py-3 px-4 hidden lg:table-cell\">Created At</th>\n <th class=\"py-3 px-4 text-right\">Action</th>\n </tr>\n </thead>\n <tbody>\n <template for=\"job of jobs\">\n <tr class=\"border-b border-gray-800 hover:bg-gray-800/40 transition\">\n <td class=\"py-3 px-4 font-mono text-indigo-400\">\n <a attr-href=\"'/runs/' + job.id\" class=\"hover:underline\">#{{ job.id }}</a>\n </td>\n <td class=\"py-3 px-4 font-medium text-white\">{{ job.workflowId }}</td>\n <td class=\"py-3 px-4\">\n <span\n class=\"inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400': job.status === 'success',\n 'bg-rose-500/10.text-rose-400': job.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.animate-pulse': job.status === 'running' || job.status === 'pending',\n 'bg-gray-500/10.text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n >\n <lucide-icon\n class=\"shrink-0\"\n bind-class=\"{\n 'text-emerald-400': job.status === 'success',\n 'text-rose-400': job.status === 'failed',\n 'text-indigo-400.animate-spin': job.status === 'running' || job.status === 'pending',\n 'text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n bind-icon=\"\n job.status === 'success'\n ? 'circle-check'\n : job.status === 'failed'\n ? 'circle-x'\n : job.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"14\"\n ></lucide-icon>\n {{ upper(job.status) }}\n </span>\n </td>\n <td class=\"py-3 px-4 hidden xl:table-cell text-xs font-mono text-gray-400\">\n {{ job.workerId || '-' }}\n </td>\n <td class=\"py-3 px-4 hidden lg:table-cell text-xs text-gray-400\">{{ job.createdAt }}</td>\n <td class=\"py-3 px-4 text-right whitespace-nowrap\">\n <a\n attr-href=\"'/runs/' + job.id\"\n class=\"text-xs bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1 rounded border border-gray-700\"\n >View Trace</a\n >\n </td>\n </tr>\n </template>\n </tbody>\n </table>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">\n {{ activeFilter ? 'No jobs match the current filter.' : 'No jobs recorded yet.' }}\n </div>\n </template>\n </section>\n\n <section\n class=\"md:hidden bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800 overflow-hidden\"\n >\n <template for=\"job of jobs\">\n <a\n attr-href=\"'/runs/' + job.id\"\n class=\"block p-4 bg-gray-900/60 hover:bg-gray-800/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-400\"\n >\n <div class=\"flex items-start justify-between gap-3\">\n <div class=\"min-w-0\">\n <div class=\"flex items-center gap-2\">\n <lucide-icon\n class=\"flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-gray-700/70\"\n bind-class=\"{\n 'text-emerald-400': job.status === 'success',\n 'text-rose-400': job.status === 'failed',\n 'text-indigo-400.animate-spin': job.status === 'running' || job.status === 'pending',\n 'text-gray-400': !['success', 'failed', 'running'].includes(job.status)\n }\"\n bind-icon=\"\n job.status === 'success'\n ? 'circle-check'\n : job.status === 'failed'\n ? 'circle-x'\n : job.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"15\"\n ></lucide-icon>\n <h2 class=\"font-medium text-white truncate\">{{ job.workflowId }}</h2>\n </div>\n <p class=\"mt-1 pl-[36px] text-xs font-mono text-gray-400\">#{{ job.id }} · {{ upper(job.status) }}</p>\n </div>\n </div>\n </a>\n </template>\n <template if=\"!jobs.length\">\n <div class=\"p-8 text-center text-gray-500\">\n {{ activeFilter ? 'No jobs match the current filter.' : 'No jobs recorded yet.' }}\n </div>\n </template>\n </section>\n\n <template if=\"hasMore\">\n <div class=\"flex justify-center\">\n <button\n class=\"px-4 py-2 text-sm text-gray-200 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg\"\n on-click=\"loadMore()\"\n >\n {{ loadingMore ? 'Loading...' : 'Load more' }}\n </button>\n </div>\n </template>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref } from '@li3/web';\n import { apiFetch } from '/api-client.mjs';\n\n export default function setup() {\n const jobs = ref([]);\n const refreshError = ref(false);\n const hasMore = ref(false);\n const loadingMore = ref(false);\n const filter = ref('');\n const activeFilter = ref('');\n const pushConfigured = ref(false);\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 let eventSource;\n let refreshing = false;\n let refreshPending = false;\n let searchInProgress = false;\n let refreshGeneration = 0;\n\n const upper = (value) => String(value || '').toUpperCase();\n const terminalStatuses = new Set(['success', 'failed', 'cancelled']);\n const notifyCompletedJob = async (job) => {\n if (!notificationsEnabled.value || pushConfigured.value || !['success', 'failed'].includes(job.status))\n 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: `${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 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 =\n 'Notifications are blocked. Allow them in this site’s Android browser settings.';\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 await enablePushNotifications();\n notificationNotice.value =\n 'Notifications enabled. Background delivery is available when server push is configured.';\n } else {\n notificationNotice.value = 'Notification permission was not granted.';\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 saveResponse = await apiFetch('/api/push/subscriptions', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(subscription),\n });\n if (!saveResponse.ok) throw new Error(`Push subscription failed: ${saveResponse.status}`);\n } catch (error) {\n console.error('Unable to enable background notifications', error);\n }\n };\n const disablePushNotifications = async () => {\n try {\n const registration = await navigator.serviceWorker.ready;\n const subscription = await registration.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 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\n try {\n const activeIds = jobs.value\n .filter((job) => !terminalStatuses.has(job.status))\n .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 url =\n afterId === null\n ? `/api/jobs?limit=100${filterQuery}`\n : `/api/jobs?afterId=${afterId}&limit=100${filterQuery}`;\n const response = await apiFetch(url, { headers: { accept: 'application/json' } });\n if (!response.ok) throw new Error(`Dashboard refresh failed: ${response.status}`);\n\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 }\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\n const loadMore = async () => {\n if (loadingMore.value || !hasMore.value || !jobs.value.length) return;\n loadingMore.value = true;\n\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 response = await apiFetch(`/api/jobs?beforeId=${beforeId}${filterQuery}`, {\n headers: { accept: 'application/json' },\n });\n if (!response.ok) throw new Error(`Loading older jobs failed: ${response.status}`);\n\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 applyFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\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 setFilter = (event) => {\n filter.value = event.target.value;\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 const query = params.toString();\n history.pushState(null, '', `${window.location.pathname}${query ? `?${query}` : ''}`);\n };\n const clearFilter = async () => {\n searchInProgress = true;\n refreshGeneration++;\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\n onInit(() => {\n const initialSearch = new URLSearchParams(window.location.search).get('search')?.trim() || '';\n filter.value = initialSearch;\n activeFilter.value = initialSearch;\n if (notificationsSupported.value) {\n navigator.serviceWorker\n .register('/service-worker.js')\n .then(() => {\n if (notificationsEnabled.value) void enablePushNotifications();\n })\n .catch((error) => {\n console.error('Service worker registration failed', error);\n });\n }\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();\n refreshTimer = setInterval(refreshJobs, 60000);\n });\n onDestroy(() => {\n eventSource?.close();\n clearInterval(refreshTimer);\n });\n\n return {\n jobs,\n refreshError,\n hasMore,\n loadingMore,\n filter,\n activeFilter,\n pushConfigured,\n setFilter,\n loadMore,\n applyFilter,\n clearFilter,\n upper,\n notificationsSupported,\n notificationsEnabled,\n notificationNotice,\n toggleNotifications,\n };\n }\n <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
14
14
|
//#endregion
|
|
15
15
|
//#region src/dashboard.ts
|
|
16
16
|
function _(e) {
|
|
@@ -113,7 +113,7 @@ var ee = class {
|
|
|
113
113
|
type: "image/svg+xml",
|
|
114
114
|
purpose: "any maskable"
|
|
115
115
|
}]
|
|
116
|
-
}), ne = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\">\n <rect width=\"512\" height=\"512\" rx=\"112\" fill=\"#030712\"/>\n <path d=\"M128 144h256v224H128z\" fill=\"#111827\" stroke=\"#6366f1\" stroke-width=\"24\"/>\n <path d=\"m174 218 62 38-62 38M260 300h78\" fill=\"none\" stroke=\"#f9fafb\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"24\"/>\n <circle cx=\"365\" cy=\"147\" r=\"47\" fill=\"#10b981\" stroke=\"#030712\" stroke-width=\"18\"/>\n</svg>", re = "self.addEventListener('install', () => self.skipWaiting());\nself.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));\nself.addEventListener('push', (event) => {\n const data = event.data?.json() || {};\n event.waitUntil(self.registration.showNotification(data.title || 'Runner Engine', {\n body: data.body || 'A job status changed.',\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n data: { url: data.url || '/runs' },\n }));\n});\nself.addEventListener('notificationclick', (event) => {\n event.notification.close();\n const target = event.notification.data?.url || '/runs';\n event.waitUntil((async () => {\n const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });\n const existing = windows.find((client) => new URL(client.url).pathname === target);\n if (existing) return existing.focus();\n return self.clients.openWindow(target);\n })());\n});", ie = "<!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 <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <title>Workflow Run</title>\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@li3/\": \"https://cdn.li3.dev/@li3/\",\n \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\",\n \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\"\n }\n }\n <\/script>\n <style>\n </style>\n </head>\n <body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <div class=\"max-w-7xl mx-auto space-y-4 py-6\">\n <app-header bind-title=\"report.workflowName\" bind-subtitle=\"'Run #' + report.jobId\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\n <div class=\"flex flex-wrap items-center justify-between gap-3 border-b border-gray-800 pb-4\">\n <p class=\"text-xs text-gray-400\">\n Started {{ report.startedAt }} · {{ timing }}\n <template if=\"report.workerId\"> · worker: {{ report.workerId }}</template>\n <template if=\"report.parentId\"> · from #{{ report.parentId }}</template>\n </p>\n <div class=\"flex items-center gap-2 shrink-0\">\n <span\n class=\"px-4 py-1.5 rounded-full text-sm font-semibold border\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400.border-emerald-500/20': report.status === 'success',\n 'bg-rose-500/10.text-rose-400.border-rose-500/20': report.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.border-indigo-500/20': report.status === 'running',\n 'bg-amber-500/10.text-amber-400.border-amber-500/20': !['success', 'failed', 'running'].includes(report.status)\n }\"\n >\n {{ upper(report.status) }}\n </span>\n <template if=\"report.status === 'running'\">\n <button\n class=\"text-sm text-rose-300 px-4 py-1.5 border border-rose-500/50 hover:border-rose-400 rounded-full disabled:opacity-50\"\n on-click=\"cancelJob()\"\n bind-disabled.bool=\"cancelling\"\n >\n {{ cancelling ? 'Cancelling...' : 'Cancel' }}\n </button>\n </template>\n <template if=\"report.status !== 'running'\">\n <div class=\"relative flex items-center gap-1\">\n <button class=\"text-sm text-white px-4 py-1.5 border border-gray-600 hover:border-gray-400 rounded-full\" on-click=\"restartJob()\">Restart</button>\n <details class=\"relative\">\n <summary class=\"cursor-pointer list-none rounded-full border border-gray-600 p-2 text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\" aria-label=\"More restart options\">\n <lucide-icon icon=\"chevron-down\" size=\"14\"></lucide-icon>\n </summary>\n <div class=\"absolute right-0 z-20 mt-2 w-56 rounded-xl border border-gray-700 bg-gray-900 p-2 shadow-2xl\">\n <button type=\"button\" on-click=\"openManualRestart($event)\" class=\"w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-gray-800\">Restart with inputs</button>\n </div>\n </details>\n </div>\n </template>\n </div>\n </div>\n <template if=\"manualRestartOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"w-full max-w-lg rounded-2xl border border-gray-700 bg-gray-900 p-5 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"manual-restart-title\">\n <div class=\"flex items-start justify-between gap-3\"><div><h2 id=\"manual-restart-title\" class=\"text-lg font-semibold text-white\">Restart with inputs</h2><p class=\"mt-1 text-sm text-gray-400\">These values override the original trigger inputs for the new run.</p></div><button type=\"button\" on-click=\"closeManualRestart()\" aria-label=\"Close restart dialog\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></div>\n <details class=\"mt-5 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/60\">\n <summary class=\"cursor-pointer px-3 py-2 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">Original inputs</summary>\n <code-block language=\"json\" theme=\"atom-one-dark\" bind-source=\"originalInputsJson\" class=\"block max-h-64 overflow-auto border-t border-gray-800 p-3\"></code-block>\n </details>\n <div class=\"mt-4 space-y-3\">\n <p class=\"text-xs text-gray-500\">Additional inputs override matching original values without changing the original payload.</p>\n <template for=\"entry of manualInputs\">\n <div class=\"grid grid-cols-[1fr_1fr_auto] gap-2\"><input bind-value=\"entry.key\" on-input=\"setManualKey(entry, $event)\" placeholder=\"Key\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><input bind-value=\"entry.value\" on-input=\"setManualValue(entry, $event)\" placeholder=\"Value\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><button type=\"button\" on-click=\"removeManualInput(entry)\" aria-label=\"Remove input\" class=\"rounded-lg px-2 text-rose-300 hover:bg-rose-500/10\"><lucide-icon icon=\"x\" size=\"15\"></lucide-icon></button></div>\n </template>\n <button type=\"button\" on-click=\"addManualInput()\" class=\"text-sm text-indigo-300 hover:underline\">+ Add input</button>\n </div>\n <div class=\"mt-6 flex justify-end gap-2\"><button type=\"button\" on-click=\"closeManualRestart()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><button type=\"button\" on-click=\"restartWithInputs()\" bind-disabled=\"manualRestartLoading\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ manualRestartLoading ? 'Restarting...' : 'Restart' }}</button></div>\n </div>\n </div>\n </template>\n <template if=\"previousRuns.length\">\n <details class=\"rounded-xl border border-gray-800 bg-gray-900/70\">\n <summary class=\"cursor-pointer px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Previous runs\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"run of previousRuns\">\n <li>\n <a attr-href=\"'/runs/' + run.id\" class=\"flex items-center justify-between gap-3 px-4 py-3 text-sm outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <span><span class=\"font-mono text-indigo-300\">#{{ run.id }}</span><span class=\"ml-3 text-gray-400\">{{ run.startedAt }}</span></span>\n <span class=\"font-semibold uppercase text-xs text-gray-400\">{{ upper(run.status) }}</span>\n </a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n\n <section class=\"rounded overflow-hidden border-b border-gray-800\">\n <h2 class=\"sr-only\">Execution Steps</h2>\n <template for=\"[step, index] of report.steps\">\n <details class=\"border border-b-0 border-gray-800 bg-gray-900/50 text-sm\">\n <summary\n class=\"flex flex-col md:flex-row md:items-center justify-between gap-2 p-3 bg-gray-800/40 border-b border-gray-800 cursor-pointer\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <lucide-icon\n class=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-700/70\"\n bind-class=\"{\n 'text-emerald-400': step.status === 'success',\n 'text-rose-400': step.status === 'failed',\n 'text-indigo-400.animate-spin': step.status === 'running',\n 'text-gray-400': !['success', 'failed', 'running'].includes(step.status)\n }\"\n bind-icon=\"\n step.status === 'success'\n ? 'circle-check'\n : step.status === 'failed'\n ? 'circle-x'\n : step.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"15\"\n ></lucide-icon>\n <template if=\"step.exitCode !== undefined && step.exitCode !== 0\">\n <span class=\"font-mono text-gray-500\">({{ step.exitCode }})</span>\n </template>\n <h3 class=\"font-semibold text-gray-200 truncate min-w-0 flex-1\">{{ step.name }}</h3>\n <div class=\"font-mono text-gray-400 shrink-0\">{{ step.durationMs }}ms</div>\n <span\n class=\"sr-only px-2.5 py-0.5 rounded-full font-medium\"\n bind-class=\"{\n 'text-emerald-400.bg-emerald-500/10': step.status === 'success',\n 'text-rose-400.bg-rose-500/10': step.status === 'failed',\n 'text-indigo-400.bg-indigo-500/10': step.status === 'running',\n 'text-gray-400.bg-gray-500/10': !['success', 'failed', 'running'].includes(step.status)\n }\"\n >\n {{ upper(step.status) }}\n </span>\n </div>\n </summary>\n <template if=\"report.canViewLogs\">\n <div class=\"relative p-4 bg-gray-950 font-mono text-gray-300 leading-relaxed overflow-auto w-full\">\n <template if=\"step.status === 'failed'\">\n <button type=\"button\" on-click=\"askAi(step, $event)\" aria-label=\"Get AI help for failed step\" title=\"Get AI help\" class=\"absolute right-3 top-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900 text-indigo-300 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon bind-icon=\"aiLoading && aiStepId === step.id ? 'loader-circle' : 'sparkles'\" bind-class=\"aiLoading && aiStepId === step.id ? 'animate-spin' : ''\" size=\"16\"></lucide-icon>\n </button>\n </template>\n <pre class=\"whitespace-pre-wrap\" bind-innerhtml=\"stepLog(step)\"></pre>\n </div>\n </template>\n <template if=\"!report.canViewLogs\">\n <div class=\"p-4 bg-gray-950 text-sm text-gray-500\">Logs are available to authenticated users.</div>\n </template>\n </details>\n </template>\n </section>\n\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Trigger Inputs\n </summary>\n <code-block\n language=\"json\"\n theme=\"atom-one-dark\"\n bind-source=\"inputsJson\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n <template if=\"report.workflowSourceYaml\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Workflow source YAML · revision {{ report.workflowRevision }}\n </summary>\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n bind-source=\"report.workflowSourceYaml\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n </template>\n <template if=\"report.canViewLogs && aiStepId\">\n <aside class=\"fixed bottom-4 left-4 right-4 z-40 mt-4 flex max-h-[70vh] flex-col rounded-2xl border border-indigo-500/30 bg-gray-900/95 p-4 shadow-2xl backdrop-blur lg:left-auto lg:w-[28rem]\" aria-labelledby=\"ai-help-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 pb-3\">\n <div><h2 id=\"ai-help-title\" class=\"font-semibold text-white\">AI help</h2><p class=\"mt-1 text-xs text-gray-500\">Ask about a failed step.</p></div>\n <lucide-icon icon=\"sparkles\" size=\"18\" class=\"text-indigo-300\"></lucide-icon>\n </div>\n <div class=\"min-h-0 flex-1 space-y-3 overflow-y-auto py-3\" aria-live=\"polite\">\n <p class=\"flex items-center gap-2 text-sm text-indigo-300\" class-hidden=\"!aiLoading\">\n <lucide-icon icon=\"loader-circle\" size=\"16\" class=\"animate-spin\"></lucide-icon>\n Thinking through the failed step...\n </p>\n <p class=\"text-sm text-gray-500\" class-hidden=\"aiMessages.length > 0\">Select the help icon beside a failed step to start.</p>\n <template for=\"message of aiMessages\">\n <div class=\"rounded-lg p-3 text-sm\" bind-class=\"message.role === 'assistant' ? 'bg-indigo-500/10.text-gray-200' : 'bg-gray-800.text-gray-300'\">\n <p class=\"mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500\">{{ message.role }}</p>\n <template if=\"message.html\"><div class=\"ai-markdown text-sm leading-relaxed\" bind-innerhtml=\"message.html\"></div></template>\n <template if=\"!message.html\"><pre class=\"whitespace-pre-wrap font-sans leading-relaxed\">{{ message.content }}</pre></template>\n </div>\n </template>\n <p class=\"text-sm text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n </div>\n <form class=\"border-t border-gray-800 pt-3\" on-submit.prevent=\"askFollowup()\">\n <label class=\"sr-only\" for=\"ai-question\">Ask a follow-up question</label>\n <div class=\"flex gap-2\">\n <input id=\"ai-question\" bind-value=\"aiQuestion\" placeholder=\"Ask a follow-up...\" bind-disabled=\"!aiStepId || aiLoading\" class=\"min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200 outline-none placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:opacity-50\" />\n <button type=\"submit\" aria-label=\"Ask AI\" bind-disabled=\"!aiStepId || aiLoading\" class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500 text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"><lucide-icon icon=\"arrow-up\" size=\"16\"></lucide-icon></button>\n </div>\n </form>\n </aside>\n </template>\n <template if=\"report.canViewLogs && report.artifacts.length\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Artifacts\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"artifact of report.artifacts\">\n <li class=\"px-4 py-3\">\n <a\n href=\"#\"\n on-click=\"downloadArtifact($event, artifact)\"\n class=\"font-mono text-sm text-indigo-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >{{ artifact }}</a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n </div>\n\n <script setup>\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\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 now = ref(Date.now());\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 artifactUrl = (path) => `/api/runs/${report.value.jobId}/artifacts/${encodeURIComponent(path)}`;\n const downloadArtifact = async (event, path) => {\n event.preventDefault();\n const response = await window.runnerApiFetch(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 window.runnerApiFetch(`/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) return ansiUp.ansi_to_html(step.logContent);\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 window.runnerApiFetch(`/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 window.runnerApiFetch(`/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 window.runnerApiFetch(`/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 window.runnerApiFetch(`/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 window.runnerApiFetch(`/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 }\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, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n", ae = /(?:^|[_-])auth(?:entication)?(?:$|[_-])|access[_-]?key|api[_-]?key|authorization|cookie|credential|passphrase|password|private[_-]?key|secret|session(?:id)?|signing[_-]?key|token/i;
|
|
116
|
+
}), ne = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 512 512\">\n <rect width=\"512\" height=\"512\" rx=\"112\" fill=\"#030712\"/>\n <path d=\"M128 144h256v224H128z\" fill=\"#111827\" stroke=\"#6366f1\" stroke-width=\"24\"/>\n <path d=\"m174 218 62 38-62 38M260 300h78\" fill=\"none\" stroke=\"#f9fafb\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"24\"/>\n <circle cx=\"365\" cy=\"147\" r=\"47\" fill=\"#10b981\" stroke=\"#030712\" stroke-width=\"18\"/>\n</svg>", re = "self.addEventListener('install', () => self.skipWaiting());\nself.addEventListener('activate', (event) => event.waitUntil(self.clients.claim()));\nself.addEventListener('push', (event) => {\n const data = event.data?.json() || {};\n event.waitUntil(self.registration.showNotification(data.title || 'Runner Engine', {\n body: data.body || 'A job status changed.',\n icon: '/app-icon.svg',\n badge: '/app-icon.svg',\n data: { url: data.url || '/runs' },\n }));\n});\nself.addEventListener('notificationclick', (event) => {\n event.notification.close();\n const target = event.notification.data?.url || '/runs';\n event.waitUntil((async () => {\n const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });\n const existing = windows.find((client) => new URL(client.url).pathname === target);\n if (existing) return existing.focus();\n return self.clients.openWindow(target);\n })());\n});", ie = "<!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 <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/code-block.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <title>Workflow Run</title>\n <script type=\"importmap\">\n {\n \"imports\": {\n \"@app/\": \"/\",\n \"@li3/\": \"https://cdn.li3.dev/@li3/\",\n \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\",\n \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\"\n }\n }\n <\/script>\n <style>\n </style>\n </head>\n <body class=\"bg-gray-950 text-gray-100 min-h-screen p-4 md:p-6 font-sans\">\n <template app>\n <div class=\"max-w-7xl mx-auto space-y-4 py-6\">\n <app-header bind-title=\"report.workflowName\" bind-subtitle=\"'Run #' + report.jobId\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\n <div class=\"flex flex-wrap items-center justify-between gap-3 border-b border-gray-800 pb-4\">\n <p class=\"text-xs text-gray-400\">\n Started {{ report.startedAt }} · {{ timing }}\n <template if=\"report.workerId\"> · worker: {{ report.workerId }}</template>\n <template if=\"report.parentId\"> · from #{{ report.parentId }}</template>\n </p>\n <div class=\"flex items-center gap-2 shrink-0\">\n <span\n class=\"px-4 py-1.5 rounded-full text-sm font-semibold border\"\n bind-class=\"{\n 'bg-emerald-500/10.text-emerald-400.border-emerald-500/20': report.status === 'success',\n 'bg-rose-500/10.text-rose-400.border-rose-500/20': report.status === 'failed',\n 'bg-indigo-500/10.text-indigo-400.border-indigo-500/20': report.status === 'running',\n 'bg-amber-500/10.text-amber-400.border-amber-500/20': !['success', 'failed', 'running'].includes(report.status)\n }\"\n >\n {{ upper(report.status) }}\n </span>\n <template if=\"report.status === 'running'\">\n <button\n class=\"text-sm text-rose-300 px-4 py-1.5 border border-rose-500/50 hover:border-rose-400 rounded-full disabled:opacity-50\"\n on-click=\"cancelJob()\"\n bind-disabled.bool=\"cancelling\"\n >\n {{ cancelling ? 'Cancelling...' : 'Cancel' }}\n </button>\n </template>\n <template if=\"report.status !== 'running'\">\n <div class=\"relative flex items-center gap-1\">\n <button class=\"text-sm text-white px-4 py-1.5 border border-gray-600 hover:border-gray-400 rounded-full\" on-click=\"restartJob()\">Restart</button>\n <details class=\"relative\">\n <summary class=\"cursor-pointer list-none rounded-full border border-gray-600 p-2 text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\" aria-label=\"More restart options\">\n <lucide-icon icon=\"chevron-down\" size=\"14\"></lucide-icon>\n </summary>\n <div class=\"absolute right-0 z-20 mt-2 w-56 rounded-xl border border-gray-700 bg-gray-900 p-2 shadow-2xl\">\n <button type=\"button\" on-click=\"openManualRestart($event)\" class=\"w-full rounded-lg px-3 py-2 text-left text-sm text-gray-200 hover:bg-gray-800\">Restart with inputs</button>\n </div>\n </details>\n </div>\n </template>\n </div>\n </div>\n <template if=\"manualRestartOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"w-full max-w-lg rounded-2xl border border-gray-700 bg-gray-900 p-5 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"manual-restart-title\">\n <div class=\"flex items-start justify-between gap-3\"><div><h2 id=\"manual-restart-title\" class=\"text-lg font-semibold text-white\">Restart with inputs</h2><p class=\"mt-1 text-sm text-gray-400\">These values override the original trigger inputs for the new run.</p></div><button type=\"button\" on-click=\"closeManualRestart()\" aria-label=\"Close restart dialog\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></div>\n <details class=\"mt-5 overflow-hidden rounded-xl border border-gray-800 bg-gray-950/60\">\n <summary class=\"cursor-pointer px-3 py-2 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">Original inputs</summary>\n <code-block language=\"json\" theme=\"atom-one-dark\" bind-source=\"originalInputsJson\" class=\"block max-h-64 overflow-auto border-t border-gray-800 p-3\"></code-block>\n </details>\n <div class=\"mt-4 space-y-3\">\n <p class=\"text-xs text-gray-500\">Additional inputs override matching original values without changing the original payload.</p>\n <template for=\"entry of manualInputs\">\n <div class=\"grid grid-cols-[1fr_1fr_auto] gap-2\"><input bind-value=\"entry.key\" on-input=\"setManualKey(entry, $event)\" placeholder=\"Key\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><input bind-value=\"entry.value\" on-input=\"setManualValue(entry, $event)\" placeholder=\"Value\" class=\"rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200\" /><button type=\"button\" on-click=\"removeManualInput(entry)\" aria-label=\"Remove input\" class=\"rounded-lg px-2 text-rose-300 hover:bg-rose-500/10\"><lucide-icon icon=\"x\" size=\"15\"></lucide-icon></button></div>\n </template>\n <button type=\"button\" on-click=\"addManualInput()\" class=\"text-sm text-indigo-300 hover:underline\">+ Add input</button>\n </div>\n <div class=\"mt-6 flex justify-end gap-2\"><button type=\"button\" on-click=\"closeManualRestart()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><button type=\"button\" on-click=\"restartWithInputs()\" bind-disabled=\"manualRestartLoading\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ manualRestartLoading ? 'Restarting...' : 'Restart' }}</button></div>\n </div>\n </div>\n </template>\n <template if=\"previousRuns.length\">\n <details class=\"rounded-xl border border-gray-800 bg-gray-900/70\">\n <summary class=\"cursor-pointer px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Previous runs\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"run of previousRuns\">\n <li>\n <a attr-href=\"'/runs/' + run.id\" class=\"flex items-center justify-between gap-3 px-4 py-3 text-sm outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <span><span class=\"font-mono text-indigo-300\">#{{ run.id }}</span><span class=\"ml-3 text-gray-400\">{{ run.startedAt }}</span></span>\n <span class=\"font-semibold uppercase text-xs text-gray-400\">{{ upper(run.status) }}</span>\n </a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n\n <section class=\"rounded overflow-hidden border-b border-gray-800\">\n <h2 class=\"sr-only\">Execution Steps</h2>\n <template for=\"[step, index] of report.steps\">\n <details class=\"border border-b-0 border-gray-800 bg-gray-900/50 text-sm\">\n <summary\n class=\"flex flex-col md:flex-row md:items-center justify-between gap-2 p-3 bg-gray-800/40 border-b border-gray-800 cursor-pointer\"\n >\n <div class=\"flex items-center gap-3 min-w-0\">\n <lucide-icon\n class=\"flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-700/70\"\n bind-class=\"{\n 'text-emerald-400': step.status === 'success',\n 'text-rose-400': step.status === 'failed',\n 'text-indigo-400.animate-spin': step.status === 'running',\n 'text-gray-400': !['success', 'failed', 'running'].includes(step.status)\n }\"\n bind-icon=\"\n step.status === 'success'\n ? 'circle-check'\n : step.status === 'failed'\n ? 'circle-x'\n : step.status === 'running'\n ? 'loader-circle'\n : 'circle'\n \"\n size=\"15\"\n ></lucide-icon>\n <template if=\"step.exitCode !== undefined && step.exitCode !== 0\">\n <span class=\"font-mono text-gray-500\">({{ step.exitCode }})</span>\n </template>\n <h3 class=\"font-semibold text-gray-200 truncate min-w-0 flex-1\">{{ step.name }}</h3>\n <div class=\"font-mono text-gray-400 shrink-0\">{{ step.durationMs }}ms</div>\n <span\n class=\"sr-only px-2.5 py-0.5 rounded-full font-medium\"\n bind-class=\"{\n 'text-emerald-400.bg-emerald-500/10': step.status === 'success',\n 'text-rose-400.bg-rose-500/10': step.status === 'failed',\n 'text-indigo-400.bg-indigo-500/10': step.status === 'running',\n 'text-gray-400.bg-gray-500/10': !['success', 'failed', 'running'].includes(step.status)\n }\"\n >\n {{ upper(step.status) }}\n </span>\n </div>\n </summary>\n <template if=\"report.canViewLogs\">\n <div class=\"relative p-4 bg-gray-950 font-mono text-gray-300 leading-relaxed overflow-auto w-full\">\n <template if=\"step.status === 'failed'\">\n <button type=\"button\" on-click=\"askAi(step, $event)\" aria-label=\"Get AI help for failed step\" title=\"Get AI help\" class=\"absolute right-3 top-3 z-10 inline-flex h-7 w-7 items-center justify-center rounded-lg bg-gray-900 text-indigo-300 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon bind-icon=\"aiLoading && aiStepId === step.id ? 'loader-circle' : 'sparkles'\" bind-class=\"aiLoading && aiStepId === step.id ? 'animate-spin' : ''\" size=\"16\"></lucide-icon>\n </button>\n </template>\n <pre class=\"whitespace-pre-wrap\" bind-innerhtml=\"stepLog(step)\"></pre>\n </div>\n </template>\n <template if=\"!report.canViewLogs\">\n <div class=\"p-4 bg-gray-950 text-sm text-gray-500\">Logs are available to authenticated users.</div>\n </template>\n </details>\n </template>\n </section>\n\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Trigger Inputs\n </summary>\n <code-block\n language=\"json\"\n theme=\"atom-one-dark\"\n bind-source=\"inputsJson\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n <template if=\"report.workflowSourceYaml\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Workflow source YAML · revision {{ report.workflowRevision }}\n </summary>\n <code-block\n language=\"yaml\"\n theme=\"atom-one-dark\"\n bind-source=\"report.workflowSourceYaml\"\n class=\"block max-h-[38rem] overflow-auto border-t border-gray-800 bg-gray-950 p-4\"\n ></code-block>\n </details>\n </template>\n <template if=\"report.canViewLogs && aiStepId\">\n <aside class=\"fixed bottom-4 left-4 right-4 z-40 mt-4 flex max-h-[70vh] flex-col rounded-2xl border border-indigo-500/30 bg-gray-900/95 p-4 shadow-2xl backdrop-blur lg:left-auto lg:w-[28rem]\" aria-labelledby=\"ai-help-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 pb-3\">\n <div><h2 id=\"ai-help-title\" class=\"font-semibold text-white\">AI help</h2><p class=\"mt-1 text-xs text-gray-500\">Ask about a failed step.</p></div>\n <lucide-icon icon=\"sparkles\" size=\"18\" class=\"text-indigo-300\"></lucide-icon>\n </div>\n <div class=\"min-h-0 flex-1 space-y-3 overflow-y-auto py-3\" aria-live=\"polite\">\n <p class=\"flex items-center gap-2 text-sm text-indigo-300\" class-hidden=\"!aiLoading\">\n <lucide-icon icon=\"loader-circle\" size=\"16\" class=\"animate-spin\"></lucide-icon>\n Thinking through the failed step...\n </p>\n <p class=\"text-sm text-gray-500\" class-hidden=\"aiMessages.length > 0\">Select the help icon beside a failed step to start.</p>\n <template for=\"message of aiMessages\">\n <div class=\"rounded-lg p-3 text-sm\" bind-class=\"message.role === 'assistant' ? 'bg-indigo-500/10.text-gray-200' : 'bg-gray-800.text-gray-300'\">\n <p class=\"mb-1 text-[10px] font-semibold uppercase tracking-wider text-gray-500\">{{ message.role }}</p>\n <template if=\"message.html\"><div class=\"ai-markdown text-sm leading-relaxed\" bind-innerhtml=\"message.html\"></div></template>\n <template if=\"!message.html\"><pre class=\"whitespace-pre-wrap font-sans leading-relaxed\">{{ message.content }}</pre></template>\n </div>\n </template>\n <p class=\"text-sm text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n </div>\n <form class=\"border-t border-gray-800 pt-3\" on-submit.prevent=\"askFollowup()\">\n <label class=\"sr-only\" for=\"ai-question\">Ask a follow-up question</label>\n <div class=\"flex gap-2\">\n <input id=\"ai-question\" bind-value=\"aiQuestion\" placeholder=\"Ask a follow-up...\" bind-disabled=\"!aiStepId || aiLoading\" class=\"min-w-0 flex-1 rounded-lg border border-gray-700 bg-gray-950 px-3 py-2 text-sm text-gray-200 outline-none placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:opacity-50\" />\n <button type=\"submit\" aria-label=\"Ask AI\" bind-disabled=\"!aiStepId || aiLoading\" class=\"inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-indigo-500 text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"><lucide-icon icon=\"arrow-up\" size=\"16\"></lucide-icon></button>\n </div>\n </form>\n </aside>\n </template>\n <template if=\"report.canViewLogs && report.artifacts.length\">\n <details class=\"bg-gray-900 border border-gray-800 rounded-xl overflow-hidden\">\n <summary class=\"cursor-pointer px-4 py-3 text-xs font-semibold uppercase tracking-wider text-gray-400 outline-none transition hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n Artifacts\n </summary>\n <ul class=\"divide-y divide-gray-800 border-t border-gray-800\">\n <template for=\"artifact of report.artifacts\">\n <li class=\"px-4 py-3\">\n <a\n href=\"#\"\n on-click=\"downloadArtifact($event, artifact)\"\n class=\"font-mono text-sm text-indigo-300 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >{{ artifact }}</a>\n </li>\n </template>\n </ul>\n </details>\n </template>\n </div>\n\n <script setup>\n import { computed, onDestroy, onInit, ref } from '@li3/web';\n import { AnsiUp } from 'ansi_up';\n import { marked } from 'marked';\n import { apiFetch } from '/api-client.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 now = ref(Date.now());\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 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) return ansiUp.ansi_to_html(step.logContent);\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 }\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, artifactUrl, downloadArtifact, stepLog, askAi, askFollowup, restartJob, openManualRestart, closeManualRestart, addManualInput, removeManualInput, setManualKey, setManualValue, restartWithInputs, cancelJob, cancelling };\n }\n <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n", ae = /(?:^|[_-])auth(?:entication)?(?:$|[_-])|access[_-]?key|api[_-]?key|authorization|cookie|credential|passphrase|password|private[_-]?key|secret|session(?:id)?|signing[_-]?key|token/i;
|
|
117
117
|
function oe(e, t, n, r = [], i = !0, a) {
|
|
118
118
|
let o = JSON.parse(e.payload), s = e.report ? JSON.parse(e.report) : ce(e, o, r), c = e.status;
|
|
119
119
|
return {
|
|
@@ -1805,7 +1805,7 @@ function Qt(e = !1) {
|
|
|
1805
1805
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1806
1806
|
<title>Workflow help</title>
|
|
1807
1807
|
<script type="importmap">
|
|
1808
|
-
{ "imports": { "@li3/": "https://cdn.li3.dev/@li3/" } }
|
|
1808
|
+
{ "imports": { "@app/": "/", "@li3/": "https://cdn.li3.dev/@li3/" } }
|
|
1809
1809
|
<\/script>
|
|
1810
1810
|
<link rel="component" href="/app-header.html" />
|
|
1811
1811
|
<style>${Zt}</style>
|
|
@@ -2536,7 +2536,7 @@ var $t = {
|
|
|
2536
2536
|
let t = await r.run("UPDATE api_keys SET revoked_at = CURRENT_TIMESTAMP WHERE id = ? AND revoked_at IS NULL", [e]);
|
|
2537
2537
|
return Number(t?.changes || 0) > 0;
|
|
2538
2538
|
}
|
|
2539
|
-
}, nn = "<!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\" />\n <title>Runner settings</title>\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n </head>\n <body data-page=\"__SETTINGS_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <app-header title=\"Settings\" subtitle=\"Human-admin controls and automation credentials\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\n <nav class=\"flex flex-wrap gap-1 rounded-xl border border-gray-800 bg-gray-900/70 p-1\" aria-label=\"Settings navigation\">\n <a href=\"/settings/workflows\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Secrets</a>\n <a href=\"/settings/tokens\" bind-class=\"page === 'tokens' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" bind-class=\"page === 'notifications' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n <a href=\"/settings/workers\" bind-class=\"page === 'workers' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Workers</a>\n </nav>\n\n <template if=\"page === 'tokens'\">\n </template>\n <template if=\"page === 'notifications'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"notifications-title\">\n <h2 id=\"notifications-title\" class=\"text-lg font-semibold text-white\">Notifications</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Receive job completion notifications on this device.</p>\n <button type=\"button\" on-click=\"toggleNotifications()\" class=\"mt-5 inline-flex items-center gap-2 rounded-lg border border-indigo-500/60 px-4 py-2.5 text-sm font-medium text-indigo-200 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon bind-icon=\"notificationsEnabled ? 'bell-off' : 'bell-ring'\" size=\"16\"></lucide-icon>\n {{ notificationsEnabled ? 'Disable notifications' : 'Enable notifications' }}\n </button>\n <p class=\"mt-3 text-sm text-gray-400\" role=\"status\">{{ notificationMessage }}</p>\n </section>\n </template>\n\n <template if=\"page === 'workers'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"workers-title\">\n <h2 id=\"workers-title\" class=\"text-lg font-semibold text-white\">Workers</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Connected runner status, versions, tags, and load.</p>\n <div class=\"mt-5 overflow-hidden rounded-xl border border-gray-800\"><table class=\"w-full text-left text-sm\"><thead class=\"bg-gray-950 text-xs uppercase text-gray-500\"><tr><th class=\"px-4 py-3\">Worker</th><th class=\"px-4 py-3\">Version</th><th class=\"px-4 py-3\">Tags</th><th class=\"px-4 py-3\">Load</th><th class=\"px-4 py-3\">Last seen</th></tr></thead><tbody><template for=\"worker of workers\"><tr class=\"border-t border-gray-800\"><td class=\"px-4 py-3 font-mono text-gray-200\">{{ worker.workerId }} <span class=\"ml-2 rounded-full px-2 py-0.5 text-xs\" bind-class=\"worker.online ? 'bg-emerald-500/10.text-emerald-300' : 'bg-gray-800.text-gray-500'\">{{ worker.online ? 'online' : 'offline' }}</span></td><td class=\"px-4 py-3 font-mono text-gray-400\">{{ worker.version }}</td><td class=\"px-4 py-3 text-gray-400\">{{ worker.tags.join(', ') }}</td><td class=\"px-4 py-3 text-gray-400\">{{ worker.activeJobs }} / {{ worker.concurrency }}</td><td class=\"px-4 py-3 text-gray-500\">{{ worker.lastSeen }}</td></tr></template></tbody></table><p class=\"p-5 text-sm text-gray-500\" class-hidden=\"workers.length > 0\">No worker heartbeats received yet.</p></div>\n </section>\n </template>\n\n <template if=\"page === 'tokens'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"keys-title\">\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <h2 id=\"keys-title\" class=\"text-lg font-semibold text-white\">API keys</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Issue narrowly scoped credentials for automation tools. Keys are shown only once.</p>\n </div>\n </div>\n <div class=\"mt-5 grid gap-3 rounded-xl border border-gray-800 bg-gray-950/50 p-4 md:grid-cols-[1fr_auto]\">\n <label class=\"sr-only\" for=\"key-name\">Key name</label>\n <input id=\"key-name\" bind-value=\"keyName\" on-input=\"setKeyName($event)\" placeholder=\"Documentation bot\" class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\n <button type=\"button\" on-click=\"issueKey()\" class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-500 px-4 py-2.5 text-sm font-semibold text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"><lucide-icon icon=\"key-round\" size=\"16\"></lucide-icon>Issue key</button>\n <fieldset class=\"md:col-span-2\">\n <legend class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Scopes</legend>\n <div class=\"space-y-2\">\n <template for=\"scope of scopeOptions\">\n <label class=\"group flex cursor-pointer items-center gap-3 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"scope.selected\" on-change=\"toggleScope(scope)\" class=\"peer sr-only\" />\n <span aria-hidden=\"true\" class=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-gray-600 bg-gray-900 text-transparent transition peer-checked:border-indigo-400 peer-checked:bg-indigo-500 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400/70 peer-disabled:opacity-50\">\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m3 8 3 3 7-7\"></path></svg>\n </span>\n <span class=\"group-hover:text-white\">{{ scope.label }}</span>\n </label>\n </template>\n </div>\n </fieldset>\n </div>\n <template if=\"newToken\">\n <div class=\"mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4\" role=\"alert\">\n <p class=\"text-sm font-semibold text-amber-200\">Copy this key now. It cannot be shown again.</p>\n <code class=\"mt-2 block select-all break-all rounded bg-gray-950 p-3 font-mono text-xs text-amber-100\">{{ newToken }}</code>\n </div>\n </template>\n <h3 class=\"mt-5 text-xs font-semibold uppercase tracking-wider text-gray-500\">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-gray-800 bg-gray-950/60 px-4 py-3\">\n <div class=\"min-w-0\"><p class=\"truncate text-sm font-medium text-gray-200\">{{ key.name }}</p><p class=\"text-xs text-gray-500\">{{ key.scopes.join(', ') }} · created {{ key.created_at }}</p></div>\n <button type=\"button\" on-click=\"revokeKey(key)\" class=\"shrink-0 rounded-lg p-2 text-rose-300 outline-none hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\" aria-label=\"Revoke {{ key.name }}\" title=\"Revoke key\"><lucide-icon icon=\"trash\" size=\"16\"></lucide-icon></button>\n </li>\n </template>\n </ul>\n </section>\n </template>\n <p class=\"text-sm text-rose-300\" role=\"alert\" class-hidden=\"!error\">{{ error }}</p>\n </main>\n <script setup>\n import { onInit, ref } from '@li3/web';\n export default function () {\n const page = document.body.dataset.page;\n const keys = ref([]);\n const workers = ref([]);\n const keyName = ref('');\n const newToken = ref('');\n const error = ref('');\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\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 ]);\n const api = async (url, options = {}) => { const response = await window.runnerApiFetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`); return body; };\n const load = async () => { keys.value = (await api('/api/api-keys')).keys; };\n const loadWorkers = async () => { workers.value = (await api('/api/workers')).workers; };\n const issueKey = async () => { try { error.value = ''; const result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName.value, scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name) }) }); newToken.value = result.token; window.runnerApiFetch.setToken(result.token); keyName.value = ''; await load(); } catch (reason) { error.value = reason.message; } };\n const revokeKey = async (key) => { if (!confirm(`Revoke ${key.name}?`)) return; try { await api(`/api/api-keys/${key.id}`, { method: 'DELETE' }); await load(); } catch (reason) { error.value = reason.message; } };\n const setKeyName = (event) => { keyName.value = event.target.value; };\n const toggleScope = (scope) => { scope.selected = !scope.selected; };\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) { notificationMessage.value = 'Notifications are not supported by this browser.'; return; }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); notificationMessage.value = 'Notifications enabled.'; }\n else notificationMessage.value = 'Notification permission was not granted.';\n };\n onInit(() => { if (page === 'tokens') load().catch((reason) => { error.value = reason.message; }); if (page === 'workers') loadWorkers().catch((reason) => { error.value = reason.message; }); });\n return { page, keys, workers, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n <\/script>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
2539
|
+
}, nn = "<!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\" />\n <title>Runner settings</title>\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\" } }\n <\/script>\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n </head>\n <body data-page=\"__SETTINGS_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\n <template app>\n <main class=\"mx-auto max-w-6xl space-y-5 py-6\">\n <app-header title=\"Settings\" subtitle=\"Human-admin controls and automation credentials\" back=\"/runs\" back-label=\"Back to jobs\"></app-header>\n <nav class=\"flex flex-wrap gap-1 rounded-xl border border-gray-800 bg-gray-900/70 p-1\" aria-label=\"Settings navigation\">\n <a href=\"/settings/workflows\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Secrets</a>\n <a href=\"/settings/tokens\" bind-class=\"page === 'tokens' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" bind-class=\"page === 'notifications' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n <a href=\"/settings/workers\" bind-class=\"page === 'workers' ? 'bg-gray-800.text-white' : ''\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Workers</a>\n </nav>\n\n <template if=\"page === 'tokens'\">\n </template>\n <template if=\"page === 'notifications'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"notifications-title\">\n <h2 id=\"notifications-title\" class=\"text-lg font-semibold text-white\">Notifications</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Receive job completion notifications on this device.</p>\n <button type=\"button\" on-click=\"toggleNotifications()\" class=\"mt-5 inline-flex items-center gap-2 rounded-lg border border-indigo-500/60 px-4 py-2.5 text-sm font-medium text-indigo-200 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon bind-icon=\"notificationsEnabled ? 'bell-off' : 'bell-ring'\" size=\"16\"></lucide-icon>\n {{ notificationsEnabled ? 'Disable notifications' : 'Enable notifications' }}\n </button>\n <p class=\"mt-3 text-sm text-gray-400\" role=\"status\">{{ notificationMessage }}</p>\n </section>\n </template>\n\n <template if=\"page === 'workers'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"workers-title\">\n <h2 id=\"workers-title\" class=\"text-lg font-semibold text-white\">Workers</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Connected runner status, versions, tags, and load.</p>\n <div class=\"mt-5 overflow-hidden rounded-xl border border-gray-800\"><table class=\"w-full text-left text-sm\"><thead class=\"bg-gray-950 text-xs uppercase text-gray-500\"><tr><th class=\"px-4 py-3\">Worker</th><th class=\"px-4 py-3\">Version</th><th class=\"px-4 py-3\">Tags</th><th class=\"px-4 py-3\">Load</th><th class=\"px-4 py-3\">Last seen</th></tr></thead><tbody><template for=\"worker of workers\"><tr class=\"border-t border-gray-800\"><td class=\"px-4 py-3 font-mono text-gray-200\">{{ worker.workerId }} <span class=\"ml-2 rounded-full px-2 py-0.5 text-xs\" bind-class=\"worker.online ? 'bg-emerald-500/10.text-emerald-300' : 'bg-gray-800.text-gray-500'\">{{ worker.online ? 'online' : 'offline' }}</span></td><td class=\"px-4 py-3 font-mono text-gray-400\">{{ worker.version }}</td><td class=\"px-4 py-3 text-gray-400\">{{ worker.tags.join(', ') }}</td><td class=\"px-4 py-3 text-gray-400\">{{ worker.activeJobs }} / {{ worker.concurrency }}</td><td class=\"px-4 py-3 text-gray-500\">{{ worker.lastSeen }}</td></tr></template></tbody></table><p class=\"p-5 text-sm text-gray-500\" class-hidden=\"workers.length > 0\">No worker heartbeats received yet.</p></div>\n </section>\n </template>\n\n <template if=\"page === 'tokens'\">\n <section class=\"rounded-2xl border border-gray-800 bg-gray-900/80 p-5\" aria-labelledby=\"keys-title\">\n <div class=\"flex items-start justify-between gap-3\">\n <div>\n <h2 id=\"keys-title\" class=\"text-lg font-semibold text-white\">API keys</h2>\n <p class=\"mt-1 text-sm text-gray-400\">Issue narrowly scoped credentials for automation tools. Keys are shown only once.</p>\n </div>\n </div>\n <div class=\"mt-5 grid gap-3 rounded-xl border border-gray-800 bg-gray-950/50 p-4 md:grid-cols-[1fr_auto]\">\n <label class=\"sr-only\" for=\"key-name\">Key name</label>\n <input id=\"key-name\" bind-value=\"keyName\" on-input=\"setKeyName($event)\" placeholder=\"Documentation bot\" class=\"rounded-lg border border-gray-700 bg-gray-900 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\n <button type=\"button\" on-click=\"issueKey()\" class=\"inline-flex items-center justify-center gap-2 rounded-lg bg-indigo-500 px-4 py-2.5 text-sm font-semibold text-white outline-none hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"><lucide-icon icon=\"key-round\" size=\"16\"></lucide-icon>Issue key</button>\n <fieldset class=\"md:col-span-2\">\n <legend class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Scopes</legend>\n <div class=\"space-y-2\">\n <template for=\"scope of scopeOptions\">\n <label class=\"group flex cursor-pointer items-center gap-3 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"scope.selected\" on-change=\"toggleScope(scope)\" class=\"peer sr-only\" />\n <span aria-hidden=\"true\" class=\"flex h-5 w-5 shrink-0 items-center justify-center rounded-md border border-gray-600 bg-gray-900 text-transparent transition peer-checked:border-indigo-400 peer-checked:bg-indigo-500 peer-checked:text-white peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-400/70 peer-disabled:opacity-50\">\n <svg viewBox=\"0 0 16 16\" width=\"13\" height=\"13\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m3 8 3 3 7-7\"></path></svg>\n </span>\n <span class=\"group-hover:text-white\">{{ scope.label }}</span>\n </label>\n </template>\n </div>\n </fieldset>\n </div>\n <template if=\"newToken\">\n <div class=\"mt-4 rounded-xl border border-amber-500/40 bg-amber-500/10 p-4\" role=\"alert\">\n <p class=\"text-sm font-semibold text-amber-200\">Copy this key now. It cannot be shown again.</p>\n <code class=\"mt-2 block select-all break-all rounded bg-gray-950 p-3 font-mono text-xs text-amber-100\">{{ newToken }}</code>\n </div>\n </template>\n <h3 class=\"mt-5 text-xs font-semibold uppercase tracking-wider text-gray-500\">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-gray-800 bg-gray-950/60 px-4 py-3\">\n <div class=\"min-w-0\"><p class=\"truncate text-sm font-medium text-gray-200\">{{ key.name }}</p><p class=\"text-xs text-gray-500\">{{ key.scopes.join(', ') }} · created {{ key.created_at }}</p></div>\n <button type=\"button\" on-click=\"revokeKey(key)\" class=\"shrink-0 rounded-lg p-2 text-rose-300 outline-none hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\" aria-label=\"Revoke {{ key.name }}\" title=\"Revoke key\"><lucide-icon icon=\"trash\" size=\"16\"></lucide-icon></button>\n </li>\n </template>\n </ul>\n </section>\n </template>\n <p class=\"text-sm text-rose-300\" role=\"alert\" class-hidden=\"!error\">{{ error }}</p>\n </main>\n <script setup>\n import { onInit, ref } from '@li3/web';\n import { apiFetch, setApiToken } from '/api-client.mjs';\n export default function () {\n const page = document.body.dataset.page;\n const keys = ref([]);\n const workers = ref([]);\n const keyName = ref('');\n const newToken = ref('');\n const error = ref('');\n const notificationsSupported = ref('Notification' in window && 'serviceWorker' in navigator);\n const notificationsEnabled = ref(notificationsSupported.value && Notification.permission === 'granted' && localStorage.getItem('runner-notifications') === 'enabled');\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 ]);\n const api = async (url, options = {}) => { const response = await apiFetch(url, { headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) }, ...options }); const body = await response.json().catch(() => ({})); if (!response.ok) throw new Error(body.error || `Request failed: ${response.status}`); return body; };\n const load = async () => { keys.value = (await api('/api/api-keys')).keys; };\n const loadWorkers = async () => { workers.value = (await api('/api/workers')).workers; };\n const issueKey = async () => { try { error.value = ''; const result = await api('/api/api-keys', { method: 'POST', body: JSON.stringify({ name: keyName.value, scopes: scopeOptions.value.filter((scope) => scope.selected).map((scope) => scope.name) }) }); newToken.value = result.token; setApiToken(result.token); keyName.value = ''; await load(); } catch (reason) { error.value = reason.message; } };\n const revokeKey = async (key) => { if (!confirm(`Revoke ${key.name}?`)) return; try { await api(`/api/api-keys/${key.id}`, { method: 'DELETE' }); await load(); } catch (reason) { error.value = reason.message; } };\n const setKeyName = (event) => { keyName.value = event.target.value; };\n const toggleScope = (scope) => { scope.selected = !scope.selected; };\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) { notificationMessage.value = 'Notifications are not supported by this browser.'; return; }\n const permission = await Notification.requestPermission();\n notificationsEnabled.value = permission === 'granted';\n if (notificationsEnabled.value) { localStorage.setItem('runner-notifications', 'enabled'); notificationMessage.value = 'Notifications enabled.'; }\n else notificationMessage.value = 'Notification permission was not granted.';\n };\n onInit(() => { if (page === 'tokens') load().catch((reason) => { error.value = reason.message; }); if (page === 'workers') loadWorkers().catch((reason) => { error.value = reason.message; }); });\n return { page, keys, workers, keyName, newToken, error, scopeOptions, issueKey, revokeKey, setKeyName, toggleScope, notificationsEnabled, notificationMessage, toggleNotifications };\n }\n <\/script>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
2540
2540
|
//#endregion
|
|
2541
2541
|
//#region src/settings-ui.ts
|
|
2542
2542
|
function rn(e = "tokens") {
|
|
@@ -12484,7 +12484,7 @@ async function Vh(e, t, n, r) {
|
|
|
12484
12484
|
}
|
|
12485
12485
|
//#endregion
|
|
12486
12486
|
//#region src/app-header.html?raw
|
|
12487
|
-
var Hh = "<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 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\" attr-href=\"back\" attr-aria-label=\"backLabel || 'Back'\" attr-title=\"backLabel || 'Back'\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"></path></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=\"authConfigured && !authenticated\">\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/login\">Sign in</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 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\" href=\"/help\" aria-label=\"Help\" title=\"Help\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"></circle><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><path d=\"M12 17h.01\"></path></svg>\n </a>\n <a 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\" href=\"/settings\" aria-label=\"Settings\" title=\"Settings\">\n <lucide-icon icon=\"settings\" size=\"16\"></lucide-icon>\n </a>\n </div>\n </header>\n <script setup>\n import { defineProp, onInit, ref } from '@li3/web';\n export default function () {\n const authConfigured = ref(false);\n const authenticated = ref(false);\n const userName = 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 } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName };\n }\n <\/script>\n</template>\n", Uh = "<!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\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <script type=\"importmap\">\n { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\", \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\" } }\n <\/script>\n <link rel=\"component\" href=\"/app-router.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\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 <title>On Workflow Runner</title>\n </head>\n <body class=\"min-h-screen bg-gray-950 text-gray-100 font-sans\">\n <script>\n window.runnerApiFetch = (() => {\n const rawFetch = window.fetch.bind(window);\n let token = sessionStorage.getItem('runner-api-token') || '';\n let expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n const loadToken = async () => {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', { headers: { accept: 'application/json' } });\n if (!response.ok) return '';\n const data = await response.json();\n token = data.access_token || '';\n expiresAt = Number(data.expires_at || 0);\n return token;\n };\n const apiFetch = async (input, init = {}) => {\n const target = new URL(input, window.location.href);\n if (target.origin !== window.location.origin || (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))) {\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 apiFetch.setToken = (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 return apiFetch;\n })();\n <\/script>\n <template app>\n <app-router></app-router>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n", Wh = "<template component=\"app-router\">\n <div ref=\"outlet\" aria-live=\"polite\"></div>\n <script setup>\n import { getElement, onDestroy, onInit, templateRef } from '@li3/web';\n import { load } from '@li3/web';\n\n export default function () {\n const outlet = templateRef('outlet');\n let navigating = false;\n\n const route = () => {\n const url = new URL(window.location.href);\n const path = url.pathname;\n if (path === '/' || path === '/runs') return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n if (path.match(/^\\/runs\\/\\d+$/)) 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/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n if (path === '/settings/secrets') return { component: 'page-secrets', url: '/pages/workflows.html?page=secrets', page: 'secrets' };\n if (path === '/settings/tokens') return { component: 'page-settings', url: '/pages/settings.html?page=tokens', page: 'tokens' };\n if (path === '/settings/notifications') return { component: 'page-settings', url: '/pages/settings.html?page=notifications', page: 'notifications' };\n if (path === '/settings/workers') return { component: 'page-settings', url: '/pages/settings.html?page=workers', page: 'workers' };\n if (path === '/settings' || path === '/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n const editor = path.match(/^\\/settings\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (editor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${editor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n };\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 await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\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 (target.origin !== window.location.origin || !target.pathname.startsWith('/')) return;\n if (target.pathname.startsWith('/auth/')) 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 <\/script>\n</template>\n", Gh = "<!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 { \"imports\": { \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\" } }\n <\/script>\n <style>\n body[data-page='list'] .editor-panel {\n display: none;\n }\n body[data-page='workflows'] .editor-panel,\n body[data-page='workflows'] .secrets-panel,\n body[data-page='secrets'] .workflow-list,\n body[data-page='secrets'] .editor-panel {\n display: none;\n }\n body[data-page='editor'] .workflow-list,\n body[data-page='editor'] .secrets-panel {\n display: none;\n }\n </style>\n </head>\n <body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\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'\"><app-header title=\"Secrets\" subtitle=\"Write-only credentials available to workflows\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <template if=\"page !== 'secrets'\"><app-header title=\"Workflow control room\" subtitle=\"Draft, validate, and publish revisioned YAML definitions\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <nav class=\"flex flex-wrap gap-1 rounded-xl border border-gray-800 bg-gray-900/70 p-1\" aria-label=\"Settings navigation\">\n <a href=\"/settings/workflows\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'workflows' || page === 'editor' ? 'bg-gray-800.text-white' : ''\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'secrets' ? 'bg-gray-800.text-white' : ''\">Secrets</a>\n <a href=\"/settings/tokens\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n </nav>\n\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-rose-500/40 bg-rose-500/10 text-rose-100' : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100'\"\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-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-400\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-white\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li>\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"group flex min-h-16 items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left text-sm text-gray-300 outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n >\n <span\n class=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition group-hover:bg-indigo-500/15 group-hover:text-indigo-300\"\n >\n <lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>\n </span>\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate text-base font-semibold text-gray-100\">{{ workflow.name }}</span>\n <span class=\"mt-0.5 block truncate font-mono text-[11px] text-gray-500\">{{ workflow.id }}</span>\n </span>\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-emerald-500/30 bg-emerald-500/10 text-emerald-300' : workflow.status === 'archived' ? 'border-gray-700 bg-gray-800 text-gray-500' : 'border-amber-500/30 bg-amber-500/10 text-amber-300'\"\n >{{ workflow.status }}</span\n >\n <lucide-icon\n icon=\"arrow-up-right\"\n size=\"16\"\n class=\"shrink-0 text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-gray-500\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\"\n >\n <div class=\"border-b border-gray-800 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-gray-400 outline-none transition hover:bg-gray-800 hover:text-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\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-gray-800 bg-gray-900 px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <label class=\"block max-w-xl flex-1 text-xs font-semibold uppercase tracking-wider text-gray-400\"\n >Workflow ID\n <input\n bind-value=\"workflowId\"\n on-input=\"setWorkflowId($event)\"\n bind-disabled=\"selectedId\"\n aria-describedby=\"workflow-id-help\"\n class=\"mt-2 w-full rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm normal-case tracking-normal text-white outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\"\n placeholder=\"derived-from-workflow-name\"\n />\n <span\n id=\"workflow-id-help\"\n class=\"mt-1 block text-[11px] font-normal normal-case tracking-normal text-gray-500\"\n >The ID becomes permanent after the first save.</span\n >\n </label>\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-gray-700 bg-gray-950 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button type=\"button\" aria-label=\"Get AI workflow help\" title=\"Get AI workflow help\" on-click=\"openAiHelp()\" class=\"inline-flex items-center gap-2 rounded-lg border border-indigo-500/50 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon icon=\"sparkles\" size=\"16\"></lucide-icon><span class=\"hidden sm: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 gap-2 rounded-lg bg-indigo-500 px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm: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 gap-2 rounded-lg border border-emerald-500/50 px-3 py-2.5 text-sm font-medium text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Publish</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 gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm: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-gray-500\">\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-emerald-400': 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-rose-500/10 text-rose-100' : ''\"\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-indigo-300 outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >Revision {{ revision }}</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-gray-700 bg-gray-950 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-gray-700 bg-gray-950\"\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-gray-800 bg-gray-950/50\"\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-gray-300 outline-none transition hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\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-gray-500\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-gray-800 bg-gray-950 px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-gray-500\">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 class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"workflow-ai-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 px-5 py-4\"><div><h2 id=\"workflow-ai-title\" class=\"text-lg font-semibold text-white\">AI workflow help</h2><p class=\"mt-1 text-sm text-gray-400\">Describe the change you want. The suggestion will not modify the editor until you accept it.</p></div><button type=\"button\" on-click=\"closeAiHelp()\" aria-label=\"Close AI help\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></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 id=\"workflow-ai-request\" bind-value=\"aiRequest\" placeholder=\"Example: Add a Docker cache volume to the build step.\" class=\"min-h-24 w-full rounded-xl border border-gray-700 bg-gray-950 p-3 text-sm text-gray-200 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"></textarea>\n <p class=\"mt-2 text-xs text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n <template if=\"aiDiffHtml\">\n <div class=\"mt-4\"><p class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Suggested changes</p><pre class=\"max-h-[36rem] overflow-auto rounded-xl border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"aiDiffHtml\"></pre></div>\n </template>\n </div>\n <div class=\"flex items-center justify-end gap-2 border-t border-gray-800 px-5 py-3\"><button type=\"button\" on-click=\"closeAiHelp()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><template if=\"aiDiffHtml\"><button type=\"button\" on-click=\"rejectAiSuggestion()\" class=\"rounded-lg border border-rose-500/50 px-3 py-2 text-sm text-rose-300 hover:bg-rose-500/10\">Reject</button><button type=\"button\" on-click=\"acceptAiSuggestion()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400\">Accept suggestion</button></template><button type=\"button\" on-click=\"requestAiHelp()\" bind-disabled=\"aiLoading || !aiRequest.trim()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ aiLoading ? 'Thinking...' : 'Suggest' }}</button></div>\n </div>\n </div>\n </template>\n\n <template if=\"revisionDialogOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div\n class=\"flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 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-gray-800 px-4 py-3\">\n <div>\n <h2 id=\"revision-dialog-title\" class=\"font-semibold text-white\">Workflow revision {{ revisionNumber }}</h2>\n <p class=\"text-xs text-gray-500\">Read-only source snapshot</p>\n </div>\n <button type=\"button\" on-click=\"closeRevisionDialog()\" aria-label=\"Close revision dialog\" class=\"rounded-lg p-2 text-gray-400 outline-none hover:bg-gray-800 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\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-rose-300\" class-hidden=\"!revisionError\">{{ revisionError }}</p>\n <pre class=\"min-h-[20rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"revisionDiffHtml\"></pre>\n </div>\n <div class=\"flex items-center justify-between border-t border-gray-800 px-4 py-3\">\n <button type=\"button\" on-click=\"navigateRevision(-1)\" bind-disabled=\"revisionLoading || revisionNumber <= 1\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\n <lucide-icon icon=\"chevron-left\" size=\"16\"></lucide-icon>Previous\n </button>\n <button type=\"button\" on-click=\"navigateRevision(1)\" bind-disabled=\"revisionLoading || revisionNumber >= revision\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\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-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 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-indigo-500/10 p-2 text-indigo-300\"\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-white\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-gray-400\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\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-gray-800 bg-gray-950/50\">\n <summary class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\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-gray-800 p-4\">\n <p class=\"text-xs text-gray-500\">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-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"fileMode\" on-change=\"setFileMode($event)\" class=\"h-4 w-4 rounded border-gray-700 bg-gray-900 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\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-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\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-gray-700 bg-gray-900 px-3 py-2 text-xs text-gray-400 file:mr-3 file:rounded file:border-0 file:bg-gray-700 file:px-2 file:py-1 file:text-xs file:text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30\"\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-indigo-500/60 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none transition hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\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 class=\"flex min-h-14 items-center gap-2 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3\">\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group flex min-w-0 flex-1 items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\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-gray-800 text-gray-500 group-hover:text-indigo-300\"\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-gray-200\">{{ name }}</span>\n <span class=\"text-xs text-gray-500\">Select</span>\n <lucide-icon icon=\"chevron-right\" size=\"16\" class=\"text-gray-600 transition group-hover:text-indigo-300\"></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-rose-300 outline-none transition hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\"\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-gray-700 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-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-gray-500\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n import { diffLines } from 'diff';\n\n export default function () {\n const page = document.body.dataset.page;\n const initialId = __WORKFLOW_ID__;\n const initialRevision = __WORKFLOW_REVISION__;\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('');\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 api = async (url, options = {}) => {\n const response = await window.runnerApiFetch(url, {\n headers: { accept: 'application/json', ...(options.body ? { 'content-type': 'application/json' } : {}) },\n ...options,\n });\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 derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/(^-|-$)/g, '') || '';\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\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/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 });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = workflowId.value.trim() || derivedId();\n if (!id) throw new Error('Set an ID or add a workflow name first.');\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 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();\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value)) 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(fileMode.value ? { value: secretFileData.value, encoding: 'base64', originalName: secretFileName.value } : { value: secretValue.value, encoding: 'utf8' }),\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();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n if (secretName.value.trim() === 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 setWorkflowId = (event) => {\n workflowId.value = event.target.value;\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 = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\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) => value.replace(/[&<>\"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character] || '');\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null) return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource).map((part) => {\n const className = part.added ? 'text-emerald-300 bg-emerald-500/10' : part.removed ? 'text-rose-300 bg-rose-500/10' : 'text-gray-300';\n const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';\n return part.value.split('\\n').filter((line, index, lines) => index < lines.length - 1 || line).map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`).join('');\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 = () => { if (!aiLoading.value) aiDialogOpen.value = false; };\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 window.runnerApiFetch('/api/ai/workflow-help', {\n method: 'POST',\n headers: { accept: 'text/event-stream', 'content-type': 'application/json' },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || `AI help failed: ${response.status}`); }\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.replace(/^```(?:yaml|yml)?\\s*/i, '').replace(/\\s*```$/i, '').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 = () => { source.value = aiSuggestion.value; aiDialogOpen.value = false; validate(); };\n const rejectAiSuggestion = () => { aiSuggestion.value = ''; aiDiffHtml.value = ''; };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage = page === 'editor'\n ? initialId ? openWorkflow(initialId) : Promise.resolve()\n : page === 'secrets' ? loadSecrets() : 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 remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\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 <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
12487
|
+
var Hh = "<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 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\" attr-href=\"back\" attr-aria-label=\"backLabel || 'Back'\" attr-title=\"backLabel || 'Back'\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"17\" height=\"17\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"m15 18-6-6 6-6\"></path></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=\"authConfigured && !authenticated\">\n <a class=\"text-xs text-indigo-300 hover:text-white\" href=\"/auth/login\">Sign in</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 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\" href=\"/help\" aria-label=\"Help\" title=\"Help\">\n <svg aria-hidden=\"true\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.8\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><circle cx=\"12\" cy=\"12\" r=\"9\"></circle><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><path d=\"M12 17h.01\"></path></svg>\n </a>\n <a 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\" href=\"/settings\" aria-label=\"Settings\" title=\"Settings\">\n <lucide-icon icon=\"settings\" size=\"16\"></lucide-icon>\n </a>\n </div>\n </header>\n <script setup src=\"@app/app-header.mjs\"><\/script>\n</template>\n", Uh = "<!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\" />\n <meta name=\"theme-color\" content=\"#030712\" />\n <link rel=\"manifest\" href=\"/manifest.webmanifest\" />\n <link rel=\"icon\" href=\"/app-icon.svg\" type=\"image/svg+xml\" />\n <script type=\"importmap\">\n { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"ansi_up\": \"https://esm.sh/ansi_up@6.0.6\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\", \"marked\": \"https://cdn.jsdelivr.net/npm/marked@18.0.12/lib/marked.esm.js\" } }\n <\/script>\n <link rel=\"component\" href=\"/app-router.html\" />\n <link rel=\"component\" href=\"/app-header.html\" />\n <link rel=\"component\" href=\"https://sodium.static.apphor.de/lucide-icon.html\" />\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 <title>On Workflow Runner</title>\n </head>\n <body class=\"min-h-screen bg-gray-950 text-gray-100 font-sans\">\n <template app>\n <app-router></app-router>\n </template>\n <script type=\"module\">import '@li3/web';<\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n", Wh = "<template component=\"app-router\">\n <div ref=\"outlet\" aria-live=\"polite\"></div>\n <script setup src=\"@app/app-router.mjs\"><\/script>\n</template>\n", Gh = "<!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 { \"imports\": { \"@app/\": \"/\", \"@li3/\": \"https://cdn.li3.dev/@li3/\", \"diff\": \"https://unpkg.com/diff@9.0.0/libesm/index.js\" } }\n <\/script>\n <style>\n body[data-page='list'] .editor-panel {\n display: none;\n }\n body[data-page='workflows'] .editor-panel,\n body[data-page='workflows'] .secrets-panel,\n body[data-page='secrets'] .workflow-list,\n body[data-page='secrets'] .editor-panel {\n display: none;\n }\n body[data-page='editor'] .workflow-list,\n body[data-page='editor'] .secrets-panel {\n display: none;\n }\n </style>\n </head>\n <body data-page=\"__WORKFLOW_PAGE__\" class=\"min-h-screen bg-gray-950 p-4 font-sans text-gray-100 md:p-6\">\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'\"><app-header title=\"Secrets\" subtitle=\"Write-only credentials available to workflows\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <template if=\"page !== 'secrets'\"><app-header title=\"Workflow control room\" subtitle=\"Draft, validate, and publish revisioned YAML definitions\" back=\"/runs\" back-label=\"Back to dashboard\"></app-header></template>\n <nav class=\"flex flex-wrap gap-1 rounded-xl border border-gray-800 bg-gray-900/70 p-1\" aria-label=\"Settings navigation\">\n <a href=\"/settings/workflows\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'workflows' || page === 'editor' ? 'bg-gray-800.text-white' : ''\">Workflows</a>\n <a href=\"/settings/secrets\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\" bind-class=\"page === 'secrets' ? 'bg-gray-800.text-white' : ''\">Secrets</a>\n <a href=\"/settings/tokens\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">API tokens</a>\n <a href=\"/settings/notifications\" class=\"rounded-lg px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Notifications</a>\n </nav>\n\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-rose-500/40 bg-rose-500/10 text-rose-100' : 'border-emerald-500/40 bg-emerald-500/10 text-emerald-100'\"\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-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10\"\n aria-labelledby=\"workflow-list-title\"\n >\n <div class=\"mb-4 flex items-end justify-between gap-3 border-b border-gray-800 pb-4\">\n <div>\n <p class=\"text-[11px] font-semibold uppercase tracking-[0.18em] text-indigo-400\">Definitions</p>\n <h2 id=\"workflow-list-title\" class=\"mt-1 text-lg font-semibold text-white\">Workflows</h2>\n </div>\n <a\n href=\"/workflows/new\"\n class=\"inline-flex items-center gap-2 rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white outline-none transition hover:bg-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >\n <lucide-icon icon=\"plus\" size=\"16\"></lucide-icon>\n New workflow\n </a>\n </div>\n <ul class=\"grid gap-2\" aria-label=\"Workflow definitions\">\n <template for=\"workflow of workflows\">\n <li>\n <a\n attr-href=\"'/workflows/' + workflow.id\"\n class=\"group flex min-h-16 items-center gap-3 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3 text-left text-sm text-gray-300 outline-none transition hover:border-indigo-500/50 hover:bg-indigo-500/10 focus-visible:border-indigo-400 focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\n >\n <span\n class=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gray-800 text-gray-400 transition group-hover:bg-indigo-500/15 group-hover:text-indigo-300\"\n >\n <lucide-icon icon=\"workflow\" size=\"17\"></lucide-icon>\n </span>\n <span class=\"min-w-0 flex-1\">\n <span class=\"block truncate text-base font-semibold text-gray-100\">{{ workflow.name }}</span>\n <span class=\"mt-0.5 block truncate font-mono text-[11px] text-gray-500\">{{ workflow.id }}</span>\n </span>\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-emerald-500/30 bg-emerald-500/10 text-emerald-300' : workflow.status === 'archived' ? 'border-gray-700 bg-gray-800 text-gray-500' : 'border-amber-500/30 bg-amber-500/10 text-amber-300'\"\n >{{ workflow.status }}</span\n >\n <lucide-icon\n icon=\"arrow-up-right\"\n size=\"16\"\n class=\"shrink-0 text-gray-600 transition group-hover:text-indigo-300\"\n ></lucide-icon>\n </a>\n </li>\n </template>\n </ul>\n <div\n class=\"rounded-xl border border-dashed border-gray-700 px-4 py-8 text-center\"\n class-hidden=\"workflows.length > 0\"\n >\n <lucide-icon icon=\"workflow\" size=\"22\" class=\"mx-auto text-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No workflows yet</p>\n <p class=\"mt-1 text-xs text-gray-500\">Create a definition to start automating jobs.</p>\n </div>\n </nav>\n\n <section\n class=\"editor-panel overflow-hidden rounded-xl border border-gray-800 bg-gray-900/80 shadow-xl shadow-black/10\"\n >\n <div class=\"border-b border-gray-800 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-gray-400 outline-none transition hover:bg-gray-800 hover:text-gray-100 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\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-gray-800 bg-gray-900 px-4 py-4 sm:px-5\">\n <div class=\"flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between\">\n <label class=\"block max-w-xl flex-1 text-xs font-semibold uppercase tracking-wider text-gray-400\"\n >Workflow ID\n <input\n bind-value=\"workflowId\"\n on-input=\"setWorkflowId($event)\"\n bind-disabled=\"selectedId\"\n aria-describedby=\"workflow-id-help\"\n class=\"mt-2 w-full rounded-lg border border-gray-700 bg-gray-950 px-3 py-2.5 font-mono text-sm normal-case tracking-normal text-white outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/20\"\n placeholder=\"derived-from-workflow-name\"\n />\n <span\n id=\"workflow-id-help\"\n class=\"mt-1 block text-[11px] font-normal normal-case tracking-normal text-gray-500\"\n >The ID becomes permanent after the first save.</span\n >\n </label>\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input\n type=\"checkbox\"\n bind-checked=\"enabled\"\n on-change=\"setEnabled($event)\"\n class=\"h-4 w-4 rounded border-gray-700 bg-gray-950 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n />\n Enabled\n </label>\n <div class=\"flex flex-wrap gap-2\" class-hidden=\"readOnly\">\n <button type=\"button\" aria-label=\"Get AI workflow help\" title=\"Get AI workflow help\" on-click=\"openAiHelp()\" class=\"inline-flex items-center gap-2 rounded-lg border border-indigo-500/50 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\n <lucide-icon icon=\"sparkles\" size=\"16\"></lucide-icon><span class=\"hidden sm: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 gap-2 rounded-lg bg-indigo-500 px-3 py-2.5 text-sm font-semibold text-white transition hover:bg-indigo-400 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"save\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm: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 gap-2 rounded-lg border border-emerald-500/50 px-3 py-2.5 text-sm font-medium text-emerald-300 transition hover:bg-emerald-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"rocket\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm:inline\">Publish</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 gap-2 rounded-lg border border-rose-500/50 px-3 py-2.5 text-sm font-medium text-rose-300 transition hover:bg-rose-500/10 disabled:cursor-not-allowed disabled:opacity-50\"\n >\n <lucide-icon icon=\"trash\" size=\"16\"></lucide-icon>\n <span class=\"hidden sm: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-gray-500\">\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-emerald-400': 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-rose-500/10 text-rose-100' : ''\"\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-indigo-300 outline-none hover:underline focus-visible:rounded focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\n >Revision {{ revision }}</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-gray-700 bg-gray-950 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-gray-700 bg-gray-950\"\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-gray-800 bg-gray-950/50\"\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-gray-300 outline-none transition hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\"\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-gray-500\">Reference, tutorials, and examples</span>\n </summary>\n <div\n ref=\"helpContent\"\n class=\"max-h-[38rem] overflow-y-auto border-t border-gray-800 bg-gray-950 px-4 py-5\"\n role=\"region\"\n aria-label=\"Workflow syntax help\"\n aria-live=\"polite\"\n >\n <p class=\"text-sm text-gray-500\">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 class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div class=\"flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 shadow-2xl\" role=\"dialog\" aria-modal=\"true\" aria-labelledby=\"workflow-ai-title\">\n <div class=\"flex items-start justify-between gap-3 border-b border-gray-800 px-5 py-4\"><div><h2 id=\"workflow-ai-title\" class=\"text-lg font-semibold text-white\">AI workflow help</h2><p class=\"mt-1 text-sm text-gray-400\">Describe the change you want. The suggestion will not modify the editor until you accept it.</p></div><button type=\"button\" on-click=\"closeAiHelp()\" aria-label=\"Close AI help\" class=\"rounded-lg p-2 text-gray-400 hover:bg-gray-800\"><lucide-icon icon=\"x\" size=\"17\"></lucide-icon></button></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 id=\"workflow-ai-request\" bind-value=\"aiRequest\" placeholder=\"Example: Add a Docker cache volume to the build step.\" class=\"min-h-24 w-full rounded-xl border border-gray-700 bg-gray-950 p-3 text-sm text-gray-200 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"></textarea>\n <p class=\"mt-2 text-xs text-rose-300\" class-hidden=\"!aiError\">{{ aiError }}</p>\n <template if=\"aiDiffHtml\">\n <div class=\"mt-4\"><p class=\"mb-2 text-xs font-semibold uppercase tracking-wider text-gray-500\">Suggested changes</p><pre class=\"max-h-[36rem] overflow-auto rounded-xl border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"aiDiffHtml\"></pre></div>\n </template>\n </div>\n <div class=\"flex items-center justify-end gap-2 border-t border-gray-800 px-5 py-3\"><button type=\"button\" on-click=\"closeAiHelp()\" class=\"rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-300 hover:bg-gray-800\">Cancel</button><template if=\"aiDiffHtml\"><button type=\"button\" on-click=\"rejectAiSuggestion()\" class=\"rounded-lg border border-rose-500/50 px-3 py-2 text-sm text-rose-300 hover:bg-rose-500/10\">Reject</button><button type=\"button\" on-click=\"acceptAiSuggestion()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400\">Accept suggestion</button></template><button type=\"button\" on-click=\"requestAiHelp()\" bind-disabled=\"aiLoading || !aiRequest.trim()\" class=\"rounded-lg bg-indigo-500 px-3 py-2 text-sm font-semibold text-white hover:bg-indigo-400 disabled:opacity-50\">{{ aiLoading ? 'Thinking...' : 'Suggest' }}</button></div>\n </div>\n </div>\n </template>\n\n <template if=\"revisionDialogOpen\">\n <div class=\"fixed inset-0 z-50 flex items-center justify-center bg-gray-950/80 p-4\" role=\"presentation\">\n <div\n class=\"flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-2xl border border-gray-700 bg-gray-900 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-gray-800 px-4 py-3\">\n <div>\n <h2 id=\"revision-dialog-title\" class=\"font-semibold text-white\">Workflow revision {{ revisionNumber }}</h2>\n <p class=\"text-xs text-gray-500\">Read-only source snapshot</p>\n </div>\n <button type=\"button\" on-click=\"closeRevisionDialog()\" aria-label=\"Close revision dialog\" class=\"rounded-lg p-2 text-gray-400 outline-none hover:bg-gray-800 hover:text-white focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\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-rose-300\" class-hidden=\"!revisionError\">{{ revisionError }}</p>\n <pre class=\"min-h-[20rem] overflow-auto rounded-lg border border-gray-700 bg-gray-950 p-4 font-mono text-xs leading-6\" bind-innerhtml=\"revisionDiffHtml\"></pre>\n </div>\n <div class=\"flex items-center justify-between border-t border-gray-800 px-4 py-3\">\n <button type=\"button\" on-click=\"navigateRevision(-1)\" bind-disabled=\"revisionLoading || revisionNumber <= 1\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\n <lucide-icon icon=\"chevron-left\" size=\"16\"></lucide-icon>Previous\n </button>\n <button type=\"button\" on-click=\"navigateRevision(1)\" bind-disabled=\"revisionLoading || revisionNumber >= revision\" class=\"inline-flex items-center gap-2 rounded-lg border border-gray-700 px-3 py-2 text-sm text-gray-200 outline-none hover:bg-gray-800 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-40\">\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-gray-800 bg-gray-900/80 p-4 shadow-xl shadow-black/10 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-indigo-500/10 p-2 text-indigo-300\"\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-white\">Secrets</h2>\n <p class=\"mt-0.5 text-sm text-gray-400\">Write-only credentials available to workflows.</p>\n </div>\n </div>\n <span\n class=\"rounded-full border border-gray-700 bg-gray-950 px-2.5 py-1 text-xs font-mono text-gray-400\"\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-gray-800 bg-gray-950/50\">\n <summary class=\"flex cursor-pointer list-none items-center justify-between px-4 py-3 text-sm font-medium text-gray-300 outline-none hover:bg-gray-900 focus-visible:ring-2 focus-visible:ring-indigo-400/60\">\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-gray-800 p-4\">\n <p class=\"text-xs text-gray-500\">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-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm uppercase outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\n placeholder=\"NPM_TOKEN\"\n />\n <label class=\"inline-flex items-center gap-2 text-sm text-gray-300\">\n <input type=\"checkbox\" bind-checked=\"fileMode\" on-change=\"setFileMode($event)\" class=\"h-4 w-4 rounded border-gray-700 bg-gray-900 text-indigo-500 focus:ring-2 focus:ring-indigo-500/30\" />\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-gray-700 bg-gray-900 px-3 py-2.5 font-mono text-sm outline-none transition placeholder:text-gray-600 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30\"\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-gray-700 bg-gray-900 px-3 py-2 text-xs text-gray-400 file:mr-3 file:rounded file:border-0 file:bg-gray-700 file:px-2 file:py-1 file:text-xs file:text-gray-200 focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/30\"\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-indigo-500/60 px-3 py-2.5 text-sm font-medium text-indigo-200 outline-none transition hover:bg-indigo-500/10 focus-visible:ring-2 focus-visible:ring-indigo-400/60 disabled:opacity-50\"\n >\n <lucide-icon icon=\"lock-keyhole\" size=\"16\"></lucide-icon>Save secret\n </button>\n </div>\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 class=\"flex min-h-14 items-center gap-2 rounded-xl border border-gray-800 bg-gray-950/50 px-3.5 py-3\">\n <button\n type=\"button\"\n on-click=\"selectSecret(name)\"\n class=\"group flex min-w-0 flex-1 items-center gap-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-indigo-400/50\"\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-gray-800 text-gray-500 group-hover:text-indigo-300\"\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-gray-200\">{{ name }}</span>\n <span class=\"text-xs text-gray-500\">Select</span>\n <lucide-icon icon=\"chevron-right\" size=\"16\" class=\"text-gray-600 transition group-hover:text-indigo-300\"></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-rose-300 outline-none transition hover:bg-rose-500/10 focus-visible:ring-2 focus-visible:ring-rose-400/60\"\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-gray-700 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-gray-600\"></lucide-icon>\n <p class=\"mt-2 text-sm font-medium text-gray-300\">No secrets configured</p>\n <p class=\"mt-1 text-xs text-gray-500\">Add one above to make it available to a workflow.</p>\n </div>\n </section>\n </main>\n\n <script setup>\n import { onDestroy, onInit, ref, templateRef } from '@li3/web';\n import { apiFetch } from '/api-client.mjs';\n import { diffLines } from 'diff';\n\n export default function () {\n const page = document.body.dataset.page;\n const initialId = __WORKFLOW_ID__;\n const initialRevision = __WORKFLOW_REVISION__;\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('');\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 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 = 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 derivedId = () =>\n source.value\n .match(/^\\s*(?:id:\\s*([^\\n]+)|name:\\s*([^\\n]+))/m)\n ?.slice(1)\n .find(Boolean)\n ?.trim()\n .toLowerCase()\n .replace(/[^a-z0-9]/g, '-')\n .replace(/(^-|-$)/g, '') || '';\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\n ? `/api/workflows/${id}?revision=${initialRevision}`\n : `/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 });\n }, ms);\n };\n\n const save = () =>\n run(async () => {\n const id = workflowId.value.trim() || derivedId();\n if (!id) throw new Error('Set an ID or add a workflow name first.');\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 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();\n if (!name || (fileMode.value ? !secretFileData.value : !secretValue.value)) 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(fileMode.value ? { value: secretFileData.value, encoding: 'base64', originalName: secretFileName.value } : { value: secretValue.value, encoding: 'utf8' }),\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();\n if (!name || !confirm(`Delete ${name}?`)) return;\n await api(`/api/secrets/${name}`, { method: 'DELETE' });\n if (secretName.value.trim() === 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 setWorkflowId = (event) => {\n workflowId.value = event.target.value;\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 = [\n ...Array.from(parsed.head.querySelectorAll('style')),\n ...Array.from(parsed.body.childNodes),\n ];\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) => value.replace(/[&<>\"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' })[character] || '');\n const renderRevisionDiff = (previousSource, currentSource) => {\n if (previousSource === null) return `<span class=\"text-emerald-300\">+ ${escapeRevisionHtml(currentSource)}</span>\\n`;\n return diffLines(previousSource, currentSource).map((part) => {\n const className = part.added ? 'text-emerald-300 bg-emerald-500/10' : part.removed ? 'text-rose-300 bg-rose-500/10' : 'text-gray-300';\n const prefix = part.added ? '+ ' : part.removed ? '- ' : ' ';\n return part.value.split('\\n').filter((line, index, lines) => index < lines.length - 1 || line).map((line) => `<span class=\"block ${className}\">${prefix}${escapeRevisionHtml(line)}</span>`).join('');\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 = () => { if (!aiLoading.value) aiDialogOpen.value = false; };\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 apiFetch('/api/ai/workflow-help', {\n method: 'POST',\n headers: { accept: 'text/event-stream', 'content-type': 'application/json' },\n body: JSON.stringify({ sourceYaml: source.value, request: aiRequest.value }),\n });\n if (!response.ok) { const error = await response.json().catch(() => ({})); throw new Error(error.error || `AI help failed: ${response.status}`); }\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.replace(/^```(?:yaml|yml)?\\s*/i, '').replace(/\\s*```$/i, '').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 = () => { source.value = aiSuggestion.value; aiDialogOpen.value = false; validate(); };\n const rejectAiSuggestion = () => { aiSuggestion.value = ''; aiDiffHtml.value = ''; };\n\n onDestroy(() => clearTimeout(validationTimer));\n\n onInit(() => {\n const loadPage = page === 'editor'\n ? initialId ? openWorkflow(initialId) : Promise.resolve()\n : page === 'secrets' ? loadSecrets() : 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 remove,\n saveSecret,\n removeSecret,\n setSource,\n setWorkflowId,\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 <\/script>\n </template>\n\n <script type=\"module\">\n import '@li3/web';\n <\/script>\n <script src=\"https://cdn.tailwindcss.com\"><\/script>\n </body>\n</html>\n";
|
|
12488
12488
|
//#endregion
|
|
12489
12489
|
//#region src/workflows-ui.ts
|
|
12490
12490
|
function Kh(e, t = "", n) {
|
|
@@ -12635,8 +12635,8 @@ function Zh(e) {
|
|
|
12635
12635
|
return f.randomBytes(e).toString("base64url");
|
|
12636
12636
|
}
|
|
12637
12637
|
//#endregion
|
|
12638
|
-
//#region src/
|
|
12639
|
-
var Qh =
|
|
12638
|
+
//#region src/api-client.mjs?raw
|
|
12639
|
+
var Qh = "let token = sessionStorage.getItem('runner-api-token') || '';\nlet expiresAt = token ? Number.MAX_SAFE_INTEGER : 0;\n\nconst rawFetch = window.fetch.bind(window);\n\nasync function loadToken() {\n if (token && expiresAt > Date.now() + 30_000) return token;\n const response = await rawFetch('/api/auth/token', { headers: { accept: 'application/json' } });\n if (!response.ok) return '';\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 (target.origin !== window.location.origin || (!target.pathname.startsWith('/api/') && !target.pathname.startsWith('/restart/'))) {\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", $h = "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 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 } catch {}\n });\n return { title: defineProp('title'), subtitle: defineProp('subtitle'), back: defineProp('back'), backLabel: defineProp('back-label'), authConfigured, authenticated, userName };\n}\n", eg = "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') return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\n if (path.match(/^\\/runs\\/\\d+$/)) 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/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n if (path === '/settings/secrets') return { component: 'page-secrets', url: '/pages/workflows.html?page=secrets', page: 'secrets' };\n if (path === '/settings/tokens') return { component: 'page-settings', url: '/pages/settings.html?page=tokens', page: 'tokens' };\n if (path === '/settings/notifications') return { component: 'page-settings', url: '/pages/settings.html?page=notifications', page: 'notifications' };\n if (path === '/settings/workers') return { component: 'page-settings', url: '/pages/settings.html?page=workers', page: 'workers' };\n if (path === '/settings' || path === '/workflows') return { component: 'page-workflows', url: '/pages/workflows.html?page=workflows', page: 'workflows' };\n const editor = path.match(/^\\/settings\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (editor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${editor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n const legacyEditor = path.match(/^\\/workflows\\/(new|[a-z0-9-]+)$/);\n if (legacyEditor) return { component: 'page-workflow-editor', url: `/pages/workflows.html?page=editor&id=${legacyEditor[1]}${url.searchParams.get('revision') ? `&revision=${url.searchParams.get('revision')}` : ''}`, page: 'editor' };\n return { component: 'page-dashboard', url: '/pages/dashboard.html', page: 'dashboard' };\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 await load(current.url);\n outlet.value.replaceChildren(document.createElement(current.component));\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 (target.origin !== window.location.origin || !target.pathname.startsWith('/') || target.pathname.startsWith('/auth/')) 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", tg = 50, ng = 500, rg = class e {
|
|
12640
12640
|
server;
|
|
12641
12641
|
preprocessors = /* @__PURE__ */ new Map();
|
|
12642
12642
|
workflows = new u();
|
|
@@ -12683,6 +12683,18 @@ var Qh = 50, $h = 500, eg = class e {
|
|
|
12683
12683
|
if (e.method === "GET" && n.pathname === "/auth/logout") return this.handleOidcLogout(e, t);
|
|
12684
12684
|
if (e.method === "GET" && n.pathname === "/api/auth/session") return this.handleOidcSession(e, t);
|
|
12685
12685
|
if (e.method === "GET" && n.pathname === "/api/auth/token") return this.handleOidcToken(e, t);
|
|
12686
|
+
if (e.method === "GET" && n.pathname === "/api-client.mjs") return t.writeHead(200, {
|
|
12687
|
+
"Cache-Control": "no-cache",
|
|
12688
|
+
"Content-Type": "text/javascript; charset=utf-8"
|
|
12689
|
+
}), t.end(Qh);
|
|
12690
|
+
if (e.method === "GET" && n.pathname === "/app-header.mjs") return t.writeHead(200, {
|
|
12691
|
+
"Cache-Control": "no-cache",
|
|
12692
|
+
"Content-Type": "text/javascript; charset=utf-8"
|
|
12693
|
+
}), t.end($h);
|
|
12694
|
+
if (e.method === "GET" && n.pathname === "/app-router.mjs") return t.writeHead(200, {
|
|
12695
|
+
"Cache-Control": "no-cache",
|
|
12696
|
+
"Content-Type": "text/javascript; charset=utf-8"
|
|
12697
|
+
}), t.end(eg);
|
|
12686
12698
|
if (e.method === "GET" && n.pathname === "/app-header.html") return t.writeHead(200, {
|
|
12687
12699
|
"Content-Type": "text/html; charset=utf-8",
|
|
12688
12700
|
"Cache-Control": "no-store"
|
|
@@ -12784,8 +12796,8 @@ var Qh = 50, $h = 500, eg = class e {
|
|
|
12784
12796
|
return t.writeHead(302, { Location: `${i}${n.search}` }), t.end();
|
|
12785
12797
|
}
|
|
12786
12798
|
if (e.method === "GET" && n.pathname === "/api/jobs") {
|
|
12787
|
-
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 ?
|
|
12788
|
-
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 >
|
|
12799
|
+
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 ? tg : Number(i);
|
|
12800
|
+
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 > ng ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: `limit must be an integer from 1 to ${ng}` }))) : 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);
|
|
12789
12801
|
}
|
|
12790
12802
|
let s = n.pathname.match(/^\/api\/dispatch\/([a-z0-9-]+)$/);
|
|
12791
12803
|
if (e.method === "POST" && s) return this.handleDispatch(e, t, s[1]);
|
|
@@ -13442,4 +13454,4 @@ var Qh = 50, $h = 500, eg = class e {
|
|
|
13442
13454
|
}
|
|
13443
13455
|
};
|
|
13444
13456
|
//#endregion
|
|
13445
|
-
export {
|
|
13457
|
+
export { rg as WebhookServer };
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAwBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAWxF,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,aAAa,CAA0C;IAC/D,OAAO,CAAC,SAAS,CAA4B;IAC7C,OAAO,CAAC,gBAAgB,CAA0B;IAClD,OAAO,CAAC,KAAK,CAAe;IAC5B,OAAO,CAAC,OAAO,CAAc;IAC7B,OAAO,CAAC,IAAI,CAAiB;IAC7B,OAAO,CAAC,OAAO,CAA0B;IACzC,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,WAAW,CAAS;IAC5B,OAAO,CAAC,IAAI,CAAC,CAAa;IAC1B,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,eAAe,CAAgB;WAE1B,QAAQ,CAAC,OAAO,EAAE,oBAAoB,GAAG;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE;gBAK1D,OAAO,EAAE,oBAAoB;IAczC,oBAAoB,CAAC,YAAY,EAAE,mBAAmB;YAIxC,aAAa;IAyS3B;;OAEG;YACW,aAAa;YA0Bb,cAAc;YASd,aAAa;YAiBb,iBAAiB;YA0BjB,WAAW;YAwBX,UAAU;YAyBV,cAAc;IA8C5B,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;YAIF,QAAQ;YAaR,eAAe;YAaf,kBAAkB;IAgBhC,OAAO,CAAC,gBAAgB;IAMxB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,eAAe;IAUvB,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,OAAO;YAID,cAAc;YAYd,cAAc;YAmBd,eAAe;IAM7B,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,UAAU;YAKJ,YAAY;IAO1B,OAAO,CAAC,YAAY;IAmBpB,OAAO,CAAC,YAAY;YAUN,QAAQ;YAYR,wBAAwB;YAcxB,kBAAkB;YAOlB,iBAAiB;YAcjB,kBAAkB;YAoBlB,oBAAoB;YASpB,qBAAqB;YAWrB,gBAAgB;YAMhB,gBAAgB;YAiBhB,kBAAkB;YASlB,gBAAgB;IAc9B;;OAEG;YACW,kBAAkB;YAclB,iBAAiB;IA2B/B,OAAO,CAAC,mBAAmB;YASb,mBAAmB;YAWnB,qBAAqB;IAWnC;;OAEG;YACW,eAAe;IAK7B,OAAO,CAAC,cAAc;IAKtB,OAAO,CAAC,mBAAmB;YAcb,mBAAmB;IAwBjC;;OAEG;YACW,gBAAgB;YA+BhB,gBAAgB;YAiBhB,sBAAsB;YAetB,YAAY;YAsDZ,oBAAoB;YA4BpB,eAAe;YAkBf,cAAc;IAS5B,OAAO,CAAC,MAAM;YAMA,UAAU;IAIlB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC;IAU5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAS5B"}
|