@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agilesyndrome/cf-genai-base",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.js",
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.3";
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
- (request, env, ctx, next, state) => adminBoundary(request, env, ctx, next, state, { provider, authorize, scopes, scopeRoutes, features }),
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 = "<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.querySelector(".list"); list.replaceChildren(...groups.map((group) => { const row = document.createElement("div"); row.textContent = group.display_name + " — " + group.name; return row; })); this.querySelector(".status").textContent = groups.length + " groups"; } }
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:1rem}.nav{display:flex;flex-wrap:wrap;gap:.5rem;border-bottom:1px solid var(--cf-ui-border);padding-bottom:.75rem}.nav a{color:var(--cf-ui-text);padding:.45rem .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{display:grid;gap:.3rem;min-width:14rem}.scope-list label{display:flex;gap:.4rem;align-items:center}.status{color:var(--cf-ui-muted);min-height:1.4em}`;
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>${styles}</style><div class="shell"><nav class="nav" part="navigation"><a href="/admin" ${active === "home" ? 'aria-current="page"' : ""}>Admin</a><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">Features</a><a href="/admin/healthchecks">Healthchecks</a><a href="/admin/circuit-breakers">Circuit breakers</a><a href="/admin/groups">Groups</a></nav><slot></slot></div>`;
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>${styles}.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}</style><span class="badge" part="badge"></span>`; this.shadowRoot.querySelector(".badge").textContent = this.getAttribute("scope") || this.textContent || ""; }
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>${styles}</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."; } }
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)); label.append(input, document.createTextNode(scope.label || scope.name)); 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; }
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>${styles}</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>`;
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.description || scope.label || scope.name;
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() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card"><h2>Healthchecks</h2><p class="status">Loading…</p><div class="list" hidden></div></section>`; try { const response = await fetch("/api/admin/healthchecks", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load healthchecks."); const items = (await response.json()).healthchecks || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.textContent = `${item.display_name} — ${item.feature}/${item.component}: ${item.state}`; list.append(row); return row; })); list.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} healthcheck${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; } }
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() { this.attachShadow({ mode: "open" }).innerHTML = `<style>${styles}</style><section class="card"><h2>Circuit breakers</h2><p class="status">Loading…</p><div class="list" hidden></div></section>`; try { const response = await fetch("/api/admin/circuit-breakers", { credentials: "same-origin" }); if (!response.ok) throw new Error("Unable to load circuit breakers."); const items = (await response.json()).circuit_breakers || []; const list = this.shadowRoot.querySelector(".list"); list.replaceChildren(...items.map((item) => { const row = document.createElement("div"); row.textContent = `${item.display_name} — ${item.feature}/${item.name}: ${item.state}`; list.append(row); return row; })); list.hidden = false; this.shadowRoot.querySelector(".status").textContent = `${items.length} circuit breaker${items.length === 1 ? "" : "s"}`; } catch (error) { this.shadowRoot.querySelector(".status").textContent = error.message; } }
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);