@cloud-cli/on 1.66.1 → 1.67.0
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/{oidc-user-repository-CiprwzOj.js → oidc-user-repository-C4HnJLyx.js} +19 -0
- package/dist/oidc-user-repository-pPSfFkOo.js +2 -0
- package/dist/oidc-user-repository.d.ts +7 -2
- package/dist/oidc-user-repository.d.ts.map +1 -1
- package/dist/oidc.d.ts +2 -1
- package/dist/oidc.d.ts.map +1 -1
- package/dist/on.js +3 -3
- package/dist/{server-BUKPyuyg.js → server-Dlmiez4d.js} +122 -31
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/{worker-BwNyfSpp.js → worker-FXzBXCLz.js} +2 -0
- package/package.json +3 -1
- package/dist/oidc-user-repository-BNQF3qnb.js +0 -2
|
@@ -15,6 +15,25 @@ var t = class {
|
|
|
15
15
|
async role(t) {
|
|
16
16
|
return (await e.get("SELECT role FROM oidc_users WHERE subject = ?", [t]))?.role === "admin" ? "admin" : "user";
|
|
17
17
|
}
|
|
18
|
+
async list() {
|
|
19
|
+
return (await e.all("SELECT subject, name, email, role FROM oidc_users ORDER BY name, email, subject")).map((e) => ({
|
|
20
|
+
id: e.subject,
|
|
21
|
+
name: e.name || void 0,
|
|
22
|
+
email: e.email || void 0,
|
|
23
|
+
role: e.role === "admin" ? "admin" : "user"
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
async setRole(t, n) {
|
|
27
|
+
let r = await e.get("SELECT role FROM oidc_users WHERE subject = ?", [t]);
|
|
28
|
+
if (!r) return "not-found";
|
|
29
|
+
if (r.role === n) return "updated";
|
|
30
|
+
let i = await e.run("UPDATE oidc_users SET role = ?, updated_at = CURRENT_TIMESTAMP\n WHERE subject = ? AND (? = 'admin' OR role != 'admin' OR\n (SELECT COUNT(*) FROM oidc_users WHERE role = 'admin') > 1)", [
|
|
31
|
+
n,
|
|
32
|
+
t,
|
|
33
|
+
n
|
|
34
|
+
]);
|
|
35
|
+
return Number(i?.changes || 0) > 0 ? "updated" : await e.get("SELECT subject FROM oidc_users WHERE subject = ?", [t]) ? "last-admin" : "not-found";
|
|
36
|
+
}
|
|
18
37
|
async promote(t) {
|
|
19
38
|
let n = await e.run("UPDATE oidc_users SET role = 'admin', updated_at = CURRENT_TIMESTAMP WHERE subject = ?", [t]);
|
|
20
39
|
return Number(n?.changes || 0) > 0;
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import type { OidcUser } from
|
|
2
|
-
export type OidcRole =
|
|
1
|
+
import type { OidcUser } from "./oidc.js";
|
|
2
|
+
export type OidcRole = "user" | "admin";
|
|
3
|
+
export type OidcUserRecord = Pick<OidcUser, "id" | "name" | "email"> & {
|
|
4
|
+
role: OidcRole;
|
|
5
|
+
};
|
|
3
6
|
export declare class OidcUserRepository {
|
|
4
7
|
init(): Promise<void>;
|
|
5
8
|
upsert(user: OidcUser): Promise<void>;
|
|
6
9
|
role(subject: string): Promise<OidcRole>;
|
|
10
|
+
list(): Promise<OidcUserRecord[]>;
|
|
11
|
+
setRole(subject: string, role: OidcRole): Promise<"updated" | "not-found" | "last-admin">;
|
|
7
12
|
promote(subject: string): Promise<boolean>;
|
|
8
13
|
}
|
|
9
14
|
//# sourceMappingURL=oidc-user-repository.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oidc-user-repository.d.ts","sourceRoot":"","sources":["../src/oidc-user-repository.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"oidc-user-repository.d.ts","sourceRoot":"","sources":["../src/oidc-user-repository.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAE1C,MAAM,MAAM,QAAQ,GAAG,MAAM,GAAG,OAAO,CAAC;AACxC,MAAM,MAAM,cAAc,GAAG,IAAI,CAAC,QAAQ,EAAE,IAAI,GAAG,MAAM,GAAG,OAAO,CAAC,GAAG;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAE1F,qBAAa,kBAAkB;IACvB,IAAI;IASJ,MAAM,CAAC,IAAI,EAAE,QAAQ;IAQrB,IAAI,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAAC;IAKxC,IAAI,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAUjC,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,GAAG,OAAO,CAAC,SAAS,GAAG,WAAW,GAAG,YAAY,CAAC;IAmBzF,OAAO,CAAC,OAAO,EAAE,MAAM;CAO9B"}
|
package/dist/oidc.d.ts
CHANGED
|
@@ -23,7 +23,8 @@ export declare class OidcClient {
|
|
|
23
23
|
cookie: string;
|
|
24
24
|
}>;
|
|
25
25
|
userFromCookie(cookieHeader: string | undefined): OidcUser | undefined;
|
|
26
|
-
setRole(cookieHeader: string, role:
|
|
26
|
+
setRole(cookieHeader: string, role: "user" | "admin"): void;
|
|
27
|
+
setRoleForUser(subject: string, role: "user" | "admin"): void;
|
|
27
28
|
roleFromCookie(cookieHeader: string | undefined): "user" | "admin" | undefined;
|
|
28
29
|
accessTokenFromCookie(cookieHeader: string | undefined): {
|
|
29
30
|
accessToken: string;
|
package/dist/oidc.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oidc.d.ts","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAoBD,qBAAa,UAAU;IACrB,OAAO,CAAC,qBAAqB,CAAC,CAAe;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8B;gBAE3C,MAAM,EAAE,UAAU;IAI9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,IAAI,OAAO,YAEV;IAED,IAAI,QAAQ,WAEX;IAEK,QAAQ,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAUhE,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IA+BpH,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS;IAKtE,OAAO,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAMpD,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS;IAI/C,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS;;;;IAMhD,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"oidc.d.ts","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,UAAU;IACzB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAoBD,qBAAa,UAAU;IACrB,OAAO,CAAC,qBAAqB,CAAC,CAAe;IAC7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8B;gBAE3C,MAAM,EAAE,UAAU;IAI9B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAa;IAEpC,IAAI,OAAO,YAEV;IAED,IAAI,QAAQ,WAEX;IAEK,QAAQ,CAAC,WAAW,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAUhE,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC;IA+BpH,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,QAAQ,GAAG,SAAS;IAKtE,OAAO,CAAC,YAAY,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAMpD,cAAc,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO;IAMtD,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS;IAI/C,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS;;;;IAMhD,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC;IAgBvD,eAAe,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,WAAgB,EAAE,WAAW,CAAC,EAAE,MAAM;IAUlH,WAAW,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS;IAM5C,OAAO,CAAC,iBAAiB;IAWzB,OAAO,CAAC,WAAW;YAOL,cAAc;IAkB5B,OAAO,CAAC,KAAK;IAKb,OAAO,CAAC,SAAS;IAMjB,OAAO,CAAC,UAAU;CAYnB"}
|
package/dist/on.js
CHANGED
|
@@ -104,11 +104,11 @@ async function m() {
|
|
|
104
104
|
let [{ QueueManager: r }, { SecretStore: i }, { WebhookServer: a }, { startWorkers: o }, { WorkflowRepository: s }, { WorkflowScheduler: c }, { OidcUserRepository: l }] = await Promise.all([
|
|
105
105
|
import("./queue-DhXXzxm3.js"),
|
|
106
106
|
import("./secrets-DhkqmawU.js"),
|
|
107
|
-
import("./server-
|
|
108
|
-
import("./worker-
|
|
107
|
+
import("./server-Dlmiez4d.js"),
|
|
108
|
+
import("./worker-FXzBXCLz.js"),
|
|
109
109
|
import("./workflows-DQQIVH7c.js"),
|
|
110
110
|
import("./scheduler-2d1UPcxM.js"),
|
|
111
|
-
import("./oidc-user-repository-
|
|
111
|
+
import("./oidc-user-repository-pPSfFkOo.js")
|
|
112
112
|
]);
|
|
113
113
|
if (t === "promote-admin") {
|
|
114
114
|
if (!n) throw Error("Usage: on promote-admin --subject <oidc-subject>");
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { n as e, r as t, t as n } from "./rolldown-runtime-B0aSnxlc.js";
|
|
2
2
|
import { t as r } from "./db-client-BvMkK1lx.js";
|
|
3
|
-
import { t as i } from "./oidc-user-repository-
|
|
3
|
+
import { t as i } from "./oidc-user-repository-C4HnJLyx.js";
|
|
4
4
|
import { a, n as o, r as s, t as c } from "./matrix-expander-BItKqWu2.js";
|
|
5
5
|
import { t as l } from "./secrets-BR6K16WY.js";
|
|
6
6
|
import { t as u } from "./events-BoODn7MY.js";
|
|
@@ -1873,12 +1873,13 @@ var nn = {
|
|
|
1873
1873
|
{ name: "discovery" },
|
|
1874
1874
|
{ name: "jobs" },
|
|
1875
1875
|
{ name: "workflows" },
|
|
1876
|
+
{ name: "users" },
|
|
1876
1877
|
{ name: "secrets" },
|
|
1877
1878
|
{ name: "events" },
|
|
1878
1879
|
{ name: "webhooks" },
|
|
1879
1880
|
{ name: "notifications" }
|
|
1880
1881
|
],
|
|
1881
|
-
paths: /* @__PURE__ */ JSON.parse("{\"/api\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"Get this OpenAPI specification\",\"security\":[],\"responses\":{\"200\":{\"description\":\"OpenAPI JSON document\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}}}}},\"/api/api-keys\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"List active API keys\",\"responses\":{\"200\":{\"description\":\"API key metadata\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyList\"}}}}}},\"post\":{\"tags\":[\"discovery\"],\"summary\":\"Issue a scoped API key\",\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyIssue\"}}}},\"responses\":{\"201\":{\"description\":\"Key metadata and one-time token\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyCreated\"}}}}}}},\"/api/api-keys/{keyId}\":{\"delete\":{\"tags\":[\"discovery\"],\"summary\":\"Revoke an API key\",\"parameters\":[{\"name\":\"keyId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"204\":{\"description\":\"Key revoked\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"List jobs\",\"security\":[],\"parameters\":[{\"$ref\":\"#/components/parameters/AfterId\"},{\"$ref\":\"#/components/parameters/BeforeId\"},{\"$ref\":\"#/components/parameters/Limit\"},{\"$ref\":\"#/components/parameters/Filter\"},{\"$ref\":\"#/components/parameters/WorkflowFilter\"}],\"responses\":{\"200\":{\"description\":\"Dashboard job list\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobList\"}}}}}}},\"/api/dispatch/{trigger}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Dispatch a workflow trigger with manual inputs\",\"security\":[{\"bearerAuth\":[]},{\"basicAuth\":[]}],\"parameters\":[{\"name\":\"trigger\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/workflows/{workflowId}/run\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Run the latest saved workflow revision immediately\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"name\":\"workflowId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\"},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/jobs/{jobId}/wait\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Wait for a job to reach a terminal state\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"timeout\",\"in\":\"query\",\"schema\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":120000,\"default\":30000}}],\"responses\":{\"200\":{\"description\":\"Latest job state\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs/{jobId}/cancel\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Cancel an active job\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Job cancelled\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobStatus\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/jobs/{jobId}/secrets\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get secrets for an assigned worker job\",\"security\":[{\"workerBearerAuth\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"$ref\":\"#/components/parameters/WorkerId\"}],\"responses\":{\"200\":{\"description\":\"Decrypted job secrets\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Secrets\"}}}},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/runs/{jobId}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get a run view\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Run details\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/RunView\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/artifacts/{path}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Download an artifact\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"path\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"Artifact bytes\",\"content\":{\"application/octet-stream\":{\"schema\":{\"type\":\"string\",\"format\":\"binary\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/ai-help\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Ask AI to diagnose a failed step\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"stepId\"],\"properties\":{\"stepId\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"AI diagnosis\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows/validate\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Validate workflow YAML\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSource\"}}}},\"responses\":{\"200\":{\"description\":\"Valid workflow definitions\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ValidationResult\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}}},\"/api/ai/workflow-help\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow YAML suggestion\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"request\"],\"properties\":{\"request\":{\"type\":\"string\"},\"sourceYaml\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"Workflow YAML suggestion stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"List workflows\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Workflow list\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"workflows\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/Workflow\"}}}}}}}}}},\"/api/workflows/{workflowId}\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow or historical revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"},{\"$ref\":\"#/components/parameters/Revision\"}],\"responses\":{\"200\":{\"description\":\"Workflow definition\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}},\"put\":{\"tags\":[\"workflows\"],\"summary\":\"Save a workflow draft\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSave\"}}}},\"responses\":{\"200\":{\"description\":\"Draft saved\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}},\"delete\":{\"tags\":[\"workflows\"],\"summary\":\"Delete a workflow and its revisions\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"204\":{\"description\":\"Workflow deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/workflows/{workflowId}/publish\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Publish the latest workflow revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"200\":{\"description\":\"Workflow published\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/secrets\":{\"get\":{\"tags\":[\"secrets\"],\"summary\":\"List secret names\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Secret names\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretNames\"}}}}}}},\"/api/secrets/{name}\":{\"put\":{\"tags\":[\"secrets\"],\"summary\":\"Create or replace an encrypted secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretValue\"}}}},\"responses\":{\"204\":{\"description\":\"Secret saved\"}}},\"delete\":{\"tags\":[\"secrets\"],\"summary\":\"Delete a secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"responses\":{\"204\":{\"description\":\"Secret deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/events\":{\"get\":{\"tags\":[\"events\"],\"summary\":\"Subscribe to runner events\",\"security\":[],\"responses\":{\"200\":{\"description\":\"Server-sent event stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}}}},\"post\":{\"tags\":[\"events\"],\"summary\":\"Publish a worker job-change event\",\"security\":[{\"workerBearerAuth\":[]}],\"requestBody\":{\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobEvent\"}}}},\"responses\":{\"202\":{\"description\":\"Event accepted\"}}}},\"/api/push/public-key\":{\"get\":{\"tags\":[\"notifications\"],\"summary\":\"Get the Web Push public key\",\"security\":[],\"responses\":{\"200\":{\"description\":\"VAPID public key\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"publicKey\":{\"type\":\"string\"}}}}}}}}},\"/api/push/subscriptions\":{\"post\":{\"tags\":[\"notifications\"],\"summary\":\"Register a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"201\":{\"description\":\"Subscription saved\"}}},\"delete\":{\"tags\":[\"notifications\"],\"summary\":\"Remove a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"endpoint\"],\"properties\":{\"endpoint\":{\"type\":\"string\",\"format\":\"uri\"}}}}}},\"responses\":{\"204\":{\"description\":\"Subscription removed\"}}}},\"/webhooks/{provider}\":{\"post\":{\"tags\":[\"webhooks\"],\"summary\":\"Receive a provider webhook\",\"security\":[],\"parameters\":[{\"name\":\"provider\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Webhook accepted\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"413\":{\"$ref\":\"#/components/responses/PayloadTooLarge\"}}}},\"/restart/{jobId}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Create a new run from the active workflow revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"201\":{\"description\":\"New job created\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\"}}}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/admin/reload-secrets\":{\"post\":{\"tags\":[\"secrets\"],\"summary\":\"Reload legacy in-memory secrets\",\"responses\":{\"200\":{\"description\":\"Secrets reloaded\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}}}"),
|
|
1882
|
+
paths: /* @__PURE__ */ JSON.parse("{\"/api\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"Get this OpenAPI specification\",\"security\":[],\"responses\":{\"200\":{\"description\":\"OpenAPI JSON document\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}}}}},\"/api/api-keys\":{\"get\":{\"tags\":[\"discovery\"],\"summary\":\"List active API keys\",\"responses\":{\"200\":{\"description\":\"API key metadata\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyList\"}}}}}},\"post\":{\"tags\":[\"discovery\"],\"summary\":\"Issue a scoped API key\",\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyIssue\"}}}},\"responses\":{\"201\":{\"description\":\"Key metadata and one-time token\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ApiKeyCreated\"}}}}}}},\"/api/api-keys/{keyId}\":{\"delete\":{\"tags\":[\"discovery\"],\"summary\":\"Revoke an API key\",\"parameters\":[{\"name\":\"keyId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\",\"format\":\"uuid\"}}],\"responses\":{\"204\":{\"description\":\"Key revoked\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"List jobs\",\"security\":[],\"parameters\":[{\"$ref\":\"#/components/parameters/AfterId\"},{\"$ref\":\"#/components/parameters/BeforeId\"},{\"$ref\":\"#/components/parameters/Limit\"},{\"$ref\":\"#/components/parameters/Filter\"},{\"$ref\":\"#/components/parameters/WorkflowFilter\"}],\"responses\":{\"200\":{\"description\":\"Dashboard job list\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobList\"}}}}}}},\"/api/dispatch/{trigger}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Dispatch a workflow trigger with manual inputs\",\"security\":[{\"bearerAuth\":[]},{\"basicAuth\":[]}],\"parameters\":[{\"name\":\"trigger\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/workflows/{workflowId}/run\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Run the latest saved workflow revision immediately\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"name\":\"workflowId\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Jobs queued\"},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"}}}},\"/api/jobs/{jobId}/wait\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Wait for a job to reach a terminal state\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"timeout\",\"in\":\"query\",\"schema\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":120000,\"default\":30000}}],\"responses\":{\"200\":{\"description\":\"Latest job state\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/jobs/{jobId}/cancel\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Cancel an active job\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Job cancelled\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobStatus\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/jobs/{jobId}/secrets\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get secrets for an assigned worker job\",\"security\":[{\"workerBearerAuth\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"$ref\":\"#/components/parameters/WorkerId\"}],\"responses\":{\"200\":{\"description\":\"Decrypted job secrets\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Secrets\"}}}},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/runs/{jobId}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Get a run view\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"200\":{\"description\":\"Run details\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/RunView\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/artifacts/{path}\":{\"get\":{\"tags\":[\"jobs\"],\"summary\":\"Download an artifact\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"},{\"name\":\"path\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"responses\":{\"200\":{\"description\":\"Artifact bytes\",\"content\":{\"application/octet-stream\":{\"schema\":{\"type\":\"string\",\"format\":\"binary\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/runs/{jobId}/ai-help\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Ask AI to diagnose a failed step\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"stepId\"],\"properties\":{\"stepId\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"AI diagnosis\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"}}}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows/validate\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Validate workflow YAML\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSource\"}}}},\"responses\":{\"200\":{\"description\":\"Valid workflow definitions\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/ValidationResult\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}}},\"/api/ai/workflow-help\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow YAML suggestion\",\"security\":[{\"oidcSessionCookie\":[]}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"request\"],\"properties\":{\"request\":{\"type\":\"string\"},\"sourceYaml\":{\"type\":\"string\"}}}}}},\"responses\":{\"200\":{\"description\":\"Workflow YAML suggestion stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"502\":{\"description\":\"AI provider failure\"},\"503\":{\"description\":\"AI help is not configured\"}}}},\"/api/workflows\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"List workflows\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Workflow list\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"workflows\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/Workflow\"}}}}}}}}}},\"/api/users\":{\"get\":{\"tags\":[\"users\"],\"summary\":\"List registered OIDC users\",\"description\":\"Administrator OIDC sessions only. Bearer and basic credentials are not accepted.\",\"security\":[{\"oidcAdminSession\":[]}],\"responses\":{\"200\":{\"description\":\"User roster\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserList\"}}}},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}},\"/api/users/{subject}/role\":{\"put\":{\"tags\":[\"users\"],\"summary\":\"Change a user's OIDC role\",\"description\":\"Administrator OIDC sessions only. The final administrator cannot be demoted.\",\"security\":[{\"oidcAdminSession\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/UserSubject\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserRoleUpdate\"}}}},\"responses\":{\"200\":{\"description\":\"Role updated\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/UserRoleResponse\"}}}},\"400\":{\"$ref\":\"#/components/responses/ValidationError\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"},\"409\":{\"$ref\":\"#/components/responses/Conflict\"}}}},\"/api/workflows/{workflowId}\":{\"get\":{\"tags\":[\"workflows\"],\"summary\":\"Get a workflow or historical revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"},{\"$ref\":\"#/components/parameters/Revision\"}],\"responses\":{\"200\":{\"description\":\"Workflow definition\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}},\"put\":{\"tags\":[\"workflows\"],\"summary\":\"Save a workflow draft\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/WorkflowSave\"}}}},\"responses\":{\"200\":{\"description\":\"Draft saved\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"422\":{\"$ref\":\"#/components/responses/ValidationError\"}}},\"delete\":{\"tags\":[\"workflows\"],\"summary\":\"Delete a workflow and its revisions\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"204\":{\"description\":\"Workflow deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/workflows/{workflowId}/publish\":{\"post\":{\"tags\":[\"workflows\"],\"summary\":\"Publish the latest workflow revision\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/WorkflowId\"}],\"responses\":{\"200\":{\"description\":\"Workflow published\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/Workflow\"}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/secrets\":{\"get\":{\"tags\":[\"secrets\"],\"summary\":\"List secret names\",\"security\":[{\"oidcSessionCookie\":[]}],\"responses\":{\"200\":{\"description\":\"Secret names\",\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretNames\"}}}}}}},\"/api/secrets/{name}\":{\"put\":{\"tags\":[\"secrets\"],\"summary\":\"Create or replace an encrypted secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/SecretValue\"}}}},\"responses\":{\"204\":{\"description\":\"Secret saved\"}}},\"delete\":{\"tags\":[\"secrets\"],\"summary\":\"Delete a secret\",\"security\":[{\"oidcSessionCookie\":[]}],\"parameters\":[{\"$ref\":\"#/components/parameters/SecretName\"}],\"responses\":{\"204\":{\"description\":\"Secret deleted\"},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/api/events\":{\"get\":{\"tags\":[\"events\"],\"summary\":\"Subscribe to runner events\",\"security\":[],\"responses\":{\"200\":{\"description\":\"Server-sent event stream\",\"content\":{\"text/event-stream\":{\"schema\":{\"type\":\"string\"}}}}}},\"post\":{\"tags\":[\"events\"],\"summary\":\"Publish a worker job-change event\",\"security\":[{\"workerBearerAuth\":[]}],\"requestBody\":{\"content\":{\"application/json\":{\"schema\":{\"$ref\":\"#/components/schemas/JobEvent\"}}}},\"responses\":{\"202\":{\"description\":\"Event accepted\"}}}},\"/api/push/public-key\":{\"get\":{\"tags\":[\"notifications\"],\"summary\":\"Get the Web Push public key\",\"security\":[],\"responses\":{\"200\":{\"description\":\"VAPID public key\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"publicKey\":{\"type\":\"string\"}}}}}}}}},\"/api/push/subscriptions\":{\"post\":{\"tags\":[\"notifications\"],\"summary\":\"Register a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"201\":{\"description\":\"Subscription saved\"}}},\"delete\":{\"tags\":[\"notifications\"],\"summary\":\"Remove a Web Push subscription\",\"security\":[],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"required\":[\"endpoint\"],\"properties\":{\"endpoint\":{\"type\":\"string\",\"format\":\"uri\"}}}}}},\"responses\":{\"204\":{\"description\":\"Subscription removed\"}}}},\"/webhooks/{provider}\":{\"post\":{\"tags\":[\"webhooks\"],\"summary\":\"Receive a provider webhook\",\"security\":[],\"parameters\":[{\"name\":\"provider\",\"in\":\"path\",\"required\":true,\"schema\":{\"type\":\"string\"}}],\"requestBody\":{\"required\":true,\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\"}}}},\"responses\":{\"202\":{\"description\":\"Webhook accepted\"},\"401\":{\"$ref\":\"#/components/responses/Unauthorized\"},\"413\":{\"$ref\":\"#/components/responses/PayloadTooLarge\"}}}},\"/restart/{jobId}\":{\"post\":{\"tags\":[\"jobs\"],\"summary\":\"Create a new run from the active workflow revision\",\"parameters\":[{\"$ref\":\"#/components/parameters/JobId\"}],\"responses\":{\"201\":{\"description\":\"New job created\",\"content\":{\"application/json\":{\"schema\":{\"type\":\"object\",\"properties\":{\"id\":{\"type\":\"integer\"}}}}}},\"404\":{\"$ref\":\"#/components/responses/NotFound\"}}}},\"/admin/reload-secrets\":{\"post\":{\"tags\":[\"secrets\"],\"summary\":\"Reload legacy in-memory secrets\",\"responses\":{\"200\":{\"description\":\"Secrets reloaded\"},\"403\":{\"$ref\":\"#/components/responses/Forbidden\"}}}}}"),
|
|
1882
1883
|
components: {
|
|
1883
1884
|
securitySchemes: {
|
|
1884
1885
|
bearerAuth: {
|
|
@@ -1894,6 +1895,12 @@ var nn = {
|
|
|
1894
1895
|
in: "cookie",
|
|
1895
1896
|
name: "runner_oidc_session"
|
|
1896
1897
|
},
|
|
1898
|
+
oidcAdminSession: {
|
|
1899
|
+
type: "apiKey",
|
|
1900
|
+
in: "cookie",
|
|
1901
|
+
name: "runner_oidc_session",
|
|
1902
|
+
description: "Requires an authenticated OIDC session with administrator role."
|
|
1903
|
+
},
|
|
1897
1904
|
workerBearerAuth: {
|
|
1898
1905
|
type: "http",
|
|
1899
1906
|
scheme: "bearer",
|
|
@@ -1919,6 +1926,15 @@ var nn = {
|
|
|
1919
1926
|
pattern: "^[a-z0-9-]+$"
|
|
1920
1927
|
}
|
|
1921
1928
|
},
|
|
1929
|
+
UserSubject: {
|
|
1930
|
+
name: "subject",
|
|
1931
|
+
in: "path",
|
|
1932
|
+
required: !0,
|
|
1933
|
+
schema: {
|
|
1934
|
+
type: "string",
|
|
1935
|
+
minLength: 1
|
|
1936
|
+
}
|
|
1937
|
+
},
|
|
1922
1938
|
Revision: {
|
|
1923
1939
|
name: "revision",
|
|
1924
1940
|
in: "query",
|
|
@@ -2039,6 +2055,47 @@ var nn = {
|
|
|
2039
2055
|
enabled: { type: "boolean" }
|
|
2040
2056
|
}
|
|
2041
2057
|
},
|
|
2058
|
+
User: {
|
|
2059
|
+
type: "object",
|
|
2060
|
+
required: ["id", "role"],
|
|
2061
|
+
properties: {
|
|
2062
|
+
id: { type: "string" },
|
|
2063
|
+
name: {
|
|
2064
|
+
type: "string",
|
|
2065
|
+
nullable: !0
|
|
2066
|
+
},
|
|
2067
|
+
email: {
|
|
2068
|
+
type: "string",
|
|
2069
|
+
format: "email",
|
|
2070
|
+
nullable: !0
|
|
2071
|
+
},
|
|
2072
|
+
role: {
|
|
2073
|
+
type: "string",
|
|
2074
|
+
enum: ["user", "admin"]
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
},
|
|
2078
|
+
UserList: {
|
|
2079
|
+
type: "object",
|
|
2080
|
+
required: ["users"],
|
|
2081
|
+
properties: { users: {
|
|
2082
|
+
type: "array",
|
|
2083
|
+
items: { $ref: "#/components/schemas/User" }
|
|
2084
|
+
} }
|
|
2085
|
+
},
|
|
2086
|
+
UserRoleUpdate: {
|
|
2087
|
+
type: "object",
|
|
2088
|
+
required: ["role"],
|
|
2089
|
+
properties: { role: {
|
|
2090
|
+
type: "string",
|
|
2091
|
+
enum: ["user", "admin"]
|
|
2092
|
+
} }
|
|
2093
|
+
},
|
|
2094
|
+
UserRoleResponse: {
|
|
2095
|
+
type: "object",
|
|
2096
|
+
required: ["user"],
|
|
2097
|
+
properties: { user: { $ref: "#/components/schemas/User" } }
|
|
2098
|
+
},
|
|
2042
2099
|
WorkflowSource: {
|
|
2043
2100
|
type: "object",
|
|
2044
2101
|
required: ["sourceYaml"],
|
|
@@ -12196,6 +12253,9 @@ var Zh = 6e5, Qh = 288e5, $h = "runner_oidc_session", eg = class {
|
|
|
12196
12253
|
let n = this.cookieToken(e), r = n ? this.sessions.get(n) : void 0;
|
|
12197
12254
|
r && (r.role = t);
|
|
12198
12255
|
}
|
|
12256
|
+
setRoleForUser(e, t) {
|
|
12257
|
+
for (let n of this.sessions.values()) n.user.id === e && (n.role = t);
|
|
12258
|
+
}
|
|
12199
12259
|
roleFromCookie(e) {
|
|
12200
12260
|
return this.sessionFromCookie(e)?.role;
|
|
12201
12261
|
}
|
|
@@ -12311,7 +12371,7 @@ ${n}
|
|
|
12311
12371
|
}`;
|
|
12312
12372
|
return e.replace("<link rel=\"stylesheet\" href=\"./style.css\" />", `<style>${t}</style>`).replace("<script src=\"./app.js\" defer><\/script>", "").replace("</body>", `<script>${r}<\/script></body>`);
|
|
12313
12373
|
}
|
|
12314
|
-
var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigator, URL, URLSearchParams, Headers, EventSource, Blob */\n\nconst icons = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n};\n\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n icons[name] || icons.runs\n }</svg>`;\nconst escape = (value) =>\n String(value ?? \"\").replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${escape(status)}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst iconForStatus = statusIcon;\nconst badge = (status) =>\n `<span class=\"status ${escape(status)}\">${statusIcon(status)}${escape(labels[status] || status || \"Unknown\")}</span>`;\n\nexport function mountLivePreview() {\n const main = document.querySelector(\"#main\");\n const dialog = document.querySelector(\"#dialog\");\n const state = {\n jobs: [],\n workflows: [],\n workers: [],\n secrets: [],\n hasMore: false,\n loadingJobs: false,\n jobsLoaded: false,\n workflowsLoaded: false,\n workersLoaded: false,\n secretsLoaded: false,\n errors: {},\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n worker: \"all\",\n workflowFilter: \"all\",\n page: 1,\n run: null,\n mutating: false,\n detailTab: \"logs\",\n step: 0,\n logSearch: \"\",\n wrap: false,\n };\n let apiClient;\n let events;\n let pollTimer;\n let previousRoute = \"\";\n let jobsLoadPromise;\n let refreshJobsAfterCurrentLoad = false;\n\n const route = () => {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URLSearchParams(query) };\n };\n\n const client = async () => {\n apiClient ??= import(\"/api-client.mjs\");\n return apiClient;\n };\n\n const apiJson = async (path, options = {}) => {\n const { apiFetch } = await client();\n const headers = new Headers(options.headers || {});\n headers.set(\"accept\", \"application/json\");\n if (options.body && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n const response = await apiFetch(path, { ...options, headers });\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n const error = new Error(payload.error || `Request failed (${response.status})`);\n error.status = response.status;\n throw error;\n }\n return response.status === 204 ? null : response.json();\n };\n\n const apiBlob = async (path) => {\n const { apiFetch } = await client();\n const response = await apiFetch(path);\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n throw new Error(payload.error || `Download failed (${response.status})`);\n }\n return response.blob();\n };\n\n const showToast = (message) => {\n const toast = document.querySelector(\"#toast\");\n toast.textContent = message;\n toast.hidden = false;\n window.clearTimeout(showToast.timer);\n showToast.timer = window.setTimeout(() => {\n toast.hidden = true;\n }, 4500);\n };\n\n const dateValue = (value) => {\n if (!value) {\n return null;\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n return Number.isNaN(date.getTime()) ? null : date;\n };\n\n const formatDate = (value) => {\n const date = dateValue(value);\n return date ? new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date) : \"—\";\n };\n\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) {\n return \"—\";\n }\n const seconds = Math.floor(value / 1000);\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n };\n\n const workflowName = (workflowId) =>\n state.workflows.find((workflow) => workflow.id === workflowId)?.name || workflowId || \"Unknown workflow\";\n const pageHeading = (title, subtitle, action = \"\") =>\n `<div class=\"page-heading\"><div><h1>${escape(title)}</h1>${\n subtitle ? `<p class=\"subtitle\">${escape(subtitle)}</p>` : \"\"\n }</div>${action}</div>`;\n const runWorkflowButton = () =>\n `<button class=\"button primary\" data-action=\"new-run\">${icon(\"plus\")}Run workflow</button>`;\n const permissionMessage = (error, scope) =>\n [401, 403].includes(error?.status)\n ? `Your account needs the ${scope} permission to view this data.`\n : error?.message || \"The Flow API could not be reached.\";\n\n const setNavigation = (page, jobId = \"\") => {\n const currentJob = jobId ? state.jobs.find((job) => String(job.id) === String(jobId)) : null;\n const currentJobId = currentJob?.id ?? jobId;\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, name]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${page === id ? \"active\" : \"\"}\" ${\n page === id ? 'aria-current=\"page\"' : \"\"\n }>${icon(name)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n state.jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n document.querySelector(\"#breadcrumbs\").innerHTML = `${\n jobId\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${escape(\n currentJobId\n )}</strong>`\n : `<strong>${escape(page[0].toUpperCase() + page.slice(1))}</strong>`\n }`;\n document.title = `${jobId ? `Run #${currentJobId}` : page[0].toUpperCase() + page.slice(1)} · Flow`;\n };\n const setBreadcrumbs = (page, jobId = null) => setNavigation(page, jobId);\n\n const loadJobs = async ({ older = false } = {}) => {\n if (jobsLoadPromise) {\n if (!older) {\n refreshJobsAfterCurrentLoad = true;\n }\n await jobsLoadPromise;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n return;\n }\n state.loadingJobs = true;\n const beforeId = older && state.jobs.length ? Math.min(...state.jobs.map((job) => Number(job.id))) : null;\n jobsLoadPromise = (async () => {\n try {\n const query = new URLSearchParams({ limit: older ? \"50\" : \"100\" });\n if (beforeId) {\n query.set(\"beforeId\", String(beforeId));\n }\n if (state.workflow !== \"all\") {\n query.set(\"workflowId\", state.workflow);\n }\n const data = await apiJson(`/api/jobs?${query}`);\n if (older) {\n const merged = new Map(state.jobs.map((job) => [String(job.id), job]));\n for (const job of data.jobs || []) {\n merged.set(String(job.id), job);\n }\n state.jobs = [...merged.values()].sort((a, b) => Number(b.id) - Number(a.id));\n } else {\n state.jobs = data.jobs || [];\n }\n state.hasMore = Boolean(data.hasMore);\n state.errors.jobs = null;\n state.jobsLoaded = true;\n } catch (error) {\n state.errors.jobs = error;\n state.jobsLoaded = true;\n } finally {\n state.loadingJobs = false;\n }\n })();\n try {\n await jobsLoadPromise;\n } finally {\n jobsLoadPromise = undefined;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n }\n };\n\n const loadWorkflows = async ({ retry = false } = {}) => {\n if (state.workflowsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workflows\");\n state.workflows = data.workflows || [];\n state.errors.workflows = null;\n } catch (error) {\n state.errors.workflows = error;\n } finally {\n state.workflowsLoaded = true;\n }\n };\n\n const loadWorkers = async ({ retry = false } = {}) => {\n if (state.workersLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workers\");\n state.workers = data.workers || [];\n state.errors.workers = null;\n } catch (error) {\n state.errors.workers = error;\n } finally {\n state.workersLoaded = true;\n }\n };\n\n const loadSecrets = async ({ retry = false } = {}) => {\n if (state.secretsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/secrets\");\n state.secrets = data.secrets || [];\n state.errors.secrets = null;\n } catch (error) {\n state.errors.secrets = error;\n } finally {\n state.secretsLoaded = true;\n }\n };\n\n const workflowFormSource = () => {\n const source = document.getElementById(\"new-workflow-source\")?.value.trim() || \"\";\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n if (!source || !name) {\n return source;\n }\n const yamlName = `name: ${JSON.stringify(name)}`;\n return /^name\\s*:/m.test(source) ? source.replace(/^name\\s*:[^\\r\\n]*/m, yamlName) : `${yamlName}\\n${source}`;\n };\n\n const filteredJobs = () =>\n state.jobs.filter((job) => {\n const text = [job.id, job.workflowId, workflowName(job.workflowId), job.status, job.workerId]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status)) &&\n (state.workflow === \"all\" || job.workflowId === state.workflow) &&\n (state.worker === \"all\" || (state.worker === \"queued\" ? !job.workerId : job.workerId === state.worker))\n );\n });\n\n const renderRunsTable = () => {\n const filtered = filteredJobs();\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(state.page, pages);\n const rows = filtered.slice((state.page - 1) * 8, state.page * 8);\n const target = document.querySelector(\"#run-results\");\n if (!target) {\n return;\n }\n target.innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n state.jobs.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n rows.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Live Flow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / run</th><th scope=\"col\">Status</th><th scope=\"col\">Worker</th><th scope=\"col\">Updated</th><th scope=\"col\">Created</th></tr></thead><tbody>${rows\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${encodeURIComponent(job.id)}\" aria-label=\"Run ${escape(\n job.id\n )}: ${escape(workflowName(job.workflowId))}, ${escape(\n labels[job.status] || job.status\n )}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${escape(\n workflowName(job.workflowId)\n )}</span><span class=\"run-subtitle\"><span class=\"mono\">#${escape(\n job.id\n )}</span><span class=\"separator\">·</span><span class=\"mono\">${escape(\n job.workflowId\n )}</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"server\"\n )}${escape(job.workerId || \"Queued\")}</span></td><td>${escape(\n formatDate(job.updatedAt)\n )}</td><td>${escape(formatDate(job.createdAt))}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\"search\")}<h3>${\n state.errors.jobs ? \"Runs unavailable\" : \"No runs found\"\n }</h3><p>${escape(state.errors.jobs?.message || \"Try a different search or filter.\")}</p>${\n state.errors.jobs\n ? '<button class=\"button\" data-action=\"retry-runs\">Retry</button>'\n : '<button class=\"button\" data-action=\"clear-filters\">Clear filters</button>'\n }</div>`\n }\n <div class=\"table-footer\"><span role=\"status\">${\n filtered.length\n ? `${(state.page - 1) * 8 + 1}–${Math.min(state.page * 8, filtered.length)} of ${filtered.length} runs`\n : \"0 runs\"\n }</span><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><span>${\n state.page\n } / ${pages}</span><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages && !state.hasMore ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div>${\n state.hasMore ? '<div class=\"table-hint\">Older Flow runs are available.</div>' : \"\"\n }`;\n };\n\n const renderRuns = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `${pageHeading(\"Runs\", \"\", runWorkflowButton())}${\n state.loadingJobs && !state.jobs.length ? '<div class=\"empty-state\"><p>Loading runs…</p></div>' : \"\"\n }<section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Recent runs <span class=\"count\">${\n state.jobs.length\n }</span></h2></div><div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, workflows, or workers…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label><select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${escape(\n workflow.name\n )}</option>`\n )\n .join(\n \"\"\n )}</select><select id=\"worker-filter\" class=\"select-filter\" aria-label=\"Filter by worker\"><option value=\"all\">All workers</option><option value=\"queued\" ${\n state.worker === \"queued\" ? \"selected\" : \"\"\n }>Queued</option>${[...new Set(state.jobs.map((job) => job.workerId).filter(Boolean))]\n .map(\n (worker) =>\n `<option value=\"${escape(worker)}\" ${state.worker === worker ? \"selected\" : \"\"}>${escape(worker)}</option>`\n )\n .join(\"\")}</select></div><div id=\"run-results\"></div></section>`;\n renderRunsTable();\n };\n\n const renderRunDetails = (run) => {\n setBreadcrumbs(\"runs\", run.jobId);\n const inputs = run.inputs || {};\n const failedStep = (run.steps || []).find((step) => step.status === \"failed\");\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\"back\")}All runs</a>\n <div class=\"page-heading run-heading\"><div><h1>${iconForStatus(run.status)}${escape(\n run.workflowName || workflowName(run.workflowId)\n )}</h1><p class=\"subtitle\"><span class=\"mono\">#${escape(run.jobId)}</span><span>·</span><span>${escape(\n inputs.full_name || inputs.repo || run.workflowId || \"Workflow run\"\n )}</span></p></div>\n <div class=\"heading-actions\">${\n run.canViewLogs\n ? `<button class=\"button\" data-action=\"download-logs\">${icon(\"download\")}Download logs</button>`\n : \"\"\n }<button class=\"button ${[\"running\", \"pending\"].includes(run.status) ? \"danger\" : \"primary\"}\" data-action=\"${\n [\"running\", \"pending\"].includes(run.status) ? \"cancel\" : \"rerun\"\n }\" ${state.mutating ? \"disabled\" : \"\"}>${icon([\"running\", \"pending\"].includes(run.status) ? \"stop\" : \"refresh\")}${\n [\"running\", \"pending\"].includes(run.status) ? \"Cancel run\" : \"Re-run workflow\"\n }</button></div></div>\n ${\n failedStep\n ? `<div class=\"failure-banner\">${icon(\"alert\")}<div><strong>${escape(failedStep.name || \"Step\")} failed${\n failedStep.exitCode !== undefined ? ` with exit code ${escape(failedStep.exitCode)}` : \"\"\n }</strong><p>${escape(\n failedStep.error || \"See the failed step output.\"\n )}</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n run.status\n )}</span></div><div><span class=\"meta-label\">BRANCH</span><span class=\"meta-value\">${icon(\"branch\")}${escape(\n inputs.branch || inputs.ref?.replace(/^refs\\/heads\\//, \"\") || \"—\"\n )}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\"commit\")}${escape(\n (inputs.commit_sha || inputs.commit || \"—\").slice(0, 8)\n )}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${formatDuration(\n run.durationMs\n )}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\"server\")}${escape(\n run.workerId || \"Queued\"\n )}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n void renderRunTab(run);\n };\n\n const liveRunPage = async (id) => {\n main.innerHTML = `<div class=\"empty-state\">${icon(\"clock\")}<p>Loading run #${escape(id)}…</p></div>`;\n try {\n const run = await apiJson(`/api/runs/${encodeURIComponent(id)}`);\n state.errors.run = null;\n state.run = run;\n const importantStep = run.steps?.findIndex((step) => [\"failed\", \"running\"].includes(step.status)) ?? -1;\n if (importantStep >= 0) {\n state.step = importantStep;\n }\n renderRunDetails(run);\n } catch (error) {\n state.errors.run = error;\n main.innerHTML = `<div class=\"empty-state\">${icon(\"alert\")}<h1>Run unavailable</h1><p>${escape(\n error.message\n )}</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n };\n\n const renderRunTab = async (run) => {\n const content = document.querySelector(\"#detail-content\");\n if (!content) {\n return;\n }\n if (state.detailTab === \"inputs\") {\n let source = run.workflowSourceYaml || \"\";\n if (!source && run.workflowId) {\n try {\n const workflow = await apiJson(\n `/api/workflows/${encodeURIComponent(run.workflowId)}?revision=${encodeURIComponent(\n run.workflowRevision || \"\"\n )}`\n );\n source = workflow.sourceYaml || \"\";\n } catch {\n /* Workflow source has separate permission checks. */\n }\n }\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs</h3><pre>${escape(\n JSON.stringify(run.inputs || {}, null, 2)\n )}</pre></section>${\n source\n ? `<section class=\"source-view\" style=\"margin-top:16px\"><h3>Workflow source · revision ${escape(\n run.workflowRevision || \"—\"\n )}</h3><pre>${escape(source)}</pre></section>`\n : '<p class=\"settings-note\" style=\"margin-top:14px\">Workflow source is unavailable for this run.</p>'\n }`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n const artifacts = run.artifacts || [];\n content.innerHTML = artifacts.length\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">${\n artifacts.length\n }</span></h2>${artifacts\n .map(\n (path, index) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${escape(\n path.split(\"/\").pop() || path\n )}</strong><p>${escape(\n path\n )}</p></div><button class=\"button\" data-action=\"download-artifact\" data-value=\"${index}\">${icon(\n \"download\"\n )}Download</button></div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No artifacts</h3><p>This run did not publish any artifacts.</p></div>';\n return;\n }\n if (state.detailTab === \"history\") {\n const history = await loadRunHistory(run);\n content.innerHTML =\n history.length > 1\n ? `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${encodeURIComponent(\n entry.jobId\n )}\">Run #${escape(entry.jobId)}</a></strong><p>${escape(\n entry.workflowName || entry.workflowId || \"Workflow\"\n )} · ${formatDate(entry.startedAt)}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No previous attempts</h3><p>This run has no parent run linked to it.</p></div>';\n return;\n }\n const steps = run.steps || [];\n if (!steps.length) {\n content.innerHTML =\n '<div class=\"empty-state\"><h3>No execution steps yet</h3><p>Step details will appear when Flow records them.</p></div>';\n return;\n }\n state.step = Math.max(0, Math.min(state.step, steps.length - 1));\n const selected = steps[state.step];\n const stepButtons = steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\");\n if (!run.canViewLogs) {\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${stepButtons}</aside><div class=\"log-panel\"><div class=\"empty-state\">${icon(\n \"lock\"\n )}<h3>Logs require permission</h3><p>Your account needs <span class=\"mono\">logs:read</span> to view step output.</p></div></div></section>`;\n return;\n }\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\")}</aside><div class=\"log-panel\"><div class=\"log-heading\"><h3>${escape(selected.name)}<small>${\n selected.exitCode !== undefined\n ? `exit code ${escape(selected.exitCode)}`\n : escape(labels[selected.status] || selected.status)\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\">${icon(\"wrap\")}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logSearch\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Step failed\"\n : selected.status === \"running\"\n ? \"Step is running\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>`;\n renderLog(selected.logContent || \"\");\n };\n\n const loadRunHistory = async (run) => {\n const entries = [run];\n const visited = new Set([String(run.jobId)]);\n let parent = run.parentId;\n while (parent && !visited.has(String(parent)) && entries.length < 25) {\n visited.add(String(parent));\n try {\n const entry = await apiJson(`/api/runs/${encodeURIComponent(parent)}`);\n entries.push(entry);\n parent = entry.parentId;\n } catch {\n break;\n }\n }\n return entries;\n };\n\n const renderLog = (text) => {\n const log = document.querySelector(\"#log-lines\");\n if (!log) {\n return;\n }\n const query = state.logSearch.toLowerCase();\n let matches = 0;\n log.innerHTML = String(text)\n .split(\"\\n\")\n .map((line, index) => {\n let content = escape(line) || \" \";\n const start = query ? line.toLowerCase().indexOf(query) : -1;\n if (start >= 0) {\n matches++;\n content = `${escape(line.slice(0, start))}<mark>${escape(\n line.slice(start, start + query.length)\n )}</mark>${escape(line.slice(start + query.length))}`;\n }\n const type = /\\b(error|failed|fatal)\\b/i.test(line)\n ? \"error\"\n : /\\b(success|passed|done)\\b/i.test(line)\n ? \"pass\"\n : \"\";\n return `<div class=\"log-line ${type}\"><span class=\"line-number\">${\n index + 1\n }</span><span class=\"line-text\">${content}</span></div>`;\n })\n .join(\"\");\n const result = document.querySelector(\"#log-matches\");\n if (result) {\n result.textContent = query ? `${matches} matching lines` : \"\";\n }\n };\n const renderLiveLog = renderLog;\n\n const downloadBlob = (blob, filename) => {\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const renderRevisionDiff = (previousSource, currentSource) => {\n const before = previousSource === null ? [] : previousSource.split(\"\\n\");\n const after = String(currentSource ?? \"\").split(\"\\n\");\n let prefixLength = 0;\n while (\n prefixLength < before.length &&\n prefixLength < after.length &&\n before[prefixLength] === after[prefixLength]\n ) {\n prefixLength++;\n }\n let suffixLength = 0;\n while (\n suffixLength < before.length - prefixLength &&\n suffixLength < after.length - prefixLength &&\n before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]\n ) {\n suffixLength++;\n }\n const renderLines = (lines, className, prefix) =>\n lines\n .filter((line, index) => line || index < lines.length - 1)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escape(line)}</span>`)\n .join(\"\");\n return [\n renderLines(before.slice(0, prefixLength), \"text-gray-300\", \" \"),\n renderLines(before.slice(prefixLength, before.length - suffixLength), \"text-rose-300 bg-rose-500/10\", \"- \"),\n renderLines(after.slice(prefixLength, after.length - suffixLength), \"text-emerald-300 bg-emerald-500/10\", \"+ \"),\n renderLines(before.slice(before.length - suffixLength), \"text-gray-300\", \" \"),\n ].join(\"\");\n };\n\n const loadRevisionDiff = async (workflowId, revision) => {\n try {\n const current = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision}`);\n const previous =\n revision > 1\n ? await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision - 1}`)\n : null;\n const sourceDiff = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n openDialog(\n `Workflow ${current.name || current.id}`,\n `<p class=\"dialog-description\">${escape(current.name || current.id)} — Revision ${escape(\n current.revision\n )}</p><div class=\"source-view\"><pre>${sourceDiff}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n } catch (error) {\n showToast(`Failed to load revision diff: ${error.message}`);\n }\n };\n\n const liveWorkflowsPage = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n setBreadcrumbs(\"workflows\");\n const tabs = [\n { id: \"all\", label: \"All\", filter: () => true },\n { id: \"published\", label: \"Published\", filter: (workflow) => workflow.status === \"published\" },\n { id: \"draft\", label: \"Drafts\", filter: (workflow) => workflow.status === \"draft\" },\n { id: \"archived\", label: \"Archived\", filter: (workflow) => workflow.status === \"archived\" },\n ];\n const selectedTab = tabs.find((tab) => tab.id === state.workflowFilter) || tabs[0];\n const visibleWorkflows = state.workflows.filter(selectedTab.filter);\n const tabButtons = tabs\n .map((tab) => {\n const count = state.workflows.filter(tab.filter).length;\n return `<button class=\"status-tab ${\n state.workflowFilter === tab.id ? \"active\" : \"\"\n }\" data-action=\"workflow-status-tab\" data-value=\"${tab.id}\" aria-pressed=\"${state.workflowFilter === tab.id}\">${\n tab.label\n }<span class=\"count\">${count}</span></button>`;\n })\n .join(\"\");\n const workflowRows = visibleWorkflows\n .map((workflow) => {\n const index = state.workflows.indexOf(workflow);\n return `<div class=\"workflow-item\"><div class=\"workflow-header\"><span class=\"workflow-icon\">${icon(\n \"workflow\"\n )}</span><span class=\"workflow-name\">${escape(\n workflow.name || workflow.id\n )}</span><span class=\"workflow-status ${\n workflow.status === \"published\" ? \"success\" : workflow.status === \"archived\" ? \"disabled\" : \"enabled\"\n }\">${escape(workflow.status)}</span></div><div class=\"workflow-meta\"><span>Revision ${escape(\n workflow.revision || \"—\"\n )}</span><span class=\"workflow-id mono\">${escape(\n workflow.id\n )}</span></div><div class=\"workflow-actions\"><a class=\"text-button\" href=\"#/runs?workflow=${encodeURIComponent(\n workflow.id\n )}\">View runs ${icon(\"arrow\")}</a><a class=\"button\" href=\"/settings/workflows/${encodeURIComponent(\n workflow.id\n )}\" target=\"_top\">Edit ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}Source</button>${\n workflow.revision > 1\n ? `<button class=\"button small\" data-action=\"workflow-revision\" data-value=\"${index}\" data-dir=\"-1\">Previous revision</button>`\n : \"\"\n }${\n workflow.status !== \"published\"\n ? `<button class=\"button small success\" data-action=\"workflow-publish\" data-value=\"${index}\">Publish ${icon(\n \"rocket\"\n )}</button>`\n : \"\"\n }</div></div>`;\n })\n .join(\"\");\n const content =\n workflowRows ||\n `<div class=\"empty-state\"><h3>No ${escape(selectedTab.label.toLowerCase())} workflows</h3><p>${\n state.workflows.length\n ? \"Choose another status tab or create a workflow.\"\n : \"Flow has no workflow definitions to show.\"\n }</p></div>`;\n main.innerHTML = `${pageHeading(\"Workflows\", \"Workflow definitions\", runWorkflowButton())}${\n state.errors.workflows\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Workflows unavailable</h3><p>${escape(\n permissionMessage(state.errors.workflows, \"workflows:read\")\n )}</p><button class=\"button\" data-action=\"retry-workflows\">Retry</button></div>`\n : `<div class=\"status-tabs\" role=\"group\" aria-label=\"Filter workflows by status\">${tabButtons}</div><div class=\"workflow-list\">${content}</div>`\n }`;\n };\n\n const liveWorkersPage = async () => {\n if (!state.workersLoaded) {\n await loadWorkers();\n }\n setBreadcrumbs(\"workers\");\n main.innerHTML = `${pageHeading(\"Workers\", \"Live runner presence from Flow.\")}${\n state.errors.workers\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Worker status unavailable</h3><p>${escape(\n permissionMessage(state.errors.workers, \"workers:read\")\n )}</p><button class=\"button\" data-action=\"retry-workers\">Retry</button></div>`\n : `<div class=\"cards\">${\n state.workers\n .map(\n (worker) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\"server\")}<h2>${escape(\n worker.workerId || \"Unknown worker\"\n )}</h2><span class=\"status ${worker.online ? \"success\" : \"cancelled\"}\">${\n worker.online ? \"Online\" : \"Offline\"\n }</span></div><div class=\"card-details\">${\n (Array.isArray(worker.tags) ? worker.tags : [])\n .map((tag) => `<span class=\"tag mono\">${escape(tag)}</span>`)\n .join(\"\") || '<span class=\"settings-note\">No labels</span>'\n }</div><div class=\"worker-stats\"><div><span class=\"meta-label\">Active jobs</span><strong>${escape(\n worker.activeJobs ?? 0\n )}</strong></div><div><span class=\"meta-label\">Concurrency</span><strong>${escape(\n worker.concurrency ?? \"—\"\n )}</strong></div></div><div class=\"card-footer\"><span class=\"settings-note\">${\n worker.version ? `Runner ${escape(worker.version)}` : \"Runner version unavailable\"\n }</span><span class=\"settings-note\">Seen ${escape(\n formatDate(worker.lastSeen)\n )}</span></div></article>`\n )\n .join(\"\") ||\n '<div class=\"empty-state\"><h3>No workers reported</h3><p>Flow has no worker-presence records.</p></div>'\n }</div>`\n }`;\n };\n\n const liveSettingsPage = async () => {\n if (!state.secretsLoaded) {\n await loadSecrets();\n }\n setBreadcrumbs(\"settings\");\n main.innerHTML = `${pageHeading(\n \"Settings\",\n \"Preferences and secret names from Flow.\"\n )}<section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n localStorage.getItem(\"flow-concept-compact\") === \"true\" ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secret names</h2><p class=\"settings-note\">Values are never requested or displayed in this preview.</p>${\n state.errors.secrets\n ? `<p class=\"settings-note\">${escape(\n permissionMessage(state.errors.secrets, \"secrets:read\")\n )}</p><button class=\"button\" data-action=\"retry-secrets\">Retry</button>`\n : state.secrets\n .map(\n (name) =>\n `<div class=\"setting-row\"><strong class=\"mono\">${escape(\n name\n )}</strong><span class=\"secret-value\" aria-label=\"Secret value hidden\">••••••••••••</span><button class=\"button tiny danger\" data-action=\"remove-secret\" data-value=\"${name}\">Remove</button></div>`\n )\n .join(\"\") || '<p class=\"settings-note\">No secret names are configured.</p>'\n }${\n !state.errors.secrets && state.secrets.length < 5\n ? '<div class=\"setting-row\"><button class=\"button tiny\" data-action=\"add-secret\">Add secret</button></div>'\n : \"\"\n }</section><section class=\"settings-section\"><h2>Workflow editing</h2><p class=\"settings-note\">Create and edit workflow definitions.</p><form id=\"new-workflow-form\"><label class=\"form-field\"><span>Workflow name</span><input id=\"new-workflow-name\" type=\"text\" placeholder=\"e.g. build-docker-image\" required/></label><label class=\"form-field\"><span>Workflow ID</span><input id=\"new-workflow-id\" type=\"text\" placeholder=\"derived-from-name\" required/></label><label class=\"form-field\"><span>Enabled</span><input type=\"checkbox\" id=\"new-workflow-enabled\" checked/></label><div class=\"form-field\"><span>YAML source</span><textarea id=\"new-workflow-source\" rows=\"8\" placeholder=\"name: build-docker-image\\nruns-on: [main-server, docker]\\n\\nsteps:\\n - name: Checkout repository\\n run: git checkout '$COMMIT'\\n - name: Install dependencies\\n run: pnpm install --frozen-lockfile\\n - name: Run tests\\n run: pnpm test\\n - name: Build and push\\n run: pnpm publish\"></textarea></label></div><div class=\"form-footer\"><button type=\"button\" class=\"button\" data-action=\"validate-workflow\">Validate workflow</button><button type=\"button\" class=\"button\" data-action=\"run-now\">Run now</button><button type=\"submit\" class=\"button primary\">${icon(\n \"plus\"\n )}Create workflow</button><button type=\"button\" class=\"button\" data-action=\"close-dialog\">Cancel</button></div></form><a class=\"button secondary\" href=\"/settings/workflows\" target=\"_top\">Open full workflow settings ${icon(\n \"arrow\"\n )}</a></section>`;\n };\n\n const showNotFound = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `<div class=\"empty-state\"><h1>404 — Page not found</h1><p>This preview route does not exist.</p><a class=\"button\" href=\"#/runs\">Go to runs</a></div>`;\n };\n\n const openDialog = (title, content) => {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${escape(\n title\n )}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${content}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n };\n\n const openDispatchDialog = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n if (state.errors.workflows) {\n showToast(permissionMessage(state.errors.workflows, \"workflows:read\"));\n return;\n }\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">This will enqueue a real Flow job.</p><label class=\"form-field\"><span>Workflow</span><select id=\"dispatch-workflow\">${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${workflow.enabled === false ? \"disabled\" : \"\"}>${escape(\n workflow.name || workflow.id\n )}${workflow.enabled === false ? \" (disabled)\" : \"\"}</option>`\n )\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Inputs (JSON object)</span><textarea id=\"dispatch-inputs\" rows=\"6\">{}</textarea></label><p class=\"dialog-note\">Running a workflow requires write permission and creates real job(s).</p><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" data-action=\"dispatch-confirm\">${icon(\n \"runs\"\n )}Review and run</button></div>`\n );\n };\n\n const dispatchWorkflow = async () => {\n if (state.mutating) {\n return;\n }\n const workflowId = document.querySelector(\"#dispatch-workflow\")?.value;\n if (!workflowId) {\n showToast(\"Select a workflow first.\");\n return;\n }\n let inputs;\n try {\n inputs = JSON.parse(document.querySelector(\"#dispatch-inputs\")?.value || \"{}\");\n if (!inputs || typeof inputs !== \"object\" || Array.isArray(inputs)) {\n throw new Error(\"Inputs must be a JSON object.\");\n }\n } catch (error) {\n showToast(error.message);\n return;\n }\n const workflow = state.workflows.find((item) => item.id === workflowId);\n if (workflow?.enabled === false) {\n showToast(\"This workflow is disabled and cannot be run.\");\n return;\n }\n state.mutating = true;\n const dispatchButton = document.querySelector('[data-action=\"dispatch-confirm\"]');\n if (dispatchButton) {\n dispatchButton.disabled = true;\n }\n if (!window.confirm(`Run ${workflow?.name || workflowId} now? This creates real job(s).`)) {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n return;\n }\n try {\n const result = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {\n method: \"POST\",\n body: JSON.stringify({ inputs }),\n });\n dialog.close();\n state.jobs = [];\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Queued ${result.jobs?.length || 0} real job(s).`);\n if (result.jobs?.length) {\n window.location.hash = `/runs/${result.jobs[0]}`;\n }\n } catch (error) {\n showToast(`Could not run workflow: ${error.message}`);\n } finally {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n }\n };\n\n const render = async () => {\n const { path, query } = route();\n const [section, id] = path.split(\"/\").slice(1);\n const page = section || \"runs\";\n const changed = previousRoute !== window.location.hash;\n if (changed) {\n state.step = 0;\n state.detailTab = \"logs\";\n state.logSearch = \"\";\n if (section !== \"runs\" || !id) {\n state.run = null;\n }\n }\n previousRoute = window.location.hash;\n setNavigation(page === \"runs\" ? \"runs\" : [\"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\", id);\n if (path === \"/runs\" || path === \"\") {\n const workflowFilter = query.get(\"workflow\") || \"all\";\n const searchFilter = query.get(\"search\") || \"\";\n if (workflowFilter !== state.workflow || searchFilter !== state.search) {\n state.jobs = [];\n state.jobsLoaded = false;\n state.hasMore = false;\n }\n state.workflow = workflowFilter;\n state.search = searchFilter;\n const statusFilter = query.get(\"status\");\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(statusFilter)) {\n state.status = statusFilter;\n }\n if (!state.jobsLoaded) {\n await loadJobs();\n }\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n renderRuns();\n if (changed) {\n window.scrollTo(0, 0);\n }\n return;\n }\n if (section === \"runs\" && id) {\n await liveRunPage(id);\n return;\n }\n if (path === \"/workflows\") {\n await liveWorkflowsPage();\n return;\n }\n if (path === \"/workers\") {\n await liveWorkersPage();\n return;\n }\n if (path === \"/settings\") {\n await liveSettingsPage();\n return;\n }\n showNotFound();\n };\n\n const action = async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return false;\n }\n const { action: name, value } = button.dataset;\n if (name === \"new-run\") {\n await openDispatchDialog();\n return true;\n }\n if (name === \"close-dialog\") {\n dialog.close();\n return true;\n }\n if (name === \"dispatch-confirm\") {\n await dispatchWorkflow();\n return true;\n }\n if (name === \"retry-runs\") {\n state.jobsLoaded = false;\n state.errors.jobs = null;\n await loadJobs();\n renderRuns();\n return true;\n }\n if (name === \"retry-workflows\") {\n state.workflowsLoaded = false;\n state.errors.workflows = null;\n await liveWorkflowsPage();\n return true;\n }\n if (name === \"workflow-status-tab\") {\n state.workflowFilter = value;\n render();\n return true;\n }\n if (name === \"retry-workers\") {\n state.workersLoaded = false;\n state.errors.workers = null;\n await liveWorkersPage();\n return true;\n }\n if (name === \"retry-secrets\") {\n state.secretsLoaded = false;\n state.errors.secrets = null;\n await liveSettingsPage();\n return true;\n }\n if (name === \"add-secret\") {\n const name = window.prompt(\"Enter secret name (uppercase letters, numbers, underscores):\");\n if (!name) {\n return true;\n }\n if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {\n showToast(\"Secret name must start with a letter and contain only letters, numbers, and underscores.\");\n return true;\n }\n const secretValue = window.prompt(`Enter the value for ${name}:`);\n if (secretValue === null || secretValue.length === 0) {\n showToast(\"Secret value cannot be empty.\");\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify({ value: secretValue, encoding: \"utf8\" }),\n });\n showToast(`Secret ${name} added.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to add secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"remove-secret\") {\n const name = value;\n if (!window.confirm(`Delete secret ${name}?`)) {\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, { method: \"DELETE\" });\n showToast(`Secret ${name} removed.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to remove secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"status-filter\") {\n state.status = value;\n state.page = 1;\n renderRunsTable();\n button.focus({ preventScroll: true });\n return true;\n }\n if (name === \"clear-filters\") {\n state.search = \"\";\n state.status = \"all\";\n state.workflow = \"all\";\n state.worker = \"all\";\n state.page = 1;\n renderRuns();\n document.querySelector(\"#run-search\")?.focus();\n return true;\n }\n if (name === \"prev-page\" || name === \"next-page\") {\n const pages = Math.max(1, Math.ceil(filteredJobs().length / 8));\n if (name === \"next-page\" && state.page === pages && state.hasMore) {\n await loadJobs({ older: true });\n }\n state.page = Math.max(1, state.page + (name === \"next-page\" ? 1 : -1));\n renderRunsTable();\n return true;\n }\n if (name === \"detail-tab\" && state.run) {\n state.detailTab = value;\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"step\" && state.run) {\n state.step = Number(value);\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"jump-error\" && state.run) {\n const failed = state.run.steps?.findIndex((step) => step.status === \"failed\") ?? -1;\n if (failed >= 0) {\n state.step = failed;\n await renderRunTab(state.run);\n }\n return true;\n }\n if (name === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\")?.classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n return true;\n }\n if (name === \"copy-log\" && state.run) {\n try {\n await navigator.clipboard.writeText(state.run.steps?.[state.step]?.logContent || \"\");\n showToast(\"Step log copied.\");\n } catch {\n showToast(\"Clipboard unavailable.\");\n }\n return true;\n }\n if (name === \"download-logs\" && state.run) {\n const text = (state.run.steps || []).map((step) => `=== ${step.name} ===\\n${step.logContent || \"\"}`).join(\"\\n\\n\");\n downloadBlob(new Blob([text], { type: \"text/plain;charset=utf-8\" }), `flow-run-${state.run.jobId}.log`);\n return true;\n }\n if (name === \"download-artifact\" && state.run) {\n const artifact = state.run.artifacts?.[Number(value)];\n if (!artifact) {\n return true;\n }\n const encoded = String(artifact).split(\"/\").map(encodeURIComponent).join(\"/\");\n try {\n const blob = await apiBlob(`/api/runs/${encodeURIComponent(state.run.jobId)}/artifacts/${encoded}`);\n downloadBlob(blob, String(artifact).split(\"/\").pop() || \"artifact\");\n } catch (error) {\n showToast(`Download failed: ${error.message}`);\n }\n return true;\n }\n if (name === \"workflow-source\") {\n const workflow = state.workflows[Number(value)];\n if (workflow) {\n openDialog(\n workflow.name || workflow.id,\n `<p class=\"dialog-description\">Revision ${escape(workflow.revision || \"—\")} · ${escape(\n workflow.status || \"\"\n )}</p><div class=\"source-view\"><pre>${escape(\n workflow.sourceYaml || \"Workflow source unavailable.\"\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n return true;\n }\n if (name === \"validate-workflow\") {\n const source = workflowFormSource();\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n apiJson(`/api/workflows/validate`, {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source }),\n })\n .then((result) => {\n const status = result.valid ? \"passed\" : \"failed\";\n showToast(`Validation ${status}: ${result.error || \"\"}`);\n })\n .catch((error) => {\n showToast(`Validation failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"run-now\") {\n const id = document.getElementById(\"new-workflow-id\")?.value.trim();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const source = workflowFormSource();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n if (!id || !name) {\n showToast(\"Please enter a workflow name and ID first.\");\n return true;\n }\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return true;\n }\n if (!enabled) {\n showToast(\"Enable the workflow before running it.\");\n return true;\n }\n void (async () => {\n try {\n await apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n });\n const result = await apiJson(`/api/workflows/${encodeURIComponent(id)}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showToast(\n `Queued ${result.jobs?.length || 0} job${result.jobs?.length === 1 ? \"\" : \"s\"} from revision ${\n result.revision\n }.`\n );\n } catch (error) {\n showToast(`Failed to run workflow: ${error.message}`);\n }\n })();\n return true;\n }\n if (name === \"workflow-revision\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n const dir = Number(button.dataset.dir);\n const targetRev = (workflow.revision || 1) + dir;\n if (targetRev < 1) {\n return true;\n }\n loadRevisionDiff(workflow.id, targetRev);\n return true;\n }\n if (name === \"workflow-publish\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n if (!window.confirm(`Publish workflow ${workflow.name || workflow.id}? This will make it available for runs.`)) {\n return true;\n }\n apiJson(`/api/workflows/${workflow.id}/publish`, { method: \"POST\" })\n .then((result) => {\n showToast(`Published ${result.id || workflow.id} revision ${result.revision}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Publish failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"rerun\" && state.run) {\n if (state.mutating) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n if (\n !window.confirm(\n `Re-run ${\n state.run.workflowName || state.run.workflowId || \"this workflow\"\n }? This creates a new job from the active workflow revision.`\n )\n ) {\n state.mutating = false;\n button.disabled = false;\n return true;\n }\n try {\n const result = await apiJson(`/restart/${encodeURIComponent(state.run.jobId)}`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Re-run queued: #${result.id}`);\n window.location.hash = `/runs/${result.id}`;\n } catch (error) {\n showToast(`Re-run failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n if (name === \"cancel\" && state.run) {\n if (state.mutating) {\n return true;\n }\n if (\n ![\"pending\", \"running\"].includes(state.run.status) ||\n !window.confirm(`Cancel live run #${state.run.jobId}?`)\n ) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n try {\n await apiJson(`/api/jobs/${encodeURIComponent(state.run.jobId)}/cancel`, { method: \"POST\" });\n showToast(`Run #${state.run.jobId} cancelled.`);\n await liveRunPage(state.run.jobId);\n state.jobsLoaded = false;\n await loadJobs();\n } catch (error) {\n showToast(`Cancel failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n return false;\n };\n\n const setupEvents = () => {\n if (events) {\n return;\n }\n events = new EventSource(\"/api/events\");\n events.addEventListener(\"jobs.available\", () => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n });\n events.addEventListener(\"jobs.changed\", (event) => {\n let data = {};\n try {\n data = JSON.parse(event.data || \"{}\");\n } catch {\n /* Refresh the list even if an event payload is malformed. */\n }\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n if (state.run && (!data.jobId || String(data.jobId) === String(state.run.jobId))) {\n void liveRunPage(state.run.jobId);\n }\n });\n events.onerror = () => {\n /* EventSource retries transient disconnects automatically. */\n };\n pollTimer = window.setInterval(() => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n }, 60_000);\n };\n\n document.addEventListener(\"click\", async (event) => {\n const handled = await action(event);\n if (handled) {\n event.preventDefault();\n }\n });\n document.addEventListener(\"submit\", (event) => {\n if (event.target?.id === \"new-workflow-form\") {\n event.preventDefault();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const id = document.getElementById(\"new-workflow-id\")?.value.trim().toLowerCase();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n const source = workflowFormSource();\n if (!name || !id || !source) {\n showToast(\"Please fill in all fields (name, id, and source).\");\n return;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return;\n }\n apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n })\n .then(() => {\n showToast(`Created workflow ${id}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Failed to create workflow: ${error.message}`);\n });\n }\n });\n document.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"log-search\") {\n state.logSearch = event.target.value;\n if (state.run) {\n renderLiveLog(state.run.steps?.[state.step]?.logContent || \"\");\n }\n }\n });\n document.addEventListener(\"change\", (event) => {\n if (event.target.id === \"workflow-filter\") {\n state.workflow = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"worker-filter\") {\n state.worker = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"compact-setting\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n localStorage.setItem(\"flow-concept-compact\", String(event.target.checked));\n }\n if (event.target.id === \"wrap-setting\") {\n state.wrap = event.target.checked;\n localStorage.setItem(\"flow-concept-wrap\", String(event.target.checked));\n }\n });\n window.addEventListener(\"hashchange\", () => {\n state.run = null;\n void render();\n });\n window.addEventListener(\"beforeunload\", () => {\n events?.close();\n window.clearInterval(pollTimer);\n });\n document.querySelectorAll(\"[data-icon]\").forEach((node) => {\n node.innerHTML = icon(node.dataset.icon);\n });\n document.body.classList.toggle(\"compact\", localStorage.getItem(\"flow-concept-compact\") === \"true\");\n state.wrap = localStorage.getItem(\"flow-concept-wrap\") === \"true\";\n const initialQuery = route().query;\n state.workflow = initialQuery.get(\"workflow\") || \"all\";\n state.search = initialQuery.get(\"search\") || \"\";\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(initialQuery.get(\"status\"))) {\n state.status = initialQuery.get(\"status\");\n }\n main.innerHTML = `${pageHeading(\"Runs\", \"Runs\")}<div class=\"empty-state\">${icon(\"clock\")}<p>Loading runs…</p></div>`;\n setupEvents();\n void Promise.all([loadWorkflows(), loadJobs()]).then(() => {\n state.jobsLoaded = true;\n void render();\n });\n}\n", gg = 50, _g = 500, vg = class e {
|
|
12374
|
+
var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigator, URL, URLSearchParams, Headers, EventSource, Blob */\n\nconst icons = {\n runs: '<rect x=\"3\" y=\"4\" width=\"18\" height=\"16\" rx=\"3\"/><path d=\"m10 8 5 4-5 4Z\"/>',\n workflow:\n '<rect x=\"3\" y=\"3\" width=\"6\" height=\"6\" rx=\"1.5\"/><rect x=\"15\" y=\"15\" width=\"6\" height=\"6\" rx=\"1.5\"/><path d=\"M6 9v6a3 3 0 0 0 3 3h6M9 6h6a3 3 0 0 1 3 3v6\"/>',\n server:\n '<rect x=\"3\" y=\"3\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"14\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 6.5h.01M7 17.5h.01M11 6.5h6M11 17.5h6\"/>',\n settings:\n '<path d=\"m9 3-1 3-3 1-2 3 2 2-1 3 3 2 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-2-3 1-2-2Z\"/><circle cx=\"11.5\" cy=\"10.5\" r=\"3\"/>',\n search: '<circle cx=\"10.5\" cy=\"10.5\" r=\"6.5\"/><path d=\"m16 16 4 4\"/>',\n plus: '<path d=\"M12 5v14M5 12h14\"/>',\n arrow: '<path d=\"M5 12h14m-5-5 5 5-5 5\"/>',\n back: '<path d=\"M19 12H5m5-5-5 5 5 5\"/>',\n check: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m8 12 3 3 5-6\"/>',\n failed: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m9 9 6 6m0-6-6 6\"/>',\n running: '<path d=\"M20.4 8.8A9 9 0 1 1 15.2 3.6\"/><path d=\"M20.4 3.6v5.2h-5.2\"/>',\n pending: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n cancelled: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"m6 6 12 12\"/>',\n skipped: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>',\n branch:\n '<circle cx=\"6\" cy=\"5\" r=\"2\"/><circle cx=\"6\" cy=\"19\" r=\"2\"/><circle cx=\"18\" cy=\"6\" r=\"2\"/><path d=\"M6 7v10m0-4h5a7 7 0 0 0 7-5\"/>',\n clock: '<circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M12 7v5l3 2\"/>',\n commit: '<circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M3 12h5m8 0h5\"/>',\n refresh: '<path d=\"M20 7v5h-5M4 17v-5h5\"/><path d=\"M6.1 7a7 7 0 0 1 11.5-1L20 9M4 15l2.4 3A7 7 0 0 0 18 17\"/>',\n terminal: '<path d=\"m5 6 6 6-6 6m9 0h5\"/>',\n download: '<path d=\"M12 3v12m-4-4 4 4 4-4M4 16v4h16v-4\"/>',\n copy: '<rect x=\"8\" y=\"8\" width=\"12\" height=\"12\" rx=\"2\"/><path d=\"M16 8V4H4v12h4\"/>',\n wrap: '<path d=\"M3 6h18M3 12h14a3 3 0 0 1 0 6h-5m3-3-3 3 3 3M3 18h4\"/>',\n code: '<path d=\"m8 6-6 6 6 6m8-12 6 6-6 6M14 3l-4 18\"/>',\n box: '<path d=\"m12 3 9 5v9l-9 5-9-5V8Zm0 9 9-4M3 8l9 4v10M7.5 5.5l9 5\"/>',\n history: '<path d=\"M3 3v6h6M3.6 9a9 9 0 1 1-.2 6M12 7v5l3 2\"/>',\n alert: '<path d=\"m12 3 10 18H2Z\"/><path d=\"M12 9v5m0 3h.01\"/>',\n lock: '<rect x=\"5\" y=\"10\" width=\"14\" height=\"11\" rx=\"2\"/><path d=\"M8 10V6a4 4 0 0 1 8 0v4m-4 5v2\"/>',\n stop: '<rect x=\"5\" y=\"5\" width=\"14\" height=\"14\" rx=\"2\"/>',\n};\n\nconst icon = (name) =>\n `<svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.6\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">${\n icons[name] || icons.runs\n }</svg>`;\nconst escape = (value) =>\n String(value ?? \"\").replace(\n /[&<>\"']/g,\n (character) => ({ \"&\": \"&\", \"<\": \"<\", \">\": \">\", '\"': \""\", \"'\": \"'\" }[character])\n );\nconst labels = {\n success: \"Passed\",\n failed: \"Failed\",\n running: \"Running\",\n pending: \"Queued\",\n cancelled: \"Cancelled\",\n skipped: \"Skipped\",\n};\nconst statusIcon = (status) =>\n `<span class=\"status-icon ${escape(status)}\">${icon(status === \"success\" ? \"check\" : status)}</span>`;\nconst iconForStatus = statusIcon;\nconst badge = (status) =>\n `<span class=\"status ${escape(status)}\">${statusIcon(status)}${escape(labels[status] || status || \"Unknown\")}</span>`;\n\nexport function mountLivePreview() {\n const main = document.querySelector(\"#main\");\n const dialog = document.querySelector(\"#dialog\");\n const state = {\n jobs: [],\n workflows: [],\n workers: [],\n secrets: [],\n hasMore: false,\n loadingJobs: false,\n jobsLoaded: false,\n workflowsLoaded: false,\n workersLoaded: false,\n secretsLoaded: false,\n errors: {},\n search: \"\",\n status: \"all\",\n workflow: \"all\",\n worker: \"all\",\n workflowFilter: \"all\",\n page: 1,\n run: null,\n mutating: false,\n detailTab: \"logs\",\n step: 0,\n logSearch: \"\",\n wrap: false,\n };\n let apiClient;\n let events;\n let previousRoute = \"\";\n let jobsLoadPromise;\n let refreshJobsAfterCurrentLoad = false;\n\n const route = () => {\n const [path, query = \"\"] = window.location.hash.slice(1).split(\"?\");\n return { path: path || \"/runs\", query: new URLSearchParams(query) };\n };\n\n const client = async () => {\n apiClient ??= import(\"/api-client.mjs\");\n return apiClient;\n };\n\n const apiJson = async (path, options = {}) => {\n const { apiFetch } = await client();\n const headers = new Headers(options.headers || {});\n headers.set(\"accept\", \"application/json\");\n if (options.body && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n const response = await apiFetch(path, { ...options, headers });\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n const error = new Error(payload.error || `Request failed (${response.status})`);\n error.status = response.status;\n throw error;\n }\n return response.status === 204 ? null : response.json();\n };\n\n const apiBlob = async (path) => {\n const { apiFetch } = await client();\n const response = await apiFetch(path);\n if (!response.ok) {\n const payload = await response.json().catch(() => ({}));\n throw new Error(payload.error || `Download failed (${response.status})`);\n }\n return response.blob();\n };\n\n const showToast = (message) => {\n const toast = document.querySelector(\"#toast\");\n toast.textContent = message;\n toast.hidden = false;\n window.clearTimeout(showToast.timer);\n showToast.timer = window.setTimeout(() => {\n toast.hidden = true;\n }, 4500);\n };\n\n const dateValue = (value) => {\n if (!value) {\n return null;\n }\n const text = String(value);\n const date = new Date(text.includes(\"T\") ? text : `${text.replace(\" \", \"T\")}Z`);\n return Number.isNaN(date.getTime()) ? null : date;\n };\n\n const formatDate = (value) => {\n const date = dateValue(value);\n return date ? new Intl.DateTimeFormat(undefined, { dateStyle: \"medium\", timeStyle: \"short\" }).format(date) : \"—\";\n };\n\n const formatDuration = (milliseconds) => {\n const value = Number(milliseconds);\n if (!Number.isFinite(value) || value < 0) {\n return \"—\";\n }\n const seconds = Math.floor(value / 1000);\n return `${Math.floor(seconds / 60) ? `${Math.floor(seconds / 60)}m ` : \"\"}${seconds % 60}s`;\n };\n\n const workflowName = (workflowId) =>\n state.workflows.find((workflow) => workflow.id === workflowId)?.name || workflowId || \"Unknown workflow\";\n const pageHeading = (title, subtitle, action = \"\") =>\n `<div class=\"page-heading\"><div><h1>${escape(title)}</h1>${\n subtitle ? `<p class=\"subtitle\">${escape(subtitle)}</p>` : \"\"\n }</div>${action}</div>`;\n const runWorkflowButton = () =>\n `<button class=\"button primary\" data-action=\"new-run\">${icon(\"plus\")}Run workflow</button>`;\n const permissionMessage = (error, scope) =>\n [401, 403].includes(error?.status)\n ? `Your account needs the ${scope} permission to view this data.`\n : error?.message || \"The Flow API could not be reached.\";\n\n const setNavigation = (page, jobId = \"\") => {\n const currentJob = jobId ? state.jobs.find((job) => String(job.id) === String(jobId)) : null;\n const currentJobId = currentJob?.id ?? jobId;\n document.querySelector(\"#navigation\").innerHTML = [\n [\"runs\", \"Runs\", \"runs\"],\n [\"workflows\", \"Workflows\", \"workflow\"],\n [\"workers\", \"Workers\", \"server\"],\n [\"settings\", \"Settings\", \"settings\"],\n ]\n .map(\n ([id, title, name]) =>\n `<a href=\"#/${id}\" class=\"nav-item ${page === id ? \"active\" : \"\"}\" ${\n page === id ? 'aria-current=\"page\"' : \"\"\n }>${icon(name)}<span class=\"nav-label\">${title}</span>${\n id === \"runs\"\n ? `<span class=\"nav-count\">${\n state.jobs.filter((job) => [\"running\", \"pending\"].includes(job.status)).length\n }</span>`\n : \"\"\n }</a>`\n )\n .join(\"\");\n document.querySelector(\"#breadcrumbs\").innerHTML = `${\n jobId\n ? `<a href=\"#/runs\">Runs</a><span class=\"divider\">/</span><strong class=\"mono\">#${escape(\n currentJobId\n )}</strong>`\n : `<strong>${escape(page[0].toUpperCase() + page.slice(1))}</strong>`\n }`;\n document.title = `${jobId ? `Run #${currentJobId}` : page[0].toUpperCase() + page.slice(1)} · Flow`;\n };\n const setBreadcrumbs = (page, jobId = null) => setNavigation(page, jobId);\n\n const loadJobs = async ({ older = false } = {}) => {\n if (jobsLoadPromise) {\n if (!older) {\n refreshJobsAfterCurrentLoad = true;\n }\n await jobsLoadPromise;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n return;\n }\n state.loadingJobs = true;\n const beforeId = older && state.jobs.length ? Math.min(...state.jobs.map((job) => Number(job.id))) : null;\n jobsLoadPromise = (async () => {\n try {\n const query = new URLSearchParams({ limit: older ? \"50\" : \"100\" });\n if (beforeId) {\n query.set(\"beforeId\", String(beforeId));\n }\n if (state.workflow !== \"all\") {\n query.set(\"workflowId\", state.workflow);\n }\n const data = await apiJson(`/api/jobs?${query}`);\n if (older) {\n const merged = new Map(state.jobs.map((job) => [String(job.id), job]));\n for (const job of data.jobs || []) {\n merged.set(String(job.id), job);\n }\n state.jobs = [...merged.values()].sort((a, b) => Number(b.id) - Number(a.id));\n } else {\n state.jobs = data.jobs || [];\n }\n state.hasMore = Boolean(data.hasMore);\n state.errors.jobs = null;\n state.jobsLoaded = true;\n } catch (error) {\n state.errors.jobs = error;\n state.jobsLoaded = true;\n } finally {\n state.loadingJobs = false;\n }\n })();\n try {\n await jobsLoadPromise;\n } finally {\n jobsLoadPromise = undefined;\n if (refreshJobsAfterCurrentLoad) {\n refreshJobsAfterCurrentLoad = false;\n await loadJobs();\n }\n }\n };\n\n const loadWorkflows = async ({ retry = false } = {}) => {\n if (state.workflowsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workflows\");\n state.workflows = data.workflows || [];\n state.errors.workflows = null;\n } catch (error) {\n state.errors.workflows = error;\n } finally {\n state.workflowsLoaded = true;\n }\n };\n\n const loadWorkers = async ({ retry = false } = {}) => {\n if (state.workersLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/workers\");\n state.workers = data.workers || [];\n state.errors.workers = null;\n } catch (error) {\n state.errors.workers = error;\n } finally {\n state.workersLoaded = true;\n }\n };\n\n const loadSecrets = async ({ retry = false } = {}) => {\n if (state.secretsLoaded && !retry) {\n return;\n }\n try {\n const data = await apiJson(\"/api/secrets\");\n state.secrets = data.secrets || [];\n state.errors.secrets = null;\n } catch (error) {\n state.errors.secrets = error;\n } finally {\n state.secretsLoaded = true;\n }\n };\n\n const workflowFormSource = () => {\n const source = document.getElementById(\"new-workflow-source\")?.value.trim() || \"\";\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n if (!source || !name) {\n return source;\n }\n const yamlName = `name: ${JSON.stringify(name)}`;\n return /^name\\s*:/m.test(source) ? source.replace(/^name\\s*:[^\\r\\n]*/m, yamlName) : `${yamlName}\\n${source}`;\n };\n\n const filteredJobs = () =>\n state.jobs.filter((job) => {\n const text = [job.id, job.workflowId, workflowName(job.workflowId), job.status, job.workerId]\n .join(\" \")\n .toLowerCase();\n return (\n text.includes(state.search.trim().toLowerCase()) &&\n (state.status === \"all\" ||\n (state.status === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === state.status)) &&\n (state.workflow === \"all\" || job.workflowId === state.workflow) &&\n (state.worker === \"all\" || (state.worker === \"queued\" ? !job.workerId : job.workerId === state.worker))\n );\n });\n\n const renderRunsTable = () => {\n const filtered = filteredJobs();\n const pages = Math.max(1, Math.ceil(filtered.length / 8));\n state.page = Math.min(state.page, pages);\n const rows = filtered.slice((state.page - 1) * 8, state.page * 8);\n const target = document.querySelector(\"#run-results\");\n if (!target) {\n return;\n }\n target.innerHTML = `<div class=\"filter-tabs\" role=\"group\" aria-label=\"Filter by run status\">${[\n [\"all\", \"All runs\"],\n [\"running\", \"In progress\"],\n [\"failed\", \"Failed\"],\n [\"success\", \"Passed\"],\n ]\n .map(\n ([value, label]) =>\n `<button class=\"filter-tab ${\n state.status === value ? \"active\" : \"\"\n }\" data-action=\"status-filter\" data-value=\"${value}\" aria-pressed=\"${\n state.status === value\n }\">${label}<span class=\"count\">${\n state.jobs.filter(\n (job) =>\n value === \"all\" ||\n (value === \"running\" ? [\"pending\", \"running\"].includes(job.status) : job.status === value)\n ).length\n }</span></button>`\n )\n .join(\"\")}</div>\n ${\n rows.length\n ? `<table class=\"run-table\"><caption class=\"sr-only\">Live Flow runs, newest first</caption><thead><tr><th scope=\"col\">Workflow / run</th><th scope=\"col\">Status</th><th scope=\"col\">Worker</th><th scope=\"col\">Updated</th><th scope=\"col\">Created</th></tr></thead><tbody>${rows\n .map(\n (job) =>\n `<tr><td><a class=\"run-link\" href=\"#/runs/${encodeURIComponent(job.id)}\" aria-label=\"Run ${escape(\n job.id\n )}: ${escape(workflowName(job.workflowId))}, ${escape(\n labels[job.status] || job.status\n )}\">${statusIcon(job.status)}<span class=\"run-copy\"><span class=\"run-title\">${escape(\n workflowName(job.workflowId)\n )}</span><span class=\"run-subtitle\"><span class=\"mono\">#${escape(\n job.id\n )}</span><span class=\"separator\">·</span><span class=\"mono\">${escape(\n job.workflowId\n )}</span></span></span></a></td><td>${badge(job.status)}</td><td><span class=\"branch\">${icon(\n \"server\"\n )}${escape(job.workerId || \"Queued\")}</span></td><td>${escape(\n formatDate(job.updatedAt)\n )}</td><td>${escape(formatDate(job.createdAt))}</td></tr>`\n )\n .join(\"\")}</tbody></table>`\n : `<div class=\"empty-state\">${icon(\"search\")}<h3>${\n state.errors.jobs ? \"Runs unavailable\" : \"No runs found\"\n }</h3><p>${escape(state.errors.jobs?.message || \"Try a different search or filter.\")}</p>${\n state.errors.jobs\n ? '<button class=\"button\" data-action=\"retry-runs\">Retry</button>'\n : '<button class=\"button\" data-action=\"clear-filters\">Clear filters</button>'\n }</div>`\n }\n <div class=\"table-footer\"><div class=\"pagination\"><button class=\"button\" data-action=\"prev-page\" ${\n state.page === 1 ? \"disabled\" : \"\"\n }>${icon(\"back\")}Previous</button><button class=\"button\" data-action=\"next-page\" ${\n state.page === pages && !state.hasMore ? \"disabled\" : \"\"\n }>Next${icon(\"arrow\")}</button></div></div>${\n state.hasMore ? '<div class=\"table-hint\">Older Flow runs are available.</div>' : \"\"\n }`;\n };\n\n const renderRuns = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `${pageHeading(\"Runs\", \"\", runWorkflowButton())}${\n state.loadingJobs && !state.jobs.length ? '<div class=\"empty-state\"><p>Loading runs…</p></div>' : \"\"\n }<section aria-labelledby=\"recent-runs\"><div class=\"section-heading\"><h2 id=\"recent-runs\">Recent runs</h2></div><div class=\"filters\"><label class=\"search-box\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search runs</span><input id=\"run-search\" type=\"search\" value=\"${escape(\n state.search\n )}\" placeholder=\"Search runs, workflows, or workers…\" autocomplete=\"off\"/><kbd aria-hidden=\"true\">/</kbd></label><select id=\"workflow-filter\" class=\"select-filter\" aria-label=\"Filter by workflow\"><option value=\"all\">All workflows</option>${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${state.workflow === workflow.id ? \"selected\" : \"\"}>${escape(\n workflow.name\n )}</option>`\n )\n .join(\n \"\"\n )}</select><select id=\"worker-filter\" class=\"select-filter\" aria-label=\"Filter by worker\"><option value=\"all\">All workers</option><option value=\"queued\" ${\n state.worker === \"queued\" ? \"selected\" : \"\"\n }>Queued</option>${[...new Set(state.jobs.map((job) => job.workerId).filter(Boolean))]\n .map(\n (worker) =>\n `<option value=\"${escape(worker)}\" ${state.worker === worker ? \"selected\" : \"\"}>${escape(worker)}</option>`\n )\n .join(\"\")}</select></div><div id=\"run-results\"></div></section>`;\n renderRunsTable();\n };\n\n const renderRunDetails = (run) => {\n setBreadcrumbs(\"runs\", run.jobId);\n const inputs = run.inputs || {};\n const failedStep = (run.steps || []).find((step) => step.status === \"failed\");\n main.innerHTML = `<a href=\"#/runs\" class=\"back-link\">${icon(\"back\")}All runs</a>\n <div class=\"page-heading run-heading\"><div><h1>${iconForStatus(run.status)}${escape(\n run.workflowName || workflowName(run.workflowId)\n )}</h1><p class=\"subtitle\"><span class=\"mono\">#${escape(run.jobId)}</span><span>·</span><span>${escape(\n inputs.full_name || inputs.repo || run.workflowId || \"Workflow run\"\n )}</span></p></div>\n <div class=\"heading-actions\">${\n run.canViewLogs\n ? `<button class=\"button\" data-action=\"download-logs\">${icon(\"download\")}Download logs</button>`\n : \"\"\n }<button class=\"button ${[\"running\", \"pending\"].includes(run.status) ? \"danger\" : \"primary\"}\" data-action=\"${\n [\"running\", \"pending\"].includes(run.status) ? \"cancel\" : \"rerun\"\n }\" ${state.mutating ? \"disabled\" : \"\"}>${icon([\"running\", \"pending\"].includes(run.status) ? \"stop\" : \"refresh\")}${\n [\"running\", \"pending\"].includes(run.status) ? \"Cancel run\" : \"Re-run workflow\"\n }</button></div></div>\n ${\n failedStep\n ? `<div class=\"failure-banner\">${icon(\"alert\")}<div><strong>${escape(failedStep.name || \"Step\")} failed${\n failedStep.exitCode !== undefined ? ` with exit code ${escape(failedStep.exitCode)}` : \"\"\n }</strong><p>${escape(\n failedStep.error || \"See the failed step output.\"\n )}</p></div><button class=\"text-button\" data-action=\"jump-error\">Jump to error ${icon(\n \"arrow\"\n )}</button></div>`\n : \"\"\n }\n <section class=\"detail-meta\" aria-label=\"Run metadata\"><div><span class=\"meta-label\">STATUS</span><span class=\"meta-value\">${badge(\n run.status\n )}</span></div><div><span class=\"meta-label\">BRANCH</span><span class=\"meta-value\">${icon(\"branch\")}${escape(\n inputs.branch || inputs.ref?.replace(/^refs\\/heads\\//, \"\") || \"—\"\n )}</span></div><div><span class=\"meta-label\">COMMIT</span><span class=\"meta-value mono\">${icon(\"commit\")}${escape(\n (inputs.commit_sha || inputs.commit || \"—\").slice(0, 8)\n )}</span></div><div><span class=\"meta-label\">DURATION</span><span class=\"meta-value\">${icon(\n \"clock\"\n )}${formatDuration(\n run.durationMs\n )}</span></div><div><span class=\"meta-label\">WORKER</span><span class=\"meta-value\">${icon(\"server\")}${escape(\n run.workerId || \"Queued\"\n )}</span></div></section>\n <div class=\"detail-tabs\" role=\"group\" aria-label=\"Run detail views\">${[\n [\"logs\", \"Logs & steps\", \"terminal\"],\n [\"inputs\", \"Inputs\", \"code\"],\n [\"artifacts\", \"Artifacts\", \"box\"],\n [\"history\", \"History\", \"history\"],\n ]\n .map(\n ([value, title, symbol]) =>\n `<button data-action=\"detail-tab\" data-value=\"${value}\" class=\"${\n state.detailTab === value ? \"active\" : \"\"\n }\" aria-pressed=\"${state.detailTab === value}\">${icon(symbol)}${title}</button>`\n )\n .join(\"\")}</div>\n <div id=\"detail-content\"></div>`;\n void renderRunTab(run);\n };\n\n const liveRunPage = async (id) => {\n main.innerHTML = `<div class=\"empty-state\">${icon(\"clock\")}<p>Loading run #${escape(id)}…</p></div>`;\n try {\n const run = await apiJson(`/api/runs/${encodeURIComponent(id)}`);\n state.errors.run = null;\n state.run = run;\n const importantStep = run.steps?.findIndex((step) => [\"failed\", \"running\"].includes(step.status)) ?? -1;\n if (importantStep >= 0) {\n state.step = importantStep;\n }\n renderRunDetails(run);\n } catch (error) {\n state.errors.run = error;\n main.innerHTML = `<div class=\"empty-state\">${icon(\"alert\")}<h1>Run unavailable</h1><p>${escape(\n error.message\n )}</p><a class=\"button\" href=\"#/runs\">Back to runs</a></div>`;\n }\n };\n\n const renderRunTab = async (run) => {\n const content = document.querySelector(\"#detail-content\");\n if (!content) {\n return;\n }\n if (state.detailTab === \"inputs\") {\n let source = run.workflowSourceYaml || \"\";\n if (!source && run.workflowId) {\n try {\n const workflow = await apiJson(\n `/api/workflows/${encodeURIComponent(run.workflowId)}?revision=${encodeURIComponent(\n run.workflowRevision || \"\"\n )}`\n );\n source = workflow.sourceYaml || \"\";\n } catch {\n /* Workflow source has separate permission checks. */\n }\n }\n content.innerHTML = `<section class=\"source-view\"><h3>Trigger inputs</h3><pre>${escape(\n JSON.stringify(run.inputs || {}, null, 2)\n )}</pre></section>${\n source\n ? `<section class=\"source-view\" style=\"margin-top:16px\"><h3>Workflow source · revision ${escape(\n run.workflowRevision || \"—\"\n )}</h3><pre>${escape(source)}</pre></section>`\n : '<p class=\"settings-note\" style=\"margin-top:14px\">Workflow source is unavailable for this run.</p>'\n }`;\n return;\n }\n if (state.detailTab === \"artifacts\") {\n const artifacts = run.artifacts || [];\n content.innerHTML = artifacts.length\n ? `<section class=\"settings-section\"><h2>Run artifacts <span class=\"count\">${\n artifacts.length\n }</span></h2>${artifacts\n .map(\n (path, index) =>\n `<div class=\"setting-row\"><div><strong class=\"mono\">${escape(\n path.split(\"/\").pop() || path\n )}</strong><p>${escape(\n path\n )}</p></div><button class=\"button\" data-action=\"download-artifact\" data-value=\"${index}\">${icon(\n \"download\"\n )}Download</button></div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No artifacts</h3><p>This run did not publish any artifacts.</p></div>';\n return;\n }\n if (state.detailTab === \"history\") {\n const history = await loadRunHistory(run);\n content.innerHTML =\n history.length > 1\n ? `<section class=\"settings-section\"><h2>Execution history</h2>${history\n .map(\n (entry) =>\n `<div class=\"setting-row\"><div><strong><a href=\"#/runs/${encodeURIComponent(\n entry.jobId\n )}\">Run #${escape(entry.jobId)}</a></strong><p>${escape(\n entry.workflowName || entry.workflowId || \"Workflow\"\n )} · ${formatDate(entry.startedAt)}</p></div>${badge(entry.status)}</div>`\n )\n .join(\"\")}</section>`\n : '<div class=\"empty-state\"><h3>No previous attempts</h3><p>This run has no parent run linked to it.</p></div>';\n return;\n }\n const steps = run.steps || [];\n if (!steps.length) {\n content.innerHTML =\n '<div class=\"empty-state\"><h3>No execution steps yet</h3><p>Step details will appear when Flow records them.</p></div>';\n return;\n }\n state.step = Math.max(0, Math.min(state.step, steps.length - 1));\n const selected = steps[state.step];\n const stepButtons = steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\");\n if (!run.canViewLogs) {\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${stepButtons}</aside><div class=\"log-panel\"><div class=\"empty-state\">${icon(\n \"lock\"\n )}<h3>Logs require permission</h3><p>Your account needs <span class=\"mono\">logs:read</span> to view step output.</p></div></div></section>`;\n return;\n }\n content.innerHTML = `<section class=\"execution\" aria-label=\"Execution steps and logs\"><aside class=\"steps-panel\" aria-label=\"Execution steps\"><div class=\"steps-heading\"><span>Execution steps</span><span class=\"mono\">${\n steps.filter((step) => step.status === \"success\").length\n } / ${steps.length}</span></div>${steps\n .map(\n (step, index) =>\n `<button class=\"step-button ${\n state.step === index ? \"active\" : \"\"\n }\" data-action=\"step\" data-value=\"${index}\" aria-pressed=\"${state.step === index}\" aria-label=\"${escape(\n step.name\n )}, ${escape(labels[step.status] || step.status)}\">${iconForStatus(step.status)}<span>${escape(\n step.name\n )}</span><span class=\"mono\">${formatDuration(step.durationMs)}</span></button>`\n )\n .join(\"\")}</aside><div class=\"log-panel\"><div class=\"log-heading\"><h3>${escape(selected.name)}<small>${\n selected.exitCode !== undefined\n ? `exit code ${escape(selected.exitCode)}`\n : escape(labels[selected.status] || selected.status)\n }</small></h3><div class=\"log-actions\"><button class=\"icon-button\" data-action=\"wrap\" aria-label=\"Wrap log lines\" aria-pressed=\"${\n state.wrap\n }\">${icon(\"wrap\")}</button><button class=\"icon-button\" data-action=\"copy-log\" aria-label=\"Copy step log\">${icon(\n \"copy\"\n )}</button></div></div><label class=\"log-search\">${icon(\n \"search\"\n )}<span class=\"sr-only\">Search step log</span><input id=\"log-search\" type=\"search\" placeholder=\"Find in this step…\" value=\"${escape(\n state.logSearch\n )}\"/><span id=\"log-matches\"></span></label><div class=\"log-lines ${\n state.wrap ? \"wrap\" : \"\"\n }\" id=\"log-lines\" tabindex=\"0\" aria-label=\"Step log output\"></div><div class=\"log-bottom\"><span>${\n selected.status === \"failed\"\n ? \"Step failed\"\n : selected.status === \"running\"\n ? \"Step is running\"\n : \"End of step output\"\n }</span><span class=\"mono\">UTF-8</span></div></div></section>`;\n renderLog(selected.logContent || \"\");\n };\n\n const loadRunHistory = async (run) => {\n const entries = [run];\n const visited = new Set([String(run.jobId)]);\n let parent = run.parentId;\n while (parent && !visited.has(String(parent)) && entries.length < 25) {\n visited.add(String(parent));\n try {\n const entry = await apiJson(`/api/runs/${encodeURIComponent(parent)}`);\n entries.push(entry);\n parent = entry.parentId;\n } catch {\n break;\n }\n }\n return entries;\n };\n\n const renderLog = (text) => {\n const log = document.querySelector(\"#log-lines\");\n if (!log) {\n return;\n }\n const query = state.logSearch.toLowerCase();\n let matches = 0;\n log.innerHTML = String(text)\n .split(\"\\n\")\n .map((line, index) => {\n let content = escape(line) || \" \";\n const start = query ? line.toLowerCase().indexOf(query) : -1;\n if (start >= 0) {\n matches++;\n content = `${escape(line.slice(0, start))}<mark>${escape(\n line.slice(start, start + query.length)\n )}</mark>${escape(line.slice(start + query.length))}`;\n }\n const type = /\\b(error|failed|fatal)\\b/i.test(line)\n ? \"error\"\n : /\\b(success|passed|done)\\b/i.test(line)\n ? \"pass\"\n : \"\";\n return `<div class=\"log-line ${type}\"><span class=\"line-number\">${\n index + 1\n }</span><span class=\"line-text\">${content}</span></div>`;\n })\n .join(\"\");\n const result = document.querySelector(\"#log-matches\");\n if (result) {\n result.textContent = query ? `${matches} matching lines` : \"\";\n }\n };\n const renderLiveLog = renderLog;\n\n const downloadBlob = (blob, filename) => {\n const url = URL.createObjectURL(blob);\n const anchor = document.createElement(\"a\");\n anchor.href = url;\n anchor.download = filename;\n anchor.click();\n window.setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n const renderRevisionDiff = (previousSource, currentSource) => {\n const before = previousSource === null ? [] : previousSource.split(\"\\n\");\n const after = String(currentSource ?? \"\").split(\"\\n\");\n let prefixLength = 0;\n while (\n prefixLength < before.length &&\n prefixLength < after.length &&\n before[prefixLength] === after[prefixLength]\n ) {\n prefixLength++;\n }\n let suffixLength = 0;\n while (\n suffixLength < before.length - prefixLength &&\n suffixLength < after.length - prefixLength &&\n before[before.length - suffixLength - 1] === after[after.length - suffixLength - 1]\n ) {\n suffixLength++;\n }\n const renderLines = (lines, className, prefix) =>\n lines\n .filter((line, index) => line || index < lines.length - 1)\n .map((line) => `<span class=\"block ${className}\">${prefix}${escape(line)}</span>`)\n .join(\"\");\n return [\n renderLines(before.slice(0, prefixLength), \"text-gray-300\", \" \"),\n renderLines(before.slice(prefixLength, before.length - suffixLength), \"text-rose-300 bg-rose-500/10\", \"- \"),\n renderLines(after.slice(prefixLength, after.length - suffixLength), \"text-emerald-300 bg-emerald-500/10\", \"+ \"),\n renderLines(before.slice(before.length - suffixLength), \"text-gray-300\", \" \"),\n ].join(\"\");\n };\n\n const loadRevisionDiff = async (workflowId, revision) => {\n try {\n const current = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision}`);\n const previous =\n revision > 1\n ? await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}?revision=${revision - 1}`)\n : null;\n const sourceDiff = renderRevisionDiff(previous?.sourceYaml ?? null, current.sourceYaml);\n openDialog(\n `Workflow ${current.name || current.id}`,\n `<p class=\"dialog-description\">${escape(current.name || current.id)} — Revision ${escape(\n current.revision\n )}</p><div class=\"source-view\"><pre>${sourceDiff}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n } catch (error) {\n showToast(`Failed to load revision diff: ${error.message}`);\n }\n };\n\n const liveWorkflowsPage = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n setBreadcrumbs(\"workflows\");\n const tabs = [\n { id: \"all\", label: \"All\", filter: () => true },\n { id: \"published\", label: \"Published\", filter: (workflow) => workflow.status === \"published\" },\n { id: \"draft\", label: \"Drafts\", filter: (workflow) => workflow.status === \"draft\" },\n { id: \"archived\", label: \"Archived\", filter: (workflow) => workflow.status === \"archived\" },\n ];\n const selectedTab = tabs.find((tab) => tab.id === state.workflowFilter) || tabs[0];\n const visibleWorkflows = state.workflows.filter(selectedTab.filter);\n const tabButtons = tabs\n .map((tab) => {\n const count = state.workflows.filter(tab.filter).length;\n return `<button class=\"status-tab ${\n state.workflowFilter === tab.id ? \"active\" : \"\"\n }\" data-action=\"workflow-status-tab\" data-value=\"${tab.id}\" aria-pressed=\"${state.workflowFilter === tab.id}\">${\n tab.label\n }<span class=\"count\">${count}</span></button>`;\n })\n .join(\"\");\n const workflowRows = visibleWorkflows\n .map((workflow) => {\n const index = state.workflows.indexOf(workflow);\n return `<div class=\"workflow-item\"><div class=\"workflow-header\"><span class=\"workflow-icon\">${icon(\n \"workflow\"\n )}</span><span class=\"workflow-name\">${escape(\n workflow.name || workflow.id\n )}</span><span class=\"workflow-status ${\n workflow.status === \"published\" ? \"success\" : workflow.status === \"archived\" ? \"disabled\" : \"enabled\"\n }\">${escape(workflow.status)}</span></div><div class=\"workflow-meta\"><span>Revision ${escape(\n workflow.revision || \"—\"\n )}</span><span class=\"workflow-id mono\">${escape(\n workflow.id\n )}</span></div><div class=\"workflow-actions\"><a class=\"text-button\" href=\"#/runs?workflow=${encodeURIComponent(\n workflow.id\n )}\">View runs ${icon(\"arrow\")}</a><a class=\"button\" href=\"/settings/workflows/${encodeURIComponent(\n workflow.id\n )}\" target=\"_top\">Edit ${icon(\n \"arrow\"\n )}</a><button class=\"button\" data-action=\"workflow-source\" data-value=\"${index}\">${icon(\n \"code\"\n )}Source</button>${\n workflow.revision > 1\n ? `<button class=\"button small\" data-action=\"workflow-revision\" data-value=\"${index}\" data-dir=\"-1\">Previous revision</button>`\n : \"\"\n }${\n workflow.status !== \"published\"\n ? `<button class=\"button small success\" data-action=\"workflow-publish\" data-value=\"${index}\">Publish ${icon(\n \"rocket\"\n )}</button>`\n : \"\"\n }</div></div>`;\n })\n .join(\"\");\n const content =\n workflowRows ||\n `<div class=\"empty-state\"><h3>No ${escape(selectedTab.label.toLowerCase())} workflows</h3><p>${\n state.workflows.length\n ? \"Choose another status tab or create a workflow.\"\n : \"Flow has no workflow definitions to show.\"\n }</p></div>`;\n main.innerHTML = `${pageHeading(\"Workflows\", \"Workflow definitions\", runWorkflowButton())}${\n state.errors.workflows\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Workflows unavailable</h3><p>${escape(\n permissionMessage(state.errors.workflows, \"workflows:read\")\n )}</p><button class=\"button\" data-action=\"retry-workflows\">Retry</button></div>`\n : `<div class=\"status-tabs\" role=\"group\" aria-label=\"Filter workflows by status\">${tabButtons}</div><div class=\"workflow-list\">${content}</div>`\n }`;\n };\n\n const liveWorkersPage = async () => {\n if (!state.workersLoaded) {\n await loadWorkers();\n }\n setBreadcrumbs(\"workers\");\n main.innerHTML = `${pageHeading(\"Workers\", \"Live runner presence from Flow.\")}${\n state.errors.workers\n ? `<div class=\"empty-state\">${icon(\"lock\")}<h3>Worker status unavailable</h3><p>${escape(\n permissionMessage(state.errors.workers, \"workers:read\")\n )}</p><button class=\"button\" data-action=\"retry-workers\">Retry</button></div>`\n : `<div class=\"cards\">${\n state.workers\n .map(\n (worker) =>\n `<article class=\"worker-card\"><div class=\"card-heading\">${icon(\"server\")}<h2>${escape(\n worker.workerId || \"Unknown worker\"\n )}</h2><span class=\"status ${worker.online ? \"success\" : \"cancelled\"}\">${\n worker.online ? \"Online\" : \"Offline\"\n }</span></div><div class=\"card-details\">${\n (Array.isArray(worker.tags) ? worker.tags : [])\n .map((tag) => `<span class=\"tag mono\">${escape(tag)}</span>`)\n .join(\"\") || '<span class=\"settings-note\">No labels</span>'\n }</div><div class=\"worker-stats\"><div><span class=\"meta-label\">Active jobs</span><strong>${escape(\n worker.activeJobs ?? 0\n )}</strong></div><div><span class=\"meta-label\">Concurrency</span><strong>${escape(\n worker.concurrency ?? \"—\"\n )}</strong></div></div><div class=\"card-footer\"><span class=\"settings-note\">${\n worker.version ? `Runner ${escape(worker.version)}` : \"Runner version unavailable\"\n }</span><span class=\"settings-note\">Seen ${escape(\n formatDate(worker.lastSeen)\n )}</span></div></article>`\n )\n .join(\"\") ||\n '<div class=\"empty-state\"><h3>No workers reported</h3><p>Flow has no worker-presence records.</p></div>'\n }</div>`\n }`;\n };\n\n const liveSettingsPage = async () => {\n if (!state.secretsLoaded) {\n await loadSecrets();\n }\n setBreadcrumbs(\"settings\");\n main.innerHTML = `${pageHeading(\n \"Settings\",\n \"Preferences and secret names from Flow.\"\n )}<section class=\"settings-section\"><h2>Preferences</h2><label class=\"setting-row\"><span><strong>Compact run list</strong><span class=\"settings-note\">Fit more activity on your screen.</span></span><input type=\"checkbox\" id=\"compact-setting\" ${\n localStorage.getItem(\"flow-concept-compact\") === \"true\" ? \"checked\" : \"\"\n }/></label><label class=\"setting-row\"><span><strong>Wrap log lines</strong><span class=\"settings-note\">Keep long output within the log viewer.</span></span><input type=\"checkbox\" id=\"wrap-setting\" ${\n state.wrap ? \"checked\" : \"\"\n }/></label></section><section class=\"settings-section\"><h2>${icon(\n \"lock\"\n )} Secret names</h2><p class=\"settings-note\">Values are never requested or displayed in this preview.</p>${\n state.errors.secrets\n ? `<p class=\"settings-note\">${escape(\n permissionMessage(state.errors.secrets, \"secrets:read\")\n )}</p><button class=\"button\" data-action=\"retry-secrets\">Retry</button>`\n : state.secrets\n .map(\n (name) =>\n `<div class=\"setting-row\"><strong class=\"mono\">${escape(\n name\n )}</strong><span class=\"secret-value\" aria-label=\"Secret value hidden\">••••••••••••</span><button class=\"button tiny danger\" data-action=\"remove-secret\" data-value=\"${name}\">Remove</button></div>`\n )\n .join(\"\") || '<p class=\"settings-note\">No secret names are configured.</p>'\n }${\n !state.errors.secrets && state.secrets.length < 5\n ? '<div class=\"setting-row\"><button class=\"button tiny\" data-action=\"add-secret\">Add secret</button></div>'\n : \"\"\n }</section><section class=\"settings-section\"><h2>Workflow editing</h2><p class=\"settings-note\">Create and edit workflow definitions.</p><form id=\"new-workflow-form\"><label class=\"form-field\"><span>Workflow name</span><input id=\"new-workflow-name\" type=\"text\" placeholder=\"e.g. build-docker-image\" required/></label><label class=\"form-field\"><span>Workflow ID</span><input id=\"new-workflow-id\" type=\"text\" placeholder=\"derived-from-name\" required/></label><label class=\"form-field\"><span>Enabled</span><input type=\"checkbox\" id=\"new-workflow-enabled\" checked/></label><div class=\"form-field\"><span>YAML source</span><textarea id=\"new-workflow-source\" rows=\"8\" placeholder=\"name: build-docker-image\\nruns-on: [main-server, docker]\\n\\nsteps:\\n - name: Checkout repository\\n run: git checkout '$COMMIT'\\n - name: Install dependencies\\n run: pnpm install --frozen-lockfile\\n - name: Run tests\\n run: pnpm test\\n - name: Build and push\\n run: pnpm publish\"></textarea></label></div><div class=\"form-footer\"><button type=\"button\" class=\"button\" data-action=\"validate-workflow\">Validate workflow</button><button type=\"button\" class=\"button\" data-action=\"run-now\">Run now</button><button type=\"submit\" class=\"button primary\">${icon(\n \"plus\"\n )}Create workflow</button><button type=\"button\" class=\"button\" data-action=\"close-dialog\">Cancel</button></div></form><a class=\"button secondary\" href=\"/settings/workflows\" target=\"_top\">Open full workflow settings ${icon(\n \"arrow\"\n )}</a></section>`;\n };\n\n const showNotFound = () => {\n setBreadcrumbs(\"runs\");\n main.innerHTML = `<div class=\"empty-state\"><h1>404 — Page not found</h1><p>This preview route does not exist.</p><a class=\"button\" href=\"#/runs\">Go to runs</a></div>`;\n };\n\n const openDialog = (title, content) => {\n dialog.innerHTML = `<div class=\"dialog-heading\"><h2 id=\"dialog-title\">${escape(\n title\n )}</h2><button class=\"icon-button\" data-action=\"close-dialog\" aria-label=\"Close dialog\">${icon(\n \"close\"\n )}</button></div>${content}`;\n if (!dialog.open) {\n dialog.showModal();\n }\n };\n\n const openDispatchDialog = async () => {\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n if (state.errors.workflows) {\n showToast(permissionMessage(state.errors.workflows, \"workflows:read\"));\n return;\n }\n openDialog(\n \"Run a workflow\",\n `<p class=\"dialog-description\">This will enqueue a real Flow job.</p><label class=\"form-field\"><span>Workflow</span><select id=\"dispatch-workflow\">${state.workflows\n .map(\n (workflow) =>\n `<option value=\"${escape(workflow.id)}\" ${workflow.enabled === false ? \"disabled\" : \"\"}>${escape(\n workflow.name || workflow.id\n )}${workflow.enabled === false ? \" (disabled)\" : \"\"}</option>`\n )\n .join(\n \"\"\n )}</select></label><label class=\"form-field\"><span>Inputs (JSON object)</span><textarea id=\"dispatch-inputs\" rows=\"6\">{}</textarea></label><p class=\"dialog-note\">Running a workflow requires write permission and creates real job(s).</p><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Cancel</button><button class=\"button primary\" data-action=\"dispatch-confirm\">${icon(\n \"runs\"\n )}Review and run</button></div>`\n );\n };\n\n const dispatchWorkflow = async () => {\n if (state.mutating) {\n return;\n }\n const workflowId = document.querySelector(\"#dispatch-workflow\")?.value;\n if (!workflowId) {\n showToast(\"Select a workflow first.\");\n return;\n }\n let inputs;\n try {\n inputs = JSON.parse(document.querySelector(\"#dispatch-inputs\")?.value || \"{}\");\n if (!inputs || typeof inputs !== \"object\" || Array.isArray(inputs)) {\n throw new Error(\"Inputs must be a JSON object.\");\n }\n } catch (error) {\n showToast(error.message);\n return;\n }\n const workflow = state.workflows.find((item) => item.id === workflowId);\n if (workflow?.enabled === false) {\n showToast(\"This workflow is disabled and cannot be run.\");\n return;\n }\n state.mutating = true;\n const dispatchButton = document.querySelector('[data-action=\"dispatch-confirm\"]');\n if (dispatchButton) {\n dispatchButton.disabled = true;\n }\n if (!window.confirm(`Run ${workflow?.name || workflowId} now? This creates real job(s).`)) {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n return;\n }\n try {\n const result = await apiJson(`/api/workflows/${encodeURIComponent(workflowId)}/run`, {\n method: \"POST\",\n body: JSON.stringify({ inputs }),\n });\n dialog.close();\n state.jobs = [];\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Queued ${result.jobs?.length || 0} real job(s).`);\n if (result.jobs?.length) {\n window.location.hash = `/runs/${result.jobs[0]}`;\n }\n } catch (error) {\n showToast(`Could not run workflow: ${error.message}`);\n } finally {\n state.mutating = false;\n if (dispatchButton) {\n dispatchButton.disabled = false;\n }\n }\n };\n\n const render = async () => {\n const { path, query } = route();\n const [section, id] = path.split(\"/\").slice(1);\n const page = section || \"runs\";\n const changed = previousRoute !== window.location.hash;\n if (changed) {\n state.step = 0;\n state.detailTab = \"logs\";\n state.logSearch = \"\";\n if (section !== \"runs\" || !id) {\n state.run = null;\n }\n }\n previousRoute = window.location.hash;\n setNavigation(page === \"runs\" ? \"runs\" : [\"workflows\", \"workers\", \"settings\"].includes(page) ? page : \"runs\", id);\n if (path === \"/runs\" || path === \"\") {\n const workflowFilter = query.get(\"workflow\") || \"all\";\n const searchFilter = query.get(\"search\") || \"\";\n if (workflowFilter !== state.workflow || searchFilter !== state.search) {\n state.jobs = [];\n state.jobsLoaded = false;\n state.hasMore = false;\n }\n state.workflow = workflowFilter;\n state.search = searchFilter;\n const statusFilter = query.get(\"status\");\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(statusFilter)) {\n state.status = statusFilter;\n }\n if (!state.jobsLoaded) {\n await loadJobs();\n }\n if (!state.workflowsLoaded) {\n await loadWorkflows();\n }\n renderRuns();\n if (changed) {\n window.scrollTo(0, 0);\n }\n return;\n }\n if (section === \"runs\" && id) {\n await liveRunPage(id);\n return;\n }\n if (path === \"/workflows\") {\n await liveWorkflowsPage();\n return;\n }\n if (path === \"/workers\") {\n await liveWorkersPage();\n return;\n }\n if (path === \"/settings\") {\n await liveSettingsPage();\n return;\n }\n showNotFound();\n };\n\n const action = async (event) => {\n const button = event.target.closest(\"[data-action]\");\n if (!button) {\n return false;\n }\n const { action: name, value } = button.dataset;\n if (name === \"new-run\") {\n await openDispatchDialog();\n return true;\n }\n if (name === \"close-dialog\") {\n dialog.close();\n return true;\n }\n if (name === \"dispatch-confirm\") {\n await dispatchWorkflow();\n return true;\n }\n if (name === \"retry-runs\") {\n state.jobsLoaded = false;\n state.errors.jobs = null;\n await loadJobs();\n renderRuns();\n return true;\n }\n if (name === \"retry-workflows\") {\n state.workflowsLoaded = false;\n state.errors.workflows = null;\n await liveWorkflowsPage();\n return true;\n }\n if (name === \"workflow-status-tab\") {\n state.workflowFilter = value;\n render();\n return true;\n }\n if (name === \"retry-workers\") {\n state.workersLoaded = false;\n state.errors.workers = null;\n await liveWorkersPage();\n return true;\n }\n if (name === \"retry-secrets\") {\n state.secretsLoaded = false;\n state.errors.secrets = null;\n await liveSettingsPage();\n return true;\n }\n if (name === \"add-secret\") {\n const name = window.prompt(\"Enter secret name (uppercase letters, numbers, underscores):\");\n if (!name) {\n return true;\n }\n if (!/^[A-Z][A-Z0-9_]*$/.test(name)) {\n showToast(\"Secret name must start with a letter and contain only letters, numbers, and underscores.\");\n return true;\n }\n const secretValue = window.prompt(`Enter the value for ${name}:`);\n if (secretValue === null || secretValue.length === 0) {\n showToast(\"Secret value cannot be empty.\");\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, {\n method: \"PUT\",\n body: JSON.stringify({ value: secretValue, encoding: \"utf8\" }),\n });\n showToast(`Secret ${name} added.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to add secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"remove-secret\") {\n const name = value;\n if (!window.confirm(`Delete secret ${name}?`)) {\n return true;\n }\n try {\n await apiJson(`/api/secrets/${name}`, { method: \"DELETE\" });\n showToast(`Secret ${name} removed.`);\n state.secretsLoaded = false;\n await loadSecrets();\n await liveSettingsPage();\n } catch (error) {\n showToast(`Failed to remove secret: ${error.message}`);\n }\n return true;\n }\n if (name === \"status-filter\") {\n state.status = value;\n state.page = 1;\n renderRunsTable();\n button.focus({ preventScroll: true });\n return true;\n }\n if (name === \"clear-filters\") {\n state.search = \"\";\n state.status = \"all\";\n state.workflow = \"all\";\n state.worker = \"all\";\n state.page = 1;\n renderRuns();\n document.querySelector(\"#run-search\")?.focus();\n return true;\n }\n if (name === \"prev-page\" || name === \"next-page\") {\n const pages = Math.max(1, Math.ceil(filteredJobs().length / 8));\n if (name === \"next-page\" && state.page === pages && state.hasMore) {\n await loadJobs({ older: true });\n }\n state.page = Math.max(1, state.page + (name === \"next-page\" ? 1 : -1));\n renderRunsTable();\n return true;\n }\n if (name === \"detail-tab\" && state.run) {\n state.detailTab = value;\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"step\" && state.run) {\n state.step = Number(value);\n await renderRunTab(state.run);\n return true;\n }\n if (name === \"jump-error\" && state.run) {\n const failed = state.run.steps?.findIndex((step) => step.status === \"failed\") ?? -1;\n if (failed >= 0) {\n state.step = failed;\n await renderRunTab(state.run);\n }\n return true;\n }\n if (name === \"wrap\") {\n state.wrap = !state.wrap;\n document.querySelector(\"#log-lines\")?.classList.toggle(\"wrap\", state.wrap);\n button.setAttribute(\"aria-pressed\", String(state.wrap));\n return true;\n }\n if (name === \"copy-log\" && state.run) {\n try {\n await navigator.clipboard.writeText(state.run.steps?.[state.step]?.logContent || \"\");\n showToast(\"Step log copied.\");\n } catch {\n showToast(\"Clipboard unavailable.\");\n }\n return true;\n }\n if (name === \"download-logs\" && state.run) {\n const text = (state.run.steps || []).map((step) => `=== ${step.name} ===\\n${step.logContent || \"\"}`).join(\"\\n\\n\");\n downloadBlob(new Blob([text], { type: \"text/plain;charset=utf-8\" }), `flow-run-${state.run.jobId}.log`);\n return true;\n }\n if (name === \"download-artifact\" && state.run) {\n const artifact = state.run.artifacts?.[Number(value)];\n if (!artifact) {\n return true;\n }\n const encoded = String(artifact).split(\"/\").map(encodeURIComponent).join(\"/\");\n try {\n const blob = await apiBlob(`/api/runs/${encodeURIComponent(state.run.jobId)}/artifacts/${encoded}`);\n downloadBlob(blob, String(artifact).split(\"/\").pop() || \"artifact\");\n } catch (error) {\n showToast(`Download failed: ${error.message}`);\n }\n return true;\n }\n if (name === \"workflow-source\") {\n const workflow = state.workflows[Number(value)];\n if (workflow) {\n openDialog(\n workflow.name || workflow.id,\n `<p class=\"dialog-description\">Revision ${escape(workflow.revision || \"—\")} · ${escape(\n workflow.status || \"\"\n )}</p><div class=\"source-view\"><pre>${escape(\n workflow.sourceYaml || \"Workflow source unavailable.\"\n )}</pre></div><div class=\"dialog-footer\"><button class=\"button\" data-action=\"close-dialog\">Close</button></div>`\n );\n }\n return true;\n }\n if (name === \"validate-workflow\") {\n const source = workflowFormSource();\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n apiJson(`/api/workflows/validate`, {\n method: \"POST\",\n body: JSON.stringify({ sourceYaml: source }),\n })\n .then((result) => {\n const status = result.valid ? \"passed\" : \"failed\";\n showToast(`Validation ${status}: ${result.error || \"\"}`);\n })\n .catch((error) => {\n showToast(`Validation failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"run-now\") {\n const id = document.getElementById(\"new-workflow-id\")?.value.trim();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const source = workflowFormSource();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n if (!id || !name) {\n showToast(\"Please enter a workflow name and ID first.\");\n return true;\n }\n if (!source) {\n showToast(\"Please enter workflow source YAML first.\");\n return true;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return true;\n }\n if (!enabled) {\n showToast(\"Enable the workflow before running it.\");\n return true;\n }\n void (async () => {\n try {\n await apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n });\n const result = await apiJson(`/api/workflows/${encodeURIComponent(id)}/run`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n showToast(\n `Queued ${result.jobs?.length || 0} job${result.jobs?.length === 1 ? \"\" : \"s\"} from revision ${\n result.revision\n }.`\n );\n } catch (error) {\n showToast(`Failed to run workflow: ${error.message}`);\n }\n })();\n return true;\n }\n if (name === \"workflow-revision\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n const dir = Number(button.dataset.dir);\n const targetRev = (workflow.revision || 1) + dir;\n if (targetRev < 1) {\n return true;\n }\n loadRevisionDiff(workflow.id, targetRev);\n return true;\n }\n if (name === \"workflow-publish\") {\n const workflow = state.workflows[Number(value)];\n if (!workflow) {\n return true;\n }\n if (!window.confirm(`Publish workflow ${workflow.name || workflow.id}? This will make it available for runs.`)) {\n return true;\n }\n apiJson(`/api/workflows/${workflow.id}/publish`, { method: \"POST\" })\n .then((result) => {\n showToast(`Published ${result.id || workflow.id} revision ${result.revision}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Publish failed: ${error.message}`);\n });\n return true;\n }\n if (name === \"rerun\" && state.run) {\n if (state.mutating) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n if (\n !window.confirm(\n `Re-run ${\n state.run.workflowName || state.run.workflowId || \"this workflow\"\n }? This creates a new job from the active workflow revision.`\n )\n ) {\n state.mutating = false;\n button.disabled = false;\n return true;\n }\n try {\n const result = await apiJson(`/restart/${encodeURIComponent(state.run.jobId)}`, {\n method: \"POST\",\n body: JSON.stringify({}),\n });\n state.jobsLoaded = false;\n await loadJobs();\n showToast(`Re-run queued: #${result.id}`);\n window.location.hash = `/runs/${result.id}`;\n } catch (error) {\n showToast(`Re-run failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n if (name === \"cancel\" && state.run) {\n if (state.mutating) {\n return true;\n }\n if (\n ![\"pending\", \"running\"].includes(state.run.status) ||\n !window.confirm(`Cancel live run #${state.run.jobId}?`)\n ) {\n return true;\n }\n state.mutating = true;\n button.disabled = true;\n try {\n await apiJson(`/api/jobs/${encodeURIComponent(state.run.jobId)}/cancel`, { method: \"POST\" });\n showToast(`Run #${state.run.jobId} cancelled.`);\n await liveRunPage(state.run.jobId);\n state.jobsLoaded = false;\n await loadJobs();\n } catch (error) {\n showToast(`Cancel failed: ${error.message}`);\n } finally {\n state.mutating = false;\n button.disabled = false;\n }\n return true;\n }\n return false;\n };\n\n const setupEvents = () => {\n if (events) {\n return;\n }\n events = new EventSource(\"/api/events\");\n events.addEventListener(\"jobs.available\", () => {\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n });\n events.addEventListener(\"jobs.changed\", (event) => {\n let data = {};\n try {\n data = JSON.parse(event.data || \"{}\");\n } catch {\n /* Refresh the list even if an event payload is malformed. */\n }\n state.jobsLoaded = false;\n void loadJobs().then(() => {\n if (route().path === \"/runs\") {\n renderRuns();\n }\n });\n if (state.run && (!data.jobId || String(data.jobId) === String(state.run.jobId))) {\n void liveRunPage(state.run.jobId);\n }\n });\n events.onerror = () => {\n /* EventSource retries transient disconnects automatically. */\n };\n };\n\n document.addEventListener(\"click\", async (event) => {\n const handled = await action(event);\n if (handled) {\n event.preventDefault();\n }\n });\n document.addEventListener(\"submit\", (event) => {\n if (event.target?.id === \"new-workflow-form\") {\n event.preventDefault();\n const name = document.getElementById(\"new-workflow-name\")?.value.trim();\n const id = document.getElementById(\"new-workflow-id\")?.value.trim().toLowerCase();\n const enabled = document.getElementById(\"new-workflow-enabled\")?.checked;\n const source = workflowFormSource();\n if (!name || !id || !source) {\n showToast(\"Please fill in all fields (name, id, and source).\");\n return;\n }\n if (!/^[a-z0-9-]+$/.test(id)) {\n showToast(\"Workflow ID can only contain lowercase letters, numbers, and hyphens.\");\n return;\n }\n apiJson(`/api/workflows/${encodeURIComponent(id)}`, {\n method: \"PUT\",\n body: JSON.stringify({ sourceYaml: source, enabled }),\n })\n .then(() => {\n showToast(`Created workflow ${id}.`);\n state.workflowsLoaded = false;\n void loadWorkflows();\n render();\n })\n .catch((error) => {\n showToast(`Failed to create workflow: ${error.message}`);\n });\n }\n });\n document.addEventListener(\"input\", (event) => {\n if (event.target.id === \"run-search\") {\n state.search = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"log-search\") {\n state.logSearch = event.target.value;\n if (state.run) {\n renderLiveLog(state.run.steps?.[state.step]?.logContent || \"\");\n }\n }\n });\n document.addEventListener(\"change\", (event) => {\n if (event.target.id === \"workflow-filter\") {\n state.workflow = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"worker-filter\") {\n state.worker = event.target.value;\n state.page = 1;\n renderRunsTable();\n }\n if (event.target.id === \"compact-setting\") {\n document.body.classList.toggle(\"compact\", event.target.checked);\n localStorage.setItem(\"flow-concept-compact\", String(event.target.checked));\n }\n if (event.target.id === \"wrap-setting\") {\n state.wrap = event.target.checked;\n localStorage.setItem(\"flow-concept-wrap\", String(event.target.checked));\n }\n });\n window.addEventListener(\"hashchange\", () => {\n state.run = null;\n void render();\n });\n window.addEventListener(\"beforeunload\", () => {\n events?.close();\n });\n document.querySelectorAll(\"[data-icon]\").forEach((node) => {\n node.innerHTML = icon(node.dataset.icon);\n });\n document.body.classList.toggle(\"compact\", localStorage.getItem(\"flow-concept-compact\") === \"true\");\n state.wrap = localStorage.getItem(\"flow-concept-wrap\") === \"true\";\n const initialQuery = route().query;\n state.workflow = initialQuery.get(\"workflow\") || \"all\";\n state.search = initialQuery.get(\"search\") || \"\";\n if ([\"all\", \"running\", \"failed\", \"success\"].includes(initialQuery.get(\"status\"))) {\n state.status = initialQuery.get(\"status\");\n }\n main.innerHTML = `${pageHeading(\"Runs\", \"Runs\")}<div class=\"empty-state\">${icon(\"clock\")}<p>Loading runs…</p></div>`;\n setupEvents();\n void Promise.all([loadWorkflows(), loadJobs()]).then(() => {\n state.jobsLoaded = true;\n void render();\n });\n}\n", gg = 50, _g = 500, vg = class e {
|
|
12315
12375
|
server;
|
|
12316
12376
|
preprocessors = /* @__PURE__ */ new Map();
|
|
12317
12377
|
workflows = new d();
|
|
@@ -12464,6 +12524,32 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
|
|
|
12464
12524
|
} else if (!this.requireAdmin(e, t)) return;
|
|
12465
12525
|
return this.renderAppShell(t);
|
|
12466
12526
|
}
|
|
12527
|
+
if (e.method === "GET" && n.pathname === "/api/users") return this.requireAdminSession(e, t) ? (await this.workflowsLoaded, t.writeHead(200, {
|
|
12528
|
+
"Cache-Control": "no-store",
|
|
12529
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
12530
|
+
}), t.end(JSON.stringify({ users: await this.oidcUsers.list() }))) : void 0;
|
|
12531
|
+
let a = n.pathname.match(/^\/api\/users\/([^/]+)\/role$/);
|
|
12532
|
+
if (e.method === "PUT" && a) {
|
|
12533
|
+
if (!this.requireAdminSession(e, t)) return;
|
|
12534
|
+
let n = await this.readJson(e, t);
|
|
12535
|
+
if (!n || t.headersSent) return;
|
|
12536
|
+
if (n.role !== "user" && n.role !== "admin") return t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "role must be either \"user\" or \"admin\"" }));
|
|
12537
|
+
let r;
|
|
12538
|
+
try {
|
|
12539
|
+
r = decodeURIComponent(a[1]);
|
|
12540
|
+
} catch {
|
|
12541
|
+
return t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "Invalid user subject" }));
|
|
12542
|
+
}
|
|
12543
|
+
let i = await this.oidcUsers.setRole(r, n.role);
|
|
12544
|
+
if (i === "not-found") return t.writeHead(404, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "User not found" }));
|
|
12545
|
+
if (i === "last-admin") return t.writeHead(409, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "Cannot demote the last administrator" }));
|
|
12546
|
+
this.oidc?.setRoleForUser(r, n.role);
|
|
12547
|
+
let o = (await this.oidcUsers.list()).find((e) => e.id === r);
|
|
12548
|
+
return t.writeHead(200, {
|
|
12549
|
+
"Cache-Control": "no-store",
|
|
12550
|
+
"Content-Type": "application/json; charset=utf-8"
|
|
12551
|
+
}), t.end(JSON.stringify({ user: o }));
|
|
12552
|
+
}
|
|
12467
12553
|
if (n.pathname === "/api/api-keys") {
|
|
12468
12554
|
if (!this.isAdmin(e) && !this.oidc?.userFromCookie(e.headers.cookie) && !this.oidcBearer(e)) return this.requireAdmin(e, t);
|
|
12469
12555
|
if (e.method === "GET") {
|
|
@@ -12484,57 +12570,57 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
|
|
|
12484
12570
|
}
|
|
12485
12571
|
}
|
|
12486
12572
|
}
|
|
12487
|
-
let
|
|
12488
|
-
if (
|
|
12573
|
+
let o = n.pathname.match(/^\/api\/api-keys\/([^/]+)$/);
|
|
12574
|
+
if (o && e.method === "DELETE") {
|
|
12489
12575
|
if (!this.isAdmin(e) && !this.oidc?.userFromCookie(e.headers.cookie) && !this.oidcBearer(e)) return this.requireAdmin(e, t);
|
|
12490
12576
|
if (this.oidc?.userFromCookie(e.headers.cookie) || this.oidcBearer(e)) {
|
|
12491
|
-
let n = await this.revokeOidcToken(e,
|
|
12577
|
+
let n = await this.revokeOidcToken(e, o[1]);
|
|
12492
12578
|
if (n) return t.writeHead(n.status).end();
|
|
12493
12579
|
}
|
|
12494
|
-
let n = await this.apiKeys.revoke(
|
|
12580
|
+
let n = await this.apiKeys.revoke(o[1]);
|
|
12495
12581
|
t.writeHead(n ? 204 : 404).end();
|
|
12496
12582
|
return;
|
|
12497
12583
|
}
|
|
12498
|
-
let
|
|
12499
|
-
if (e.method === "GET" &&
|
|
12584
|
+
let s = n.pathname.match(/^\/settings\/workflows\/(new|[a-z0-9-]+)$/);
|
|
12585
|
+
if (e.method === "GET" && s) {
|
|
12500
12586
|
if (!this.requireAuthenticatedUser(e, t)) return;
|
|
12501
|
-
|
|
12587
|
+
s[1] === "new" || s[1];
|
|
12502
12588
|
let r = n.searchParams.get("revision");
|
|
12503
12589
|
return r === null || Number(r), this.renderAppShell(t);
|
|
12504
12590
|
}
|
|
12505
|
-
let
|
|
12506
|
-
if (e.method === "GET" &&
|
|
12591
|
+
let c = n.pathname.match(/^\/workflows\/(new|[a-z0-9-]+)$/);
|
|
12592
|
+
if (e.method === "GET" && c) {
|
|
12507
12593
|
if (!this.requireAuthenticatedUser(e, t)) return;
|
|
12508
|
-
let r =
|
|
12594
|
+
let r = c[1] === "new" ? "" : c[1], i = r ? `/settings/workflows/${r}` : "/settings/workflows/new";
|
|
12509
12595
|
return t.writeHead(302, { Location: `${i}${n.search}` }), t.end();
|
|
12510
12596
|
}
|
|
12511
12597
|
if (e.method === "GET" && n.pathname === "/api/jobs") {
|
|
12512
12598
|
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 ? gg : Number(i);
|
|
12513
12599
|
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 > _g ? (t.writeHead(400, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: `limit must be an integer from 1 to ${_g}` }))) : 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);
|
|
12514
12600
|
}
|
|
12515
|
-
let
|
|
12516
|
-
if (e.method === "POST" &&
|
|
12517
|
-
let
|
|
12518
|
-
if (e.method === "POST" &&
|
|
12519
|
-
let
|
|
12520
|
-
if (e.method === "GET" &&
|
|
12601
|
+
let l = n.pathname.match(/^\/api\/dispatch\/([a-z0-9-]+)$/);
|
|
12602
|
+
if (e.method === "POST" && l) return this.handleDispatch(e, t, l[1]);
|
|
12603
|
+
let u = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)\/run$/);
|
|
12604
|
+
if (e.method === "POST" && u) return this.handleWorkflowRun(e, t, u[1]);
|
|
12605
|
+
let d = n.pathname.match(/^\/api\/jobs\/(\d+)\/wait$/);
|
|
12606
|
+
if (e.method === "GET" && d) return this.handleJobWait(d[1], n.searchParams.get("timeout"), t);
|
|
12521
12607
|
if (e.method === "GET" && n.pathname === "/api/events") return this.events.subscribe(e, t);
|
|
12522
12608
|
if (e.method === "GET" && n.pathname === "/api/push/public-key") return this.handlePushPublicKey(t);
|
|
12523
12609
|
if (e.method === "POST" && n.pathname === "/api/push/subscriptions") return this.handlePushSubscribe(e, t);
|
|
12524
12610
|
if (e.method === "DELETE" && n.pathname === "/api/push/subscriptions") return this.handlePushUnsubscribe(e, t);
|
|
12525
12611
|
if (n.pathname === "/api/workflows/validate" && e.method === "POST") return this.handleWorkflowValidation(e, t);
|
|
12526
12612
|
if (n.pathname === "/api/secrets" && e.method === "GET") return this.handleSecretList(e, t);
|
|
12527
|
-
let
|
|
12528
|
-
if (
|
|
12529
|
-
if (
|
|
12530
|
-
let
|
|
12531
|
-
if (
|
|
12532
|
-
let
|
|
12533
|
-
if (
|
|
12613
|
+
let f = n.pathname.match(/^\/api\/secrets\/([A-Z][A-Z0-9_]*)$/);
|
|
12614
|
+
if (f && e.method === "PUT") return this.handleSecretSave(e, t, f[1]);
|
|
12615
|
+
if (f && e.method === "DELETE") return this.handleSecretDelete(e, t, f[1]);
|
|
12616
|
+
let p = n.pathname.match(/^\/api\/jobs\/(\d+)\/secrets$/);
|
|
12617
|
+
if (p && e.method === "GET") return this.handleJobSecrets(e, t, p[1]);
|
|
12618
|
+
let m = n.pathname.match(/^\/api\/jobs\/(\d+)\/cancel$/);
|
|
12619
|
+
if (m && e.method === "POST") return this.handleCancelJob(e, t, m[1]);
|
|
12534
12620
|
if (n.pathname === "/api/workflows" && e.method === "GET") return this.handleWorkflowList(e, t);
|
|
12535
|
-
let
|
|
12536
|
-
if (
|
|
12537
|
-
let [, r, i] =
|
|
12621
|
+
let g = n.pathname.match(/^\/api\/workflows\/([a-z0-9-]+)(\/publish)?$/);
|
|
12622
|
+
if (g) {
|
|
12623
|
+
let [, r, i] = g;
|
|
12538
12624
|
if (e.method === "GET" && !i) return this.handleWorkflowGet(e, t, r, n.searchParams.get("revision"));
|
|
12539
12625
|
if (e.method === "PUT" && !i) return this.handleWorkflowSave(e, t, r);
|
|
12540
12626
|
if (e.method === "DELETE" && !i) return this.handleWorkflowDelete(e, t, r);
|
|
@@ -12546,8 +12632,8 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
|
|
|
12546
12632
|
let r = n.pathname.replace("/runs/", "");
|
|
12547
12633
|
return this.renderRunDetails(r, t, "html", await this.hasScope(e, "logs:read"));
|
|
12548
12634
|
}
|
|
12549
|
-
let
|
|
12550
|
-
if (e.method === "POST" &&
|
|
12635
|
+
let v = n.pathname.match(/^\/api\/runs\/(\d+)\/ai-help$/);
|
|
12636
|
+
if (e.method === "POST" && v) return this.handleAiHelp(e, t, v[1]);
|
|
12551
12637
|
if (e.method === "GET" && n.pathname.startsWith("/api/runs/")) {
|
|
12552
12638
|
let r = n.pathname.match(/^\/api\/runs\/(\d+)\/diagnostics$/);
|
|
12553
12639
|
if (r) return this.handleDiagnostics(e, t, r[1]);
|
|
@@ -12901,6 +12987,11 @@ var mg = pg(dg, fg, ug), hg = "/* global document, window, localStorage, navigat
|
|
|
12901
12987
|
}
|
|
12902
12988
|
return t.writeHead(401, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: "Unauthorized" })), !1;
|
|
12903
12989
|
}
|
|
12990
|
+
requireAdminSession(e, t) {
|
|
12991
|
+
if (this.isAdmin(e)) return !0;
|
|
12992
|
+
let n = !!this.oidc?.userFromCookie(e.headers.cookie);
|
|
12993
|
+
return t.writeHead(n ? 403 : 401, { "Content-Type": "application/json; charset=utf-8" }), t.end(JSON.stringify({ error: n ? "Administrator role required" : "Authentication required" })), !1;
|
|
12994
|
+
}
|
|
12904
12995
|
async readJson(e, t) {
|
|
12905
12996
|
let { rawBuffer: n } = await this.readRequest(e, t);
|
|
12906
12997
|
if (!n || t.headersSent) return null;
|
package/dist/server.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { WebhookPreprocessor, WebhookServerOptions } from
|
|
1
|
+
import type { WebhookPreprocessor, WebhookServerOptions } from "./types.js";
|
|
2
2
|
export declare class WebhookServer {
|
|
3
3
|
private server;
|
|
4
4
|
private preprocessors;
|
|
@@ -50,6 +50,7 @@ export declare class WebhookServer {
|
|
|
50
50
|
private requireScope;
|
|
51
51
|
private matchesToken;
|
|
52
52
|
private requireAdmin;
|
|
53
|
+
private requireAdminSession;
|
|
53
54
|
private readJson;
|
|
54
55
|
private handleWorkflowValidation;
|
|
55
56
|
private handleWorkflowList;
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAkBxF,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,SAAS,CAA4B;IAC7C,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;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAyBA,OAAO,KAAK,EAAc,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAkBxF,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,SAAS,CAA4B;IAC7C,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;IAmBzC,oBAAoB,CAAC,YAAY,EAAE,mBAAmB;YAIxC,aAAa;IA8Z3B;;OAEG;YACW,aAAa;YA0Bb,cAAc;YAcd,iBAAiB;YA2CjB,aAAa;YAsBb,iBAAiB;YA4BjB,WAAW;YAwBX,UAAU;YAyBV,cAAc;IAsD5B,OAAO,CAAC,OAAO;IAIf,OAAO,CAAC,QAAQ;YAIF,QAAQ;IAatB,OAAO,CAAC,wBAAwB;YAQlB,eAAe;YAcf,kBAAkB;IA0BhC,OAAO,CAAC,gBAAgB;IASxB,OAAO,CAAC,iBAAiB;IAMzB,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,eAAe;IAMvB,OAAO,CAAC,OAAO;YAID,cAAc;YAsBd,cAAc;YA6Bd,eAAe;IAW7B,OAAO,CAAC,YAAY;IAIpB,OAAO,CAAC,UAAU;YAKJ,YAAY;IAO1B,OAAO,CAAC,YAAY;IAmBpB,OAAO,CAAC,YAAY;IAqBpB,OAAO,CAAC,mBAAmB;YAQb,QAAQ;YAYR,wBAAwB;YAcxB,kBAAkB;YAOlB,iBAAiB;YAoBjB,kBAAkB;YAoBlB,oBAAoB;YASpB,qBAAqB;YAWrB,gBAAgB;YAMhB,gBAAgB;YAiBhB,kBAAkB;YASlB,gBAAgB;IAc9B;;OAEG;YACW,kBAAkB;YAWlB,iBAAiB;IA2B/B,OAAO,CAAC,mBAAmB;YASb,mBAAmB;YAoBnB,qBAAqB;IAcnC;;OAEG;YACW,eAAe;IAK7B,OAAO,CAAC,cAAc;IAKtB,OAAO,CAAC,mBAAmB;YAgBb,mBAAmB;IAwBjC;;OAEG;YACW,gBAAgB;YAsChB,gBAAgB;YAiBhB,sBAAsB;YAoBtB,YAAY;YAkEZ,oBAAoB;YA0CpB,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"}
|
|
@@ -335,12 +335,14 @@ var b = class {
|
|
|
335
335
|
devDependencies: {
|
|
336
336
|
"@changesets/cli": "^2.31.1",
|
|
337
337
|
"@eslint/js": "^10.0.1",
|
|
338
|
+
"@tailwindcss/vite": "4.3.3",
|
|
338
339
|
"@types/node": "^26.5.1",
|
|
339
340
|
"@types/web-push": "^3.6.4",
|
|
340
341
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
|
341
342
|
"@typescript-eslint/parser": "^8.70.0",
|
|
342
343
|
eslint: "10.8.0",
|
|
343
344
|
marked: "^18.0.12",
|
|
345
|
+
tailwindcss: "4.3.3",
|
|
344
346
|
tsx: "4.23.13",
|
|
345
347
|
typescript: "6.0.3",
|
|
346
348
|
"typescript-eslint": "8.70.0",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud-cli/on",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.67.0",
|
|
4
4
|
"description": "CLI entry point for the on plugin ecosystem.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@12.3.4",
|
|
@@ -35,12 +35,14 @@
|
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@changesets/cli": "^2.31.1",
|
|
37
37
|
"@eslint/js": "^10.0.1",
|
|
38
|
+
"@tailwindcss/vite": "4.3.3",
|
|
38
39
|
"@types/node": "^26.5.1",
|
|
39
40
|
"@types/web-push": "^3.6.4",
|
|
40
41
|
"@typescript-eslint/eslint-plugin": "^8.70.0",
|
|
41
42
|
"@typescript-eslint/parser": "^8.70.0",
|
|
42
43
|
"eslint": "10.8.0",
|
|
43
44
|
"marked": "^18.0.12",
|
|
45
|
+
"tailwindcss": "4.3.3",
|
|
44
46
|
"tsx": "4.23.13",
|
|
45
47
|
"typescript": "6.0.3",
|
|
46
48
|
"typescript-eslint": "8.70.0",
|