@crvouga/mockingbird-service-plane 1.6.0 → 1.7.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/CHANGELOG.md CHANGED
@@ -1,6 +1,24 @@
1
1
  # Changelog — @crvouga/mockingbird-service-plane
2
2
 
3
- ## 1.6.0 (2026-09-30)
3
+ ## 1.7.0 (2026-10-01)
4
+
5
+ ### Features
6
+
7
+ - record SQL timelines and serve the admin explorer ([8ced17a](https://github.com/crvouga/mockingbird/commit/8ced17ac398e386ef4e9d7e01fc7744c34b95d07))
8
+
9
+ ### Dependencies
10
+
11
+ - `@crvouga/mockingbird-service-sqlite`
12
+
13
+ ## 1.6.1 (2026-09-30)
14
+
15
+ ### Fixes and improvements
16
+
17
+ - keep admin buttons pressed until the action finishes ([0aabfcb](https://github.com/crvouga/mockingbird/commit/0aabfcb5f392aa51e193f9c3f817d5877d92bc6a))
18
+ - size admin select fields like the text inputs beside them ([0fb3ef2](https://github.com/crvouga/mockingbird/commit/0fb3ef2ddbf43376c93a2ae6d87c6c9bf890dafc))
19
+ - keep admin section tabs wired when boot is renamed ([4eda25e](https://github.com/crvouga/mockingbird/commit/4eda25eacd59da048d196f7cc8369d7a02b47bfc))
20
+
21
+ ## 1.6.0 (2026-09-29)
4
22
 
5
23
  ### Features
6
24
 
@@ -1899,6 +1899,167 @@ function faultPresetList(value) {
1899
1899
  })
1900
1900
  };
1901
1901
  }
1902
+ function mountSqlExplorer(root, api) {
1903
+ const cell = (value) => {
1904
+ if (value === null || value === void 0)
1905
+ return "";
1906
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
1907
+ return String(value);
1908
+ }
1909
+ return JSON.stringify(value) ?? "";
1910
+ };
1911
+ root.replaceChildren();
1912
+ const layout = document.createElement("div");
1913
+ layout.className = "sql-layout";
1914
+ const tables = document.createElement("div");
1915
+ tables.className = "sql-tables";
1916
+ const stack = document.createElement("div");
1917
+ stack.className = "stack";
1918
+ const banner = document.createElement("p");
1919
+ banner.className = "banner";
1920
+ const grid = document.createElement("div");
1921
+ const input = document.createElement("textarea");
1922
+ input.className = "sql-input";
1923
+ input.spellcheck = false;
1924
+ input.placeholder = "SELECT * FROM ...";
1925
+ const actions = document.createElement("div");
1926
+ actions.className = "row-actions";
1927
+ const run = document.createElement("button");
1928
+ run.type = "button";
1929
+ run.textContent = "Run";
1930
+ actions.append(run);
1931
+ const result = document.createElement("pre");
1932
+ stack.append(banner, grid, input, actions, result);
1933
+ layout.append(tables, stack);
1934
+ root.append(layout);
1935
+ const showError = (error) => {
1936
+ banner.textContent = error instanceof Error ? error.message : String(error);
1937
+ banner.classList.add("show");
1938
+ };
1939
+ const clearError = () => {
1940
+ banner.textContent = "";
1941
+ banner.classList.remove("show");
1942
+ };
1943
+ const renderGrid = (columns, rows) => {
1944
+ grid.replaceChildren();
1945
+ if (columns.length === 0) {
1946
+ const empty = document.createElement("p");
1947
+ empty.className = "empty";
1948
+ empty.textContent = "No rows.";
1949
+ grid.append(empty);
1950
+ return;
1951
+ }
1952
+ const table = document.createElement("table");
1953
+ const head = document.createElement("thead");
1954
+ const headRow = document.createElement("tr");
1955
+ for (const column of columns) {
1956
+ const th = document.createElement("th");
1957
+ th.textContent = column;
1958
+ headRow.append(th);
1959
+ }
1960
+ head.append(headRow);
1961
+ const body = document.createElement("tbody");
1962
+ for (const row of rows) {
1963
+ const tr = document.createElement("tr");
1964
+ for (const column of columns) {
1965
+ const td = document.createElement("td");
1966
+ td.dataset.label = column;
1967
+ td.textContent = cell(row[column]);
1968
+ tr.append(td);
1969
+ }
1970
+ body.append(tr);
1971
+ }
1972
+ table.append(head, body);
1973
+ grid.append(table);
1974
+ };
1975
+ const rowsOf = (value) => {
1976
+ if (!isRecord2(value) || !Array.isArray(value.rows))
1977
+ return [];
1978
+ return value.rows.flatMap((row) => isRecord2(row) ? [row] : []);
1979
+ };
1980
+ const columnsOf = (value, rows) => {
1981
+ if (isRecord2(value) && Array.isArray(value.columns)) {
1982
+ const named = value.columns.flatMap((column) => typeof column === "string" ? [column] : []);
1983
+ if (named.length > 0)
1984
+ return named;
1985
+ }
1986
+ const first = rows[0];
1987
+ return first === void 0 ? [] : Object.keys(first);
1988
+ };
1989
+ let selected = "";
1990
+ const loadPage = async (schema, name) => {
1991
+ selected = `${schema}.${name}`;
1992
+ const page = await api.get(`/sql/tables/${encodeURIComponent(schema)}/${encodeURIComponent(name)}?limit=50`);
1993
+ const rows = rowsOf(page);
1994
+ renderGrid(columnsOf(page, rows), rows);
1995
+ const total = isRecord2(page) && typeof page.total === "number" ? page.total : rows.length;
1996
+ result.textContent = `${selected} \xB7 ${total} rows`;
1997
+ for (const button of tables.querySelectorAll("button")) {
1998
+ if (!(button instanceof HTMLButtonElement))
1999
+ continue;
2000
+ button.setAttribute("aria-current", button.dataset.schema === schema && button.dataset.table === name ? "true" : "false");
2001
+ }
2002
+ };
2003
+ const loadTables = async () => {
2004
+ const payload = await api.get("/sql/tables");
2005
+ const listed = isRecord2(payload) && Array.isArray(payload.tables) ? payload.tables : [];
2006
+ tables.replaceChildren();
2007
+ if (listed.length === 0) {
2008
+ const empty = document.createElement("p");
2009
+ empty.className = "empty";
2010
+ empty.textContent = "No tables.";
2011
+ tables.append(empty);
2012
+ return;
2013
+ }
2014
+ for (const item of listed) {
2015
+ if (!isRecord2(item) || typeof item.schema !== "string" || typeof item.name !== "string")
2016
+ continue;
2017
+ const button = document.createElement("button");
2018
+ button.type = "button";
2019
+ button.dataset.schema = item.schema;
2020
+ button.dataset.table = item.name;
2021
+ button.textContent = `${item.schema}.${item.name}`;
2022
+ tables.append(button);
2023
+ }
2024
+ };
2025
+ tables.addEventListener("click", (event) => {
2026
+ const target = event.target;
2027
+ if (!(target instanceof Element))
2028
+ return;
2029
+ const button = target.closest("button");
2030
+ if (!(button instanceof HTMLButtonElement))
2031
+ return;
2032
+ const schema = button.dataset.schema;
2033
+ const name = button.dataset.table;
2034
+ if (schema === void 0 || name === void 0)
2035
+ return;
2036
+ clearError();
2037
+ loadPage(schema, name).catch(showError);
2038
+ });
2039
+ run.addEventListener("click", () => {
2040
+ const sql = input.value.trim();
2041
+ if (sql === "") {
2042
+ showError(new Error("Enter a statement"));
2043
+ return;
2044
+ }
2045
+ clearError();
2046
+ api.send("POST", "/sql/query", { sql }).then((payload) => {
2047
+ const rows = rowsOf(payload);
2048
+ renderGrid(columnsOf(payload, rows), rows);
2049
+ const count = isRecord2(payload) && typeof payload.rowCount === "number" ? payload.rowCount : rows.length;
2050
+ const truncated = isRecord2(payload) && payload.truncated === true ? " (truncated)" : "";
2051
+ result.textContent = `${count} rows${truncated}`;
2052
+ }).catch(showError);
2053
+ });
2054
+ const namespace = document.getElementById("namespace");
2055
+ if (namespace) {
2056
+ namespace.addEventListener("change", () => {
2057
+ clearError();
2058
+ loadTables().catch(showError);
2059
+ });
2060
+ }
2061
+ loadTables().catch(showError);
2062
+ }
1902
2063
  function bootAdmin(config) {
1903
2064
  const fieldKinds = ["string", "number", "boolean", "null", "object", "array", "unknown"];
1904
2065
  const sources = ["declared", "inferred", "mixed"];
@@ -2339,6 +2500,50 @@ function bootAdmin(config) {
2339
2500
  if (render)
2340
2501
  await render();
2341
2502
  }
2503
+ const delay = (ms) => new Promise((resolve) => {
2504
+ window.setTimeout(resolve, ms);
2505
+ });
2506
+ const confirmAction = async (button, pending, done, work) => {
2507
+ if (button.dataset.pending === "1")
2508
+ return;
2509
+ const label = button.textContent ?? "";
2510
+ const width = button.offsetWidth;
2511
+ button.dataset.pending = "1";
2512
+ button.classList.add("pressed");
2513
+ button.setAttribute("aria-busy", "true");
2514
+ button.style.minWidth = `${width}px`;
2515
+ button.textContent = pending;
2516
+ try {
2517
+ await work();
2518
+ button.textContent = done;
2519
+ await delay(1e3);
2520
+ } catch (error) {
2521
+ showError(error);
2522
+ } finally {
2523
+ button.textContent = label;
2524
+ button.style.minWidth = "";
2525
+ button.classList.remove("pressed");
2526
+ button.removeAttribute("aria-busy");
2527
+ delete button.dataset.pending;
2528
+ }
2529
+ };
2530
+ document.addEventListener("click", (event) => {
2531
+ const target = event.target;
2532
+ if (!(target instanceof Element))
2533
+ return;
2534
+ const button = target.closest("button");
2535
+ if (!(button instanceof HTMLButtonElement) || button.dataset.pending === "1")
2536
+ return;
2537
+ button.classList.add("pressed");
2538
+ const token = String(Date.now());
2539
+ button.dataset.press = token;
2540
+ window.setTimeout(() => {
2541
+ if (button.dataset.press !== token || button.dataset.pending === "1")
2542
+ return;
2543
+ button.classList.remove("pressed");
2544
+ delete button.dataset.press;
2545
+ }, 450);
2546
+ });
2342
2547
  el("namespace", HTMLSelectElement).addEventListener("change", () => {
2343
2548
  state.namespace = el("namespace", HTMLSelectElement).value;
2344
2549
  refresh().catch(showError);
@@ -2349,7 +2554,7 @@ function bootAdmin(config) {
2349
2554
  refresh().catch(showError);
2350
2555
  });
2351
2556
  el("refresh", HTMLButtonElement).addEventListener("click", () => {
2352
- refresh().catch(showError);
2557
+ confirmAction(el("refresh", HTMLButtonElement), "Refreshing...", "Updated", () => refresh()).catch(showError);
2353
2558
  });
2354
2559
  el("collections", HTMLElement).addEventListener("click", (event) => {
2355
2560
  const target = event.target;
@@ -2472,10 +2677,10 @@ function bootAdmin(config) {
2472
2677
  }).then(() => renderFaults()).catch(showError);
2473
2678
  });
2474
2679
  el("fault-clear", HTMLButtonElement).addEventListener("click", () => {
2475
- api("DELETE", "/faults").then(() => renderFaults()).catch(showError);
2680
+ confirmAction(el("fault-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/faults").then(() => renderFaults())).catch(showError);
2476
2681
  });
2477
2682
  el("journal-clear", HTMLButtonElement).addEventListener("click", () => {
2478
- api("DELETE", "/requests").then(() => renderJournal()).catch(showError);
2683
+ confirmAction(el("journal-clear", HTMLButtonElement), "Clearing...", "Cleared", () => api("DELETE", "/requests").then(() => renderJournal())).catch(showError);
2479
2684
  });
2480
2685
  el("route-list", HTMLElement).addEventListener("click", (event) => {
2481
2686
  const target = event.target;
@@ -2531,6 +2736,22 @@ function bootAdmin(config) {
2531
2736
  panel.script = value.script;
2532
2737
  return panel;
2533
2738
  };
2739
+ const decodeExtension = (value) => {
2740
+ if (!isRecord2(value))
2741
+ return void 0;
2742
+ if (value.kind === "sql") {
2743
+ return {
2744
+ kind: "sql",
2745
+ id: typeof value.id === "string" && value.id !== "" ? value.id : "sql",
2746
+ title: typeof value.title === "string" && value.title !== "" ? value.title : "SQL",
2747
+ description: typeof value.description === "string" ? value.description : "Browse tables and run queries."
2748
+ };
2749
+ }
2750
+ if (value.kind !== "panel")
2751
+ return void 0;
2752
+ const panel = decodePanel(value);
2753
+ return panel === void 0 ? void 0 : { kind: "panel", ...panel };
2754
+ };
2534
2755
  const mountPanels = (panels) => {
2535
2756
  const nav = el("nav", HTMLElement);
2536
2757
  const views = el("views", HTMLElement);
@@ -2584,15 +2805,57 @@ function bootAdmin(config) {
2584
2805
  return decoded === void 0 ? [] : [decoded];
2585
2806
  });
2586
2807
  mountPanels(panels);
2808
+ const extensions = requireList(isRecord2(manifest) ? manifest.extensions : void 0, "Extensions").flatMap((extension) => {
2809
+ const decoded = decodeExtension(extension);
2810
+ return decoded === void 0 ? [] : [decoded];
2811
+ });
2812
+ const extensionPanels = extensions.flatMap((extension) => extension.kind === "panel" ? [extension] : []);
2813
+ mountPanels(extensionPanels);
2814
+ for (const extension of extensions) {
2815
+ if (extension.kind !== "sql")
2816
+ continue;
2817
+ const nav = el("nav", HTMLElement);
2818
+ const views = el("views", HTMLElement);
2819
+ const button = document.createElement("button");
2820
+ button.type = "button";
2821
+ button.dataset.view = extension.id;
2822
+ button.textContent = extension.title;
2823
+ nav.append(button);
2824
+ const section = document.createElement("section");
2825
+ section.className = "view";
2826
+ section.id = `view-${extension.id}`;
2827
+ section.hidden = true;
2828
+ const title = document.createElement("h2");
2829
+ title.textContent = extension.title;
2830
+ const lede = document.createElement("p");
2831
+ lede.className = "lede";
2832
+ lede.textContent = extension.description;
2833
+ const body = document.createElement("div");
2834
+ body.className = "panel-body";
2835
+ section.append(title, lede, body);
2836
+ views.append(section);
2837
+ mountSqlExplorer(body, panelApi);
2838
+ }
2587
2839
  return refresh();
2588
2840
  }).catch(showError);
2589
2841
  }
2590
- var clientPrelude = [isRecord2, mountAdminBrand, startAdminBrand, faultPresetList, bootAdmin].map((fn) => fn.toString()).join("\n");
2842
+ var clientPrelude = [
2843
+ isRecord2,
2844
+ mountAdminBrand,
2845
+ startAdminBrand,
2846
+ faultPresetList,
2847
+ mountSqlExplorer,
2848
+ bootAdmin
2849
+ ].map((fn) => fn.toString()).join("\n");
2591
2850
  var adminClientSource = (config) => {
2592
2851
  const payload = JSON.stringify(config).replace(/</g, "\\u003c");
2852
+ const entry = bootAdmin.name;
2853
+ if (!/^[A-Za-z_$][\w$]*$/.test(entry)) {
2854
+ throw new Error("admin client cannot call its boot function");
2855
+ }
2593
2856
  return `<script>
2594
2857
  ${clientPrelude}
2595
- bootAdmin(${payload});
2858
+ ${entry}(${payload});
2596
2859
  </script>`;
2597
2860
  };
2598
2861
 
@@ -2633,19 +2896,33 @@ var STANDARD_ADMIN_ROUTES = [
2633
2896
  "GET /ui/manifest"
2634
2897
  ];
2635
2898
  var PANEL_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
2899
+ var RESERVED_VIEWS = /* @__PURE__ */ new Set(["overview", "state", "clock", "faults", "journal", "routes"]);
2900
+ var checkPanel = (panel, seen, label) => {
2901
+ if (!PANEL_ID.test(panel.id)) {
2902
+ throw new RangeError(`${label} id must match ${PANEL_ID}: ${JSON.stringify(panel.id)}`);
2903
+ }
2904
+ if (RESERVED_VIEWS.has(panel.id)) {
2905
+ throw new RangeError(`${label} id ${panel.id} is reserved by the admin shell`);
2906
+ }
2907
+ if (seen.has(panel.id))
2908
+ throw new RangeError(`duplicate admin view id ${panel.id}`);
2909
+ seen.add(panel.id);
2910
+ if (panel.title.trim() === "")
2911
+ throw new RangeError(`${label} ${panel.id} needs a title`);
2912
+ };
2636
2913
  var assertAdminUi = (ui) => {
2637
- if (!ui?.panels)
2914
+ if (!ui)
2638
2915
  return;
2639
2916
  const seen = /* @__PURE__ */ new Set();
2640
- for (const panel of ui.panels) {
2641
- if (!PANEL_ID.test(panel.id)) {
2642
- throw new RangeError(`admin panel id must match ${PANEL_ID}: ${JSON.stringify(panel.id)}`);
2917
+ for (const panel of ui.panels ?? [])
2918
+ checkPanel(panel, seen, "admin panel");
2919
+ for (const extension of ui.extensions ?? []) {
2920
+ if (extension.kind === "panel") {
2921
+ checkPanel(extension, seen, "admin extension");
2922
+ continue;
2643
2923
  }
2644
- if (seen.has(panel.id))
2645
- throw new RangeError(`duplicate admin panel id ${panel.id}`);
2646
- seen.add(panel.id);
2647
- if (panel.title.trim() === "")
2648
- throw new RangeError(`admin panel ${panel.id} needs a title`);
2924
+ const id = extension.id ?? "sql";
2925
+ checkPanel({ id, title: extension.title ?? "SQL" }, seen, "admin extension");
2649
2926
  }
2650
2927
  };
2651
2928
 
@@ -2806,9 +3083,24 @@ var renderAdminDocument = (service) => {
2806
3083
  .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
2807
3084
  label.field { display: flex; flex-direction: column; gap: 2px; font-size: 11px; color: var(--faint); }
2808
3085
  label.field input, label.field select, .controls input[type="password"] {
2809
- background: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
2810
- padding: 7px 8px; min-height: 36px;
2811
- }
3086
+ background-color: var(--bg-inset); border: 1px solid var(--line); border-radius: 8px;
3087
+ padding: 7px 8px; min-height: 36px; line-height: 1.2;
3088
+ }
3089
+ /* A native menu ignores min-height, so Preset rendered about half as tall as
3090
+ the text fields in the same form. Paint it like those fields. */
3091
+ label.field select {
3092
+ -webkit-appearance: none; appearance: none;
3093
+ padding-right: 28px;
3094
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='none' stroke='%2371717a' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' d='M2.5 4.5 6 8l3.5-3.5'/%3E%3C/svg%3E");
3095
+ background-repeat: no-repeat; background-position: right 10px center; background-size: 12px 12px;
3096
+ }
3097
+ .stack > .field > input,
3098
+ .stack > .field > select,
3099
+ .stack > .field > textarea,
3100
+ dialog .field > input,
3101
+ dialog .field > select,
3102
+ dialog .field > textarea { width: 100%; }
3103
+ .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger, nav.side button { cursor: pointer; }
2812
3104
  .icon-btn, .btn, .btn-primary, .btn-quiet, .btn-danger {
2813
3105
  border-radius: 9px; min-height: 36px; padding: 0 12px; border: 1px solid var(--line);
2814
3106
  background: var(--bg-elev);
@@ -2816,6 +3108,14 @@ var renderAdminDocument = (service) => {
2816
3108
  .btn-primary { background: var(--accent); color: var(--accent-ink); border-color: transparent; }
2817
3109
  .btn-quiet { background: transparent; }
2818
3110
  .btn-danger { color: var(--err); }
3111
+ /* Held after mouseup. Clearing an empty journal changes nothing on screen,
3112
+ so the press itself has to stay visible. */
3113
+ .icon-btn:active, .btn:active, .btn-quiet:active, .btn-danger:active, nav.side button:active,
3114
+ .icon-btn.pressed, .btn.pressed, .btn-quiet.pressed, .btn-danger.pressed, nav.side button.pressed {
3115
+ background: var(--bg-inset); border-color: var(--line-strong); color: var(--ink);
3116
+ }
3117
+ .btn-danger:active, .btn-danger.pressed { background: var(--err-soft); color: var(--err); }
3118
+ .btn-primary:active, .btn-primary.pressed { filter: brightness(0.88); color: var(--accent-ink); }
2819
3119
  /* minmax(0, 1fr): a 1fr column will not shrink below its content, so a long
2820
3120
  record or the full nav row would widen the page past the viewport. */
2821
3121
  .layout {
@@ -2856,6 +3156,12 @@ var renderAdminDocument = (service) => {
2856
3156
  border-radius: 999px; padding: 4px 8px;
2857
3157
  }
2858
3158
  .chip i { color: var(--faint); font-style: normal; }
3159
+ .sql-layout {
3160
+ display: grid; grid-template-columns: minmax(10rem, 16rem) minmax(0, 1fr); gap: 16px;
3161
+ }
3162
+ .sql-tables { display: flex; flex-direction: column; gap: 4px; max-height: 28rem; overflow: auto; }
3163
+ .sql-tables button { text-align: left; }
3164
+ .sql-input { font-family: var(--mono); min-height: 7rem; width: 100%; }
2859
3165
  table { width: 100%; border-collapse: collapse; font-size: 14px; }
2860
3166
  th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
2861
3167
  th { color: var(--faint); font-weight: 600; font-size: 12px; }
@@ -2904,14 +3210,22 @@ var renderAdminDocument = (service) => {
2904
3210
  input, select, textarea { font-size: 16px; }
2905
3211
  .brand, .vendor, .controls { width: 100%; }
2906
3212
  label.field, label.field select, label.field input { flex: 1; min-width: 0; }
2907
- .layout { grid-template-columns: minmax(0, 1fr); }
3213
+ /* The layout fills the screen. A single-column grid stretches every auto
3214
+ row, so the tab bar grew with the leftover height and the buttons floated
3215
+ in the middle of it. The bar stays one row; the page below takes the rest. */
3216
+ .layout {
3217
+ grid-template-columns: minmax(0, 1fr);
3218
+ grid-template-rows: auto minmax(0, 1fr);
3219
+ align-content: start;
3220
+ }
2908
3221
  nav.side {
3222
+ align-self: start; height: auto;
2909
3223
  flex-direction: row; align-items: center; overflow-x: auto; min-width: 0;
2910
3224
  border-right: 0; border-bottom: 1px solid var(--line);
2911
3225
  padding: 8px;
2912
3226
  }
2913
3227
  nav.side button { white-space: nowrap; flex: none; }
2914
- .split { grid-template-columns: minmax(0, 1fr); }
3228
+ .split, .sql-layout { grid-template-columns: minmax(0, 1fr); }
2915
3229
  main { padding: 14px; }
2916
3230
  table, thead, tbody, tr, th, td { display: block; }
2917
3231
  thead { display: none; }
@@ -3082,6 +3396,24 @@ var json2 = (body) => new Response(JSON.stringify(body), {
3082
3396
  status: 200,
3083
3397
  headers: { "content-type": "application/json; charset=utf-8" }
3084
3398
  });
3399
+ var describeExtension = (extension) => {
3400
+ if (extension.kind === "sql") {
3401
+ return {
3402
+ kind: "sql",
3403
+ id: extension.id ?? "sql",
3404
+ title: extension.title ?? "SQL",
3405
+ ...extension.description !== void 0 ? { description: extension.description } : {}
3406
+ };
3407
+ }
3408
+ return {
3409
+ kind: "panel",
3410
+ id: extension.id,
3411
+ title: extension.title,
3412
+ ...extension.description !== void 0 ? { description: extension.description } : {},
3413
+ html: extension.html,
3414
+ ...extension.script !== void 0 ? { script: extension.script } : {}
3415
+ };
3416
+ };
3085
3417
  var adminUiRoutes = (service, ui) => {
3086
3418
  const shell = () => renderAdminDocument(service);
3087
3419
  const document3 = () => ui?.render ? ui.render({ service, defaultHtml: shell }) : shell();
@@ -3096,7 +3428,8 @@ var adminUiRoutes = (service, ui) => {
3096
3428
  ...panel.description !== void 0 ? { description: panel.description } : {},
3097
3429
  html: panel.html,
3098
3430
  ...panel.script !== void 0 ? { script: panel.script } : {}
3099
- }))
3431
+ })),
3432
+ extensions: (ui?.extensions ?? []).map(describeExtension)
3100
3433
  })
3101
3434
  };
3102
3435
  };
@@ -3773,7 +4106,7 @@ var stateAdminRoutes = (routes) => {
3773
4106
  };
3774
4107
 
3775
4108
  // ../core/dist/version.js
3776
- var PACKAGE_VERSION = true ? "1.6.0" : UNRELEASED_VERSION;
4109
+ var PACKAGE_VERSION = true ? "1.7.0" : UNRELEASED_VERSION;
3777
4110
 
3778
4111
  // ../core/dist/webhooks.js
3779
4112
  var json4 = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
@@ -5143,4 +5476,4 @@ export {
5143
5476
  apiKeyCredential,
5144
5477
  PlaneAPI
5145
5478
  };
5146
- //# sourceMappingURL=chunk-X3BPHZRL.js.map
5479
+ //# sourceMappingURL=chunk-4YH7SZC3.js.map