@agilesyndrome/cf-genai-base 1.0.4 → 1.0.6
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/CONTRACT.md +2 -0
- package/README.md +11 -0
- package/package.json +1 -1
- package/src/core.js +1 -1
- package/src/index.js +20 -3
- package/src/ui/groups.js +1 -1
- package/src/ui/index.js +31 -10
package/CONTRACT.md
CHANGED
|
@@ -16,6 +16,8 @@ is optional and must use `ctx.waitUntil` for background work.
|
|
|
16
16
|
- Admin routes use `AUTH_STRATEGY`; omitted or empty means `http_basic`. Basic auth accepts username `admin` and the value of `ADMIN_TOKEN` (with `admin_token` supported for compatibility). Missing token means all admin routes return 401.
|
|
17
17
|
- `AUTH_STRATEGY=oauth` delegates identity establishment to the configured auth provider and uses `authorize` for admin policy.
|
|
18
18
|
- `scopes` registers an application scope manifest. `scopeRoutes` associates route prefixes or match functions with required scopes.
|
|
19
|
+
- `adminPage` optionally renders the authorized platform admin browser pages so a site can keep the shared system menu and its own visual shell consistent.
|
|
20
|
+
- `siteAdminPage` optionally renders site-owned pages below `/admin/site/*`, keeping them separate from the reserved platform page paths.
|
|
19
21
|
- Base provides `/api/admin/users`, `/api/admin/scopes`, `/api/admin/groups`, `/api/admin/status`, `/api/admin/features`, `/api/admin/healthchecks`, `/api/admin/circuit-breakers`, and `/api/admin/users/:id/scopes|groups` for platform administrators when the authorization and core migrations are installed. `GET /api/admin/features` returns the installed runtime feature manifests, package names and versions, per-feature health rollups, healthchecks, and circuit breakers. The browser route `GET /admin/features` renders that catalog. Feature manifests may provide `name`, `displayName`, `packageName`, and `version`. The exported UI includes users, scopes, groups, healthchecks, and circuit-breaker catalogs.
|
|
20
22
|
- Public APIs must be explicitly listed in provider-specific auth configuration.
|
|
21
23
|
- Mutating `/api/*` requests require a same-origin `Origin` header.
|
package/README.md
CHANGED
|
@@ -27,6 +27,17 @@ export default createWorker({
|
|
|
27
27
|
|
|
28
28
|
Features expose `middleware(request, env, ctx, next, state)` and may short-circuit reserved routes, attach request state, or call `next()`.
|
|
29
29
|
|
|
30
|
+
Sites may provide `adminPage({ request, env, url, state, features })` to render
|
|
31
|
+
the shared platform pages (`/admin/users`, `/admin/scopes`, `/admin/groups`,
|
|
32
|
+
`/admin/features`, `/admin/healthchecks`, and `/admin/circuit-breakers`) inside
|
|
33
|
+
their own shell. The callback runs after the shared authorization boundary and
|
|
34
|
+
must return a `Response` or `null`.
|
|
35
|
+
|
|
36
|
+
Sites may separately provide `siteAdminPage({ request, env, url, state,
|
|
37
|
+
features })` for a `/admin/site/*` namespace. This is useful when a site wants
|
|
38
|
+
its own admin pages to have an explicit boundary beside the shared platform
|
|
39
|
+
pages.
|
|
40
|
+
|
|
30
41
|
## Shared platform helpers
|
|
31
42
|
|
|
32
43
|
`createWorker` can own `/health` and `/api/health`, run a boot validator before
|
package/package.json
CHANGED
package/src/core.js
CHANGED
|
@@ -2,7 +2,7 @@ export const HEALTHCHECK_STATES = ["red", "yellow", "green"];
|
|
|
2
2
|
export const CIRCUIT_BREAKER_STATES = ["off", "tripped", "on"];
|
|
3
3
|
export const HEALTHCHECK_MODES = ["any", "all"];
|
|
4
4
|
export const BASE_PACKAGE_NAME = "@agilesyndrome/cf-genai-base";
|
|
5
|
-
export const BASE_VERSION = "1.0.
|
|
5
|
+
export const BASE_VERSION = "1.0.6";
|
|
6
6
|
|
|
7
7
|
export function eventLog(level, event, details = {}) {
|
|
8
8
|
const method = ["debug", "info", "warn", "error"].includes(level) ? level : "info";
|
package/src/index.js
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
import { ensureScopes, ensureUser, hasScope, listAuthorizationScopes, listAuthorizationUsers, listGroups, listUserGroups, listUserGrants, replaceUserGroups, replaceUserGrants } from "./authorization.js";
|
|
6
6
|
import { getCircuitBreaker, evaluateCircuitBreaker, listCircuitBreakers, listHealthchecks, listFeatureCatalog, listFeatureHealth, registerFeatureManifests, requestActor, setCircuitBreaker, updateHealthcheck } from "./core.js";
|
|
7
7
|
export * from "./core.js";
|
|
8
|
-
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true }) {
|
|
8
|
+
export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], scopeRoutes = [], middleware = [], features = [], health, boot, metrics, security = true, adminPage, siteAdminPage }) {
|
|
9
9
|
if (typeof fetch !== "function") throw new TypeError("createWorker requires a fetch handler");
|
|
10
10
|
const provider = auth || features.find((feature) => typeof feature?.getUser === "function");
|
|
11
11
|
const chain = [
|
|
12
|
-
|
|
12
|
+
(request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }),
|
|
13
13
|
...features.flatMap((feature) => feature?.middleware ? [feature.middleware.bind(feature)] : []),
|
|
14
14
|
...middleware,
|
|
15
15
|
...(auth ? [(request, env, ctx, next) => auth(request, env, ctx, next)] : []),
|
|
@@ -47,7 +47,7 @@ export function createWorker({ fetch, scheduled, auth, authorize, scopes = [], s
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
|
|
50
|
-
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }) {
|
|
50
|
+
async function adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features, adminPage, siteAdminPage }) {
|
|
51
51
|
const url = new URL(request.url);
|
|
52
52
|
if (!isAdminPath(url.pathname)) return next(request);
|
|
53
53
|
const strategy = String(env?.AUTH_STRATEGY || "http_basic").trim().toLowerCase();
|
|
@@ -72,6 +72,15 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
|
|
|
72
72
|
}
|
|
73
73
|
const platformResponse = await authorizationApi(request, env, url, state, features);
|
|
74
74
|
if (platformResponse) return platformResponse;
|
|
75
|
+
if (request.method === "GET" && isSiteAdminPage(url.pathname) && typeof siteAdminPage === "function") {
|
|
76
|
+
const response = await siteAdminPage({ request, env, url, state, features });
|
|
77
|
+
if (response) return response;
|
|
78
|
+
}
|
|
79
|
+
if (request.method === "GET" && isPlatformAdminPage(url.pathname) && typeof adminPage === "function") {
|
|
80
|
+
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
81
|
+
const response = await adminPage({ request, env, url, state, features });
|
|
82
|
+
if (response) return response;
|
|
83
|
+
}
|
|
75
84
|
if (url.pathname === "/admin/features" && request.method === "GET") {
|
|
76
85
|
if (!(state.user.auth_strategy === "http_basic" || (state.authUser && state.authUser.is_admin))) return new Response("Administrator access is required.", { status: 403, headers: { "Cache-Control": "no-store" } });
|
|
77
86
|
return featureCatalogPage(env, features, state);
|
|
@@ -79,6 +88,14 @@ async function adminBoundary(request, env, ctx, next, state, { provider, authori
|
|
|
79
88
|
return next(request);
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
function isPlatformAdminPage(pathname) {
|
|
92
|
+
return ["/admin/users", "/admin/scopes", "/admin/groups", "/admin/features", "/admin/healthchecks", "/admin/circuit-breakers"].includes(pathname);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isSiteAdminPage(pathname) {
|
|
96
|
+
return pathname === "/admin/site" || pathname.startsWith("/admin/site/");
|
|
97
|
+
}
|
|
98
|
+
|
|
82
99
|
function requiredScopeFor(pathname, routes) {
|
|
83
100
|
const route = routes.find((entry) => typeof entry.match === "function" ? entry.match(pathname) : pathname === entry.path || pathname.startsWith(String(entry.path || "") + "/"));
|
|
84
101
|
return route && route.scope ? route.scope : null;
|
package/src/ui/groups.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export class CfGroupCatalog extends HTMLElement { async connectedCallback() { this.innerHTML =
|
|
1
|
+
export class CfGroupCatalog extends HTMLElement { async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>:host{display:block;color:var(--cf-ui-text,#20231f);font:15px/1.45 system-ui,sans-serif}.card{background:var(--cf-ui-bg,#fff);border:1px solid var(--cf-ui-border,#d8ddd5);border-radius:.6rem;padding:1rem}.status{color:var(--cf-ui-muted,#687067);min-height:1.4em}.list{display:grid;gap:.3rem}.list div{padding:.55rem 0;border-bottom:1px solid var(--cf-ui-border,#d8ddd5)}</style><section class="card"><h2>User groups</h2><p class="status">Loading</p><div class="list"></div></section>`; const response = await fetch("/api/admin/groups", { credentials: "same-origin" }); const groups = (await response.json()).groups || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...groups.map((group) => { const row = document.createElement("div"); row.textContent = group.display_name + " — " + group.name; return row; })); this.shadowRoot.querySelector(".status").textContent = groups.length + " groups"; } }
|
|
2
2
|
if (!customElements.get("cf-group-catalog")) customElements.define("cf-group-catalog", CfGroupCatalog);
|
package/src/ui/index.js
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
export * from "./groups.js";
|
|
2
|
-
const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#20231f; --cf-ui-muted:#687067; --cf-ui-border:#d8ddd5; --cf-ui-primary:#2f6f52; color:var(--cf-ui-text); font:15px/1.45 system-ui,sans-serif } *,*::before,*::after{box-sizing:border-box}.shell{display:grid;gap:
|
|
2
|
+
const styles = `:host { --cf-ui-bg:#fff; --cf-ui-surface:#f7f7f5; --cf-ui-text:#20231f; --cf-ui-muted:#687067; --cf-ui-border:#d8ddd5; --cf-ui-primary:#2f6f52; color:var(--cf-ui-text); font:15px/1.45 system-ui,sans-serif } *,*::before,*::after{box-sizing:border-box}.shell{display:grid;grid-template-columns:180px minmax(0,1fr);gap:1.5rem}.nav{display:grid;align-content:start;gap:.2rem;border-right:1px solid var(--cf-ui-border);padding-right:1rem}.nav-group{display:grid;gap:.15rem;margin-bottom:.85rem}.nav-label{padding:.35rem .7rem;color:var(--cf-ui-muted);font-size:.68rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase}.nav a{color:var(--cf-ui-text);padding:.48rem .7rem;border-radius:.4rem;text-decoration:none}.nav a:hover,.nav a[aria-current=page]{background:var(--cf-ui-surface);color:var(--cf-ui-primary)}.card{background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.6rem;padding:1rem;overflow:auto}table{width:100%;border-collapse:collapse}th,td{padding:.65rem;border-bottom:1px solid var(--cf-ui-border);text-align:left;vertical-align:top}th{color:var(--cf-ui-muted);font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}button{border:1px solid var(--cf-ui-border);border-radius:.4rem;background:var(--cf-ui-bg);color:inherit;padding:.45rem .65rem;cursor:pointer}.scope-list,.catalog-list{display:grid;gap:.3rem;min-width:14rem}.scope-list label{display:flex;gap:.4rem;align-items:center}.catalog-list div{padding:.55rem 0;border-bottom:1px solid var(--cf-ui-border)}.status{color:var(--cf-ui-muted);min-height:1.4em}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}@media(max-width:640px){.shell{grid-template-columns:1fr}.nav{grid-template-columns:repeat(2,minmax(0,1fr));border-right:0;border-bottom:1px solid var(--cf-ui-border);padding:0 0 1rem}.nav-group{margin:0}.nav-label{grid-column:1/-1}}`;
|
|
3
|
+
|
|
4
|
+
const enhancedStyles = styles + `.status-grid,.breaker-groups,.feature-grid{display:grid;gap:.85rem}.status-grid{grid-template-columns:repeat(auto-fit,minmax(230px,1fr))}.status-card,.feature-card{padding:1rem;background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.65rem}.status-card h3,.feature-card h3{margin:0 0 .35rem;font-size:1rem}.status-card p,.feature-card p{margin:.3rem 0;color:var(--cf-ui-muted)}.status-line{display:flex;align-items:center;gap:.55rem;margin:.35rem 0}.state{font-weight:700}.state-green{color:#26734d}.state-yellow{color:#9a6b00}.state-red{color:#b3261e}.state-off{color:#687067}.state-tripped{color:#a33b32}.state-on{color:#26734d}.breaker-group{padding:1rem;background:var(--cf-ui-bg);border:1px solid var(--cf-ui-border);border-radius:.75rem}.breaker-group-heading{display:flex;align-items:start;justify-content:space-between;gap:1rem;margin-bottom:.75rem}.breaker-group h3{margin:0}.breaker-list{display:grid;gap:.55rem}.breaker-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:.7rem;align-items:center;padding:.7rem 0;border-top:1px solid var(--cf-ui-border)}.breaker-row.rollup{margin-top:.2rem;padding:.8rem;background:var(--cf-ui-surface);border:1px solid var(--cf-ui-border);border-radius:.55rem}.breaker-name{font-weight:700}.breaker-key{display:block;color:var(--cf-ui-muted);font:12px ui-monospace,monospace}.breaker-actions{display:flex;gap:.35rem;flex-wrap:wrap;justify-content:end}.breaker-actions button.is-active{border-color:var(--cf-ui-primary);background:var(--cf-ui-surface);color:var(--cf-ui-primary);font-weight:700}.feature-links{display:flex;gap:.65rem;flex-wrap:wrap;margin-top:.7rem}.feature-links a{color:var(--cf-ui-primary);font-weight:700}.error{color:var(--cf-ui-danger);min-height:1.4em}@media(max-width:640px){.breaker-row{grid-template-columns:1fr}.breaker-actions{justify-content:start}}`;
|
|
5
|
+
const stateInfo = { green: ["🟢", "Green"], yellow: ["🟡", "Yellow"], red: ["🔴", "Red"], off: ["⚪", "Off"], tripped: ["🔴", "Tripped"], on: ["🟢", "On"] };
|
|
6
|
+
const stateMarkup = (state) => { const [emoji, label] = stateInfo[String(state || "yellow").toLowerCase()] || ["⚪", String(state || "Unknown")]; return `<span class="state state-${String(state || "").toLowerCase()}">${emoji} ${label}</span>`; };
|
|
3
7
|
|
|
4
8
|
export class CfAdminShell extends HTMLElement {
|
|
5
9
|
connectedCallback() {
|
|
6
10
|
const active = this.getAttribute("active") || "";
|
|
7
|
-
this.attachShadow({ mode: "open" }).innerHTML = `<style>${
|
|
11
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><div class="shell"><nav class="nav" part="navigation"><div class="nav-group"><div class="nav-label">Cookbook</div><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Overview</a><a href="/new">New recipe</a><a href="/admin#reviewers">Reviewers</a></div><div class="nav-group"><div class="nav-label">System</div><a href="/admin/users" ${active === "users" ? 'aria-current="page"' : ""}>Users</a><a href="/admin/scopes" ${active === "scopes" ? 'aria-current="page"' : ""}>Scopes</a><a href="/admin/features" ${active === "features" ? 'aria-current="page"' : ""}>Features</a><a href="/admin/healthchecks" ${active === "healthchecks" ? 'aria-current="page"' : ""}>Healthchecks</a><a href="/admin/circuit-breakers" ${active === "circuit-breakers" ? 'aria-current="page"' : ""}>Circuit breakers</a><a href="/admin/groups" ${active === "groups" ? 'aria-current="page"' : ""}>Groups</a></div></nav><main><slot></slot></main></div>`;
|
|
8
12
|
}
|
|
9
13
|
}
|
|
10
14
|
|
|
11
15
|
export class CfScopeBadge extends HTMLElement {
|
|
12
|
-
connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${
|
|
16
|
+
connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}.badge{display:inline-block;border:1px solid var(--cf-ui-border);border-radius:999px;padding:.15rem .5rem;color:var(--cf-ui-primary);background:var(--cf-ui-surface);font-size:.85rem;cursor:help}</style><span class="badge" part="badge"></span>`; const name = this.getAttribute("scope") || this.textContent || ""; this.shadowRoot.querySelector(".badge").textContent = name; this.shadowRoot.querySelector(".badge").title = this.getAttribute("label") || name; }
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export class CfUserManagement extends HTMLElement {
|
|
16
|
-
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${
|
|
20
|
+
async connectedCallback() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card" part="panel"><h2>User access</h2><p class="status" part="status">Loading users and scopes…</p><table hidden part="table"><thead><tr><th>User</th><th>Scopes</th><th>Save</th></tr></thead><tbody></tbody></table></section>`; try { await this.load(); } catch (error) { this.status.textContent = error.message || "Unable to load access data."; } }
|
|
17
21
|
get status() { return this.shadowRoot.querySelector(".status"); }
|
|
18
22
|
async load() { const [usersResponse, scopesResponse] = await Promise.all([fetch("/api/admin/users", { credentials: "same-origin" }), fetch("/api/admin/scopes", { credentials: "same-origin" })]); if (!usersResponse.ok || !scopesResponse.ok) throw new Error("Unable to load user access."); const users = (await usersResponse.json()).users || []; const scopes = (await scopesResponse.json()).scopes || []; const body = this.shadowRoot.querySelector("tbody"); body.replaceChildren(...users.map((user) => this.row(user, scopes))); this.shadowRoot.querySelector("table").hidden = false; this.status.textContent = `${users.length} user${users.length === 1 ? "" : "s"}`; }
|
|
19
|
-
row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name));
|
|
23
|
+
row(user, scopes) { const row = document.createElement("tr"); const identity = document.createElement("td"); identity.textContent = `${user.display_name || user.email || "Unnamed user"} (${user.email || "no email"})`; const grants = document.createElement("td"); const list = document.createElement("div"); list.className = "scope-list"; for (const scope of scopes) { const label = document.createElement("label"); const input = document.createElement("input"); input.type = "checkbox"; input.value = scope.name; input.checked = Boolean((user.scopes || []).includes(scope.name)); const text = document.createElement("span"); text.textContent = scope.label || scope.name; text.title = scope.name; label.append(input, text); list.append(label); } grants.append(list); const action = document.createElement("td"); const button = document.createElement("button"); button.textContent = "Save"; button.addEventListener("click", async () => { button.disabled = true; const selected = [...list.querySelectorAll("input:checked")].map((input) => input.value); const response = await fetch(`/api/admin/users/${encodeURIComponent(user.id)}/scopes`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ scopes: selected }) }); button.disabled = false; this.status.textContent = response.ok ? "Access saved." : "Unable to save access."; }); action.append(button); row.append(identity, grants, action); return row; }
|
|
20
24
|
}
|
|
21
25
|
|
|
22
26
|
if (!customElements.get("cf-admin-shell")) customElements.define("cf-admin-shell", CfAdminShell);
|
|
@@ -25,7 +29,7 @@ if (!customElements.get("cf-user-management")) customElements.define("cf-user-ma
|
|
|
25
29
|
|
|
26
30
|
export class CfScopeCatalog extends HTMLElement {
|
|
27
31
|
async connectedCallback() {
|
|
28
|
-
this.attachShadow({ mode: "open" }).innerHTML = `<style>${
|
|
32
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card" part="panel"><h2>Available scopes</h2><p class="status" part="status">Loading scopes…</p><div class="scope-list" hidden part="list"></div></section>`;
|
|
29
33
|
try {
|
|
30
34
|
const response = await fetch("/api/admin/scopes", { credentials: "same-origin" });
|
|
31
35
|
if (!response.ok) throw new Error("Unable to load scopes.");
|
|
@@ -36,7 +40,8 @@ export class CfScopeCatalog extends HTMLElement {
|
|
|
36
40
|
const badge = document.createElement("cf-scope-badge");
|
|
37
41
|
badge.setAttribute("scope", scope.name);
|
|
38
42
|
const description = document.createElement("span");
|
|
39
|
-
description.textContent = scope.
|
|
43
|
+
description.textContent = ` ${scope.label || scope.description || scope.name}`;
|
|
44
|
+
description.title = scope.label || scope.name;
|
|
40
45
|
item.append(badge, description);
|
|
41
46
|
return item;
|
|
42
47
|
}));
|
|
@@ -48,16 +53,32 @@ export class CfScopeCatalog extends HTMLElement {
|
|
|
48
53
|
}
|
|
49
54
|
}
|
|
50
55
|
|
|
51
|
-
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|
|
52
56
|
if (!customElements.get("cf-scope-catalog")) customElements.define("cf-scope-catalog", CfScopeCatalog);
|
|
53
57
|
|
|
54
58
|
export class CfHealthcheckCatalog extends HTMLElement {
|
|
55
|
-
async connectedCallback() {
|
|
59
|
+
async connectedCallback() {
|
|
60
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card"><h2>Healthchecks</h2><p class="status">Loading…</p><div class="status-grid" hidden></div></section>`;
|
|
61
|
+
try { const response = await fetch("/api/admin/healthchecks", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load healthchecks."); let items = (await response.json()).healthchecks || []; const feature = new URLSearchParams(location.search).get("feature"); if (feature) items = items.filter((item) => item.feature === feature); const grid = this.shadowRoot.querySelector(".status-grid"); grid.replaceChildren(...items.map((item) => { const card = document.createElement("article"); card.className = "status-card"; card.innerHTML = `<h3></h3><div class="status-line"></div><p></p>`; card.querySelector("h3").textContent = item.display_name; card.querySelector(".status-line").innerHTML = stateMarkup(item.state); card.querySelector("p").textContent = `${item.feature} / ${item.component}`; return card; })); grid.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} healthcheck${items.length === 1 ? "" : "s"}${feature ? ` for ${feature}` : ""}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; }
|
|
62
|
+
}
|
|
56
63
|
}
|
|
57
64
|
|
|
58
65
|
export class CfCircuitBreakerCatalog extends HTMLElement {
|
|
59
|
-
async connectedCallback() {
|
|
66
|
+
async connectedCallback() {
|
|
67
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card"><h2>Circuit breakers</h2><p class="status">Loading…</p><div class="breaker-groups" hidden></div><p class="error" role="status"></p></section>`;
|
|
68
|
+
try { const response = await fetch("/api/admin/circuit-breakers", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load circuit breakers."); let items = (await response.json()).circuit_breakers || []; const feature = new URLSearchParams(location.search).get("feature"); if (feature) items = items.filter((item) => item.feature === feature); const groups = new Map(); for (const item of items) { if (!groups.has(item.feature)) groups.set(item.feature, []); groups.get(item.feature).push(item); } const root = this.shadowRoot.querySelector(".breaker-groups"); root.replaceChildren(...[...groups].map(([featureName, breakers]) => this.group(featureName, breakers))); root.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} circuit breaker${items.length === 1 ? "" : "s"}${feature ? ` for ${feature}` : ""}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; }
|
|
69
|
+
}
|
|
70
|
+
group(feature, breakers) { const section = document.createElement("section"); section.className = "breaker-group"; const heading = document.createElement("div"); heading.className = "breaker-group-heading"; heading.innerHTML = `<div><h3></h3><span class="breaker-key"></span></div><a href="/admin/features?feature=${encodeURIComponent(feature)}">View feature</a>`; heading.querySelector("h3").textContent = feature; heading.querySelector(".breaker-key").textContent = `${breakers.length} breaker${breakers.length === 1 ? "" : "s"}`; section.append(heading); const list = document.createElement("div"); list.className = "breaker-list"; const ordered = [...breakers].sort((a, b) => Number(b.name === "rollup") - Number(a.name === "rollup") || a.name.localeCompare(b.name)); list.replaceChildren(...ordered.map((breaker) => this.row(breaker))); section.append(list); return section; }
|
|
71
|
+
row(breaker) { const row = document.createElement("div"); row.className = `breaker-row${breaker.name === "rollup" ? " rollup" : ""}`; row.innerHTML = `<div><span class="breaker-name"></span><span class="breaker-key"></span></div><span class="breaker-state"></span><div class="breaker-actions"><button data-state="off">Off</button><button data-state="tripped">Tripped</button><button data-state="on">On</button></div>`; row.querySelector(".breaker-name").textContent = breaker.name === "rollup" ? `↳ ${breaker.display_name} · feature rollup` : breaker.display_name; row.querySelector(".breaker-key").textContent = `${breaker.feature}/${breaker.name}`; row.querySelector(".breaker-state").innerHTML = stateMarkup(breaker.state); for (const button of row.querySelectorAll("button")) { button.classList.toggle("is-active", button.dataset.state === breaker.state); button.addEventListener("click", () => this.update(breaker, row, button.dataset.state)); } return row; }
|
|
72
|
+
async update(breaker, row, state) { const buttons = [...row.querySelectorAll("button")]; buttons.forEach((button) => { button.disabled = true; }); try { const response = await fetch(`/api/admin/circuit-breakers/${encodeURIComponent(breaker.id)}`, { method: "PUT", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state }) }); const result = await response.json(); if (!response.ok) throw new Error(result.error || "Unable to update circuit breaker."); breaker.state = result.circuit_breaker?.state || state; row.querySelector(".breaker-state").innerHTML = stateMarkup(breaker.state); buttons.forEach((button) => button.classList.toggle("is-active", button.dataset.state === breaker.state)); } catch (error) { this.shadowRoot.querySelector(".error").textContent = error.message; } finally { buttons.forEach((button) => { button.disabled = false; }); } }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class CfFeatureCatalog extends HTMLElement {
|
|
76
|
+
async connectedCallback() {
|
|
77
|
+
this.attachShadow({ mode: "open" }).innerHTML = `<style>${enhancedStyles}</style><section class="card"><h2>Installed features</h2><p class="status">Loading…</p><div class="feature-grid" hidden></div></section>`;
|
|
78
|
+
try { const response = await fetch("/api/admin/features", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load features."); let items = (await response.json()).features || []; const selected = new URLSearchParams(location.search).get("feature"); if (selected) items = items.filter((item) => item.feature === selected); const grid = this.shadowRoot.querySelector(".feature-grid"); grid.replaceChildren(...items.map((item) => { const card = document.createElement("article"); card.className = "feature-card"; const rollup = item.circuit_breaker; card.innerHTML = `<h3></h3><p class="feature-health"></p><p class="feature-rollup"></p><div class="feature-links"><a class="health-link">Healthchecks</a><a class="breaker-link">Circuit breakers</a></div>`; card.querySelector("h3").textContent = item.display_name || item.feature; card.querySelector(".feature-health").innerHTML = `Health: ${stateMarkup(item.health)}`; card.querySelector(".feature-rollup").innerHTML = `Rollup: ${rollup ? stateMarkup(rollup.state) : "—"}`; card.querySelector(".health-link").href = `/admin/healthchecks?feature=${encodeURIComponent(item.feature)}`; card.querySelector(".breaker-link").href = `/admin/circuit-breakers?feature=${encodeURIComponent(item.feature)}`; return card; })); grid.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} feature${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; }
|
|
79
|
+
}
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
if (!customElements.get("cf-healthcheck-catalog")) customElements.define("cf-healthcheck-catalog", CfHealthcheckCatalog);
|
|
63
83
|
if (!customElements.get("cf-circuit-breaker-catalog")) customElements.define("cf-circuit-breaker-catalog", CfCircuitBreakerCatalog);
|
|
84
|
+
if (!customElements.get("cf-feature-catalog")) customElements.define("cf-feature-catalog", CfFeatureCatalog);
|