Align sized cluster seeds and GET dumps with PVE wire shapes; restyle DATA panel.

- Scale small/large/big seeds (3×50 / 10×1000 / 20×2000) with proportional
  backups, snapshots, HA, replication, Ceph capacity, and OSD totals
  (10 / 100 / 500) plus matching node disks and crush/pg metadata
- Enrich handler responses for apt, certificates, qemu/lxc status, storage,
  SDN, metrics export, and related cluster/node dumps
- Flatten nested body_example fields into PARAMS and sync the request body
  via dotted paths (oVirt-style)
- Restyle DATA controls as size cards with full-width Reset to minimal /
  Refresh stats; unload reloads the minimal cluster
This commit is contained in:
Sergey Antropoff
2026-07-18 08:46:11 +03:00
parent 48df10b17e
commit 0773f721ea
77 changed files with 4870 additions and 855 deletions
+138 -12
View File
@@ -11,7 +11,7 @@ from pathlib import Path
from app.api.openapi import contract_openapi_tag
from app.config import Settings
from app.contracts.examples import path_param_example, schema_example
from app.contracts.examples import path_param_example, schema_example, wire_param_name
from app.contracts.importer import RemoteSourceImporter
from app.contracts.model import Method, Parameter, Snapshot
from app.contracts.normalize import normalize_snapshot
@@ -226,16 +226,25 @@ def _path_param_names(path: str) -> tuple[str, ...]:
return tuple(match.group(1) for match in _PATH_PARAM.finditer(path))
def _parameter_payload(parameter: Parameter) -> dict[str, object]:
def _parameter_payload(parameter: Parameter, *, wire_name: str | None = None) -> dict[str, object]:
schema = parameter.definition
return {
"name": parameter.name,
name = wire_name or parameter.name
typetext = schema.extra.get("typetext")
payload: dict[str, object] = {
"name": name,
"type": schema.type,
"description": schema.description,
"optional": bool(schema.optional),
"enum": list(schema.enum),
"example": schema_example(schema, name=parameter.name),
}
if wire_name and wire_name != parameter.name:
payload["template"] = parameter.name
if isinstance(schema.format, str):
payload["format"] = schema.format
if isinstance(typetext, str) and typetext:
payload["typetext"] = typetext
return payload
def method_payload(
@@ -274,15 +283,20 @@ def method_payload(
"example": path_param_example(name) or name,
}
)
body_fields = [
contract_body_fields = [
_parameter_payload(parameter)
for parameter in method.parameters
if parameter.name not in path_params and "[n]" not in parameter.name
]
# Contract uses foo[n]; the wire form is foo0..fooN — seed the console with foo0.
indexed_fields = [
_parameter_payload(parameter) for parameter in method.parameters if "[n]" in parameter.name
_parameter_payload(parameter, wire_name=wire_param_name(parameter.name))
for parameter in method.parameters
if "[n]" in parameter.name
]
body_example = _body_example(method, path_params)
# Flatten nested scalars from body_example (and nested field examples) into PARAMS.
body_fields = _body_fields_with_nested(contract_body_fields, body_example)
resolved_path = _resolve_path(path, path_fields)
implemented = (
(path, method.verb.upper()) in implemented_methods
@@ -316,21 +330,133 @@ def _resolve_path(path: str, path_fields: list[dict[str, object]]) -> str:
def _body_example(method: Method, path_params: tuple[str, ...]) -> dict[str, object]:
"""Minimal request body: required parameters only, Proxmox wire names/values."""
body: dict[str, object] = {}
for parameter in method.parameters:
if parameter.name in path_params:
continue
if "[n]" in parameter.name:
concrete = parameter.name.replace("[n]", "0")
if not parameter.definition.optional:
body[concrete] = schema_example(parameter.definition, name=concrete)
continue
if parameter.definition.optional:
continue
body[parameter.name] = schema_example(parameter.definition, name=parameter.name)
wire = wire_param_name(parameter.name)
body[wire] = schema_example(parameter.definition, name=parameter.name)
return body
def _leaf_type(value: object) -> str:
if isinstance(value, bool):
return "boolean"
if isinstance(value, int) and not isinstance(value, bool):
return "integer"
if isinstance(value, float):
return "number"
return "string"
def _body_fields_from_example(body_example: dict[str, object] | None) -> list[dict[str, object]]:
"""PARAM inputs derived from body_example, including nested scalar paths."""
if not isinstance(body_example, dict) or not body_example:
return []
inner: object = body_example
# Soft unwrap a single root object envelope (Engine-style), never a scalar/list.
if len(body_example) == 1:
only = next(iter(body_example.values()))
if isinstance(only, dict):
inner = only
if not isinstance(inner, dict):
return []
fields: list[dict[str, object]] = []
def _walk(prefix: str, value: object) -> None:
if isinstance(value, dict):
for key, child in value.items():
path = f"{prefix}.{key}" if prefix else str(key)
_walk(path, child)
return
if isinstance(value, list):
for index, child in enumerate(value):
path = f"{prefix}.{index}" if prefix else str(index)
_walk(path, child)
return
fields.append(
{
"name": prefix,
"type": _leaf_type(value),
"description": prefix,
"optional": True,
"enum": [],
"example": value,
}
)
_walk("", inner)
return fields
def _body_fields_with_nested(
contract_fields: list[dict[str, object]],
body_example: dict[str, object],
) -> list[dict[str, object]]:
"""Merge contract PARAMS with nested scalar paths from body_example."""
example_fields = _body_fields_from_example(body_example)
nested_tops = {
str(item["name"]).split(".", 1)[0] for item in example_fields if "." in str(item["name"])
}
contract_by_name = {str(item["name"]): item for item in contract_fields}
merged: list[dict[str, object]] = []
seen: set[str] = set()
for item in example_fields:
name = str(item["name"])
top = name.split(".", 1)[0]
parent = contract_by_name.get(name) or contract_by_name.get(top)
row = dict(item)
if parent is not None:
row["optional"] = bool(parent.get("optional"))
description = parent.get("description")
if isinstance(description, str) and description:
row["description"] = description
typetext = parent.get("typetext")
if isinstance(typetext, str) and typetext:
row["typetext"] = typetext
fmt = parent.get("format")
if isinstance(fmt, str) and fmt:
row["format"] = fmt
else:
# body_example is required-only — treat uncovered leaves as required.
row["optional"] = False
merged.append(row)
seen.add(name)
for field in contract_fields:
name = str(field["name"])
if name in nested_tops or name in seen:
continue
example = field.get("example")
if isinstance(example, dict | list):
nested = _body_fields_from_example({name: example})
if nested:
for item in nested:
nested_name = str(item["name"])
if nested_name in seen:
continue
row = dict(item)
row["optional"] = bool(field.get("optional"))
description = field.get("description")
if isinstance(description, str) and description:
row["description"] = description
merged.append(row)
seen.add(nested_name)
continue
# Empty object/array examples stay as a top-level PARAM.
merged.append(field)
seen.add(name)
return merged
@lru_cache(maxsize=1)
def default_store_root() -> Path:
return _DEFAULT_STORE
+330 -53
View File
@@ -583,6 +583,123 @@
padding-top: 2px;
}
.demo-size-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-top: 4px;
}
@media (max-width: 900px) {
.demo-size-grid {
grid-template-columns: 1fr;
}
}
.demo-size-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 14px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background:
linear-gradient(165deg, color-mix(in srgb, var(--brand-accent) 8%, var(--surface-raised)) 0%, var(--surface-raised) 55%);
min-height: 168px;
}
.demo-size-card.is-active {
border-color: color-mix(in srgb, var(--brand-accent) 55%, var(--border));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--brand-accent) 25%, transparent);
}
.demo-size-card-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.demo-size-card-title {
margin: 0;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
color: var(--text);
text-transform: capitalize;
}
.demo-size-card-chip {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--brand-accent);
background: color-mix(in srgb, var(--brand-accent) 12%, transparent);
border-radius: 999px;
padding: 3px 8px;
white-space: nowrap;
}
.demo-size-card-metrics {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 10px;
margin: 0;
}
.demo-size-card-metrics div {
display: flex;
flex-direction: column;
gap: 1px;
}
.demo-size-card-metrics dt {
margin: 0;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
}
.demo-size-card-metrics dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.demo-size-card-note {
margin: 0;
font-size: 11px;
line-height: 1.4;
color: var(--muted);
flex: 1;
}
.demo-size-card .btn-demo-load {
width: 100%;
justify-content: center;
margin-top: auto;
}
.demo-toolbar {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.demo-toolbar .btn {
width: 100%;
box-sizing: border-box;
justify-content: center;
text-align: center;
}
.btn-demo-load {
background: var(--brand-accent);
border-color: var(--brand-accent);
@@ -5371,11 +5488,45 @@
function buildDemoDataHtml(data) {
const loaded = Boolean(data?.loaded);
const profile = data?.profile ?? "minimal";
const badgeLabel = loaded ? String(profile) : "Minimal";
const cephPiB = data?.ceph_capacity_pib;
const cephText = cephPiB != null && cephPiB > 0 ? `${cephPiB} PiB` : "—";
const tasksText = data?.tasks != null ? `${data.tasks} / ${data.task_logs ?? 0}` : "—";
const sizes = [
{
id: "small",
label: "Small",
chip: "lab",
hosts: 3,
guests: 50,
osd: 10,
backups: 10,
note: "3 hosts · 50 guests · 10 OSD · HA/replication light",
},
{
id: "large",
label: "Large",
chip: "default",
hosts: 10,
guests: 1000,
osd: 100,
backups: 80,
note: "10 hosts · 1000 guests · 100 OSD · fuller Ceph/HA",
},
{
id: "big",
label: "Big",
chip: "heavy",
hosts: 20,
guests: 2000,
osd: 500,
backups: 160,
note: "20 hosts · 2000 guests · 500 OSD · heavy Ceph/HA",
},
];
const stats = [
["Profile", data?.profile ?? "—", loaded ? "ok" : ""],
["Profile", profile, loaded ? "ok" : ""],
["Cluster", data?.cluster_name ?? "—", ""],
["Nodes", data?.nodes ?? "—", ""],
["QEMU", data?.qemu ?? "—", ""],
@@ -5390,13 +5541,12 @@
return `
<div class="help-demo-panel">
<div class="help-report-head help-demo-head">
<h3>Demo cluster</h3>
<p>Load a realistic enterprise environment into the emulator database for hands-on API exploration.</p>
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${loaded ? "Loaded" : "Empty"}</span>
<h3>Proxmox demo cluster</h3>
<p>Pick a cluster size — inventory (nodes, guests, Ceph, HA, backups, tasks) scales together.</p>
<span class="help-demo-badge ${loaded ? "loaded" : "empty"}">${escapeHtml(badgeLabel)}</span>
</div>
<p class="help-demo-note">
20 nodes · 1000 guests (850 QEMU + 150 LXC) · Ceph 300 OSD (~5 PiB) · backups, logs,
snapshots, pools, HA rules, and multi-level users. Loading replaces current simulation state.
Password for seeded users: <code>secret</code>. Loading replaces current Proxmox state.
</p>
<div class="help-stat-grid">
${stats.map(([label, value, tone]) => `
@@ -5406,9 +5556,30 @@
</div>
`).join("")}
</div>
<div class="help-demo-actions">
<button class="btn btn-sm btn-demo-load" id="btn-demo-load" type="button">Load demo cluster</button>
<button class="btn btn-sm btn-demo-unload" id="btn-demo-unload" type="button">Remove demo data</button>
<div class="demo-size-grid" role="group" aria-label="Cluster sizes">
${sizes.map((s) => {
const active = profile === s.id;
return `
<article class="demo-size-card ${active ? "is-active" : ""}" data-size-card="${s.id}">
<div class="demo-size-card-head">
<h4 class="demo-size-card-title">${escapeHtml(s.label)}</h4>
<span class="demo-size-card-chip">${escapeHtml(s.chip)}</span>
</div>
<dl class="demo-size-card-metrics">
<div><dt>Hosts</dt><dd>${s.hosts}</dd></div>
<div><dt>Guests</dt><dd>${s.guests}</dd></div>
<div><dt>OSD</dt><dd>${s.osd}</dd></div>
<div><dt>Backups</dt><dd>${s.backups}</dd></div>
</dl>
<p class="demo-size-card-note">${escapeHtml(s.note)}</p>
<button class="btn btn-sm btn-demo-load" data-demo-size="${s.id}" type="button">
Load ${escapeHtml(s.label.toLowerCase())}
</button>
</article>`;
}).join("")}
</div>
<div class="demo-toolbar">
<button class="btn btn-sm btn-demo-unload" id="btn-demo-unload" type="button">Reset to minimal</button>
<button class="btn btn-sm" id="btn-demo-refresh" type="button">Refresh stats</button>
</div>
</div>
@@ -5446,35 +5617,45 @@
renderDemoState(await res.json());
}
async function loadDemoData() {
const loadBtn = document.getElementById("btn-demo-load");
if (loadBtn) loadBtn.disabled = true;
async function loadDemoData(size = "small") {
const loadBtns = document.querySelectorAll("[data-demo-size]");
loadBtns.forEach((btn) => { btn.disabled = true; });
setLoading(true);
try {
const res = await fetch("/ui/api/demo/load", { method: "POST" });
const res = await fetch(`/ui/api/demo/load?profile=${encodeURIComponent(size)}`, {
method: "POST",
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.detail || `Load failed: ${res.status}`);
const detail = Array.isArray(body.detail)
? body.detail.map((d) => d.msg || d).join("; ")
: body.detail;
throw new Error(detail || `Load failed: ${res.status}`);
}
renderDemoState(body.summary || {});
if (state.ticket) syncAuthCookie(state.ticket);
refreshOverview();
toast("Demo cluster loaded", "ok");
const seed = body.summary || {};
const guests = (seed.qemu ?? 0) + (seed.lxc ?? 0);
toast(
`Cluster ${seed.profile || size} loaded (${seed.nodes ?? "?"} hosts · ${guests || "?"} guests)`,
"ok",
);
if (!state.ticket) {
toast("Sign in (root@pam / secret) to call API endpoints", "warn");
}
} finally {
if (loadBtn) loadBtn.disabled = false;
loadBtns.forEach((btn) => { btn.disabled = false; });
setLoading(false);
}
}
async function unloadDemoData() {
const confirmed = await showConfirm({
title: "Remove demo data?",
message: "This wipes simulator DB state (demo data and anything created via the API), then reloads a minimal cluster.",
confirmLabel: "Remove demo data",
cancelLabel: "Keep demo data",
title: "Reset to minimal?",
message: "This wipes current simulator DB state (sized demo and anything created via the API), then loads the minimal cluster (1 host · 0 guests).",
confirmLabel: "Reset to minimal",
cancelLabel: "Keep current data",
tone: "danger",
});
if (!confirmed) return;
@@ -5489,7 +5670,7 @@
}
renderDemoState(body.summary || {});
refreshOverview();
toast("Demo data removed", "info");
toast("Reset to minimal cluster", "info");
} finally {
if (unloadBtn) unloadBtn.disabled = false;
setLoading(false);
@@ -5730,17 +5911,21 @@
if (!els.dataPanel || els.dataPanel.dataset.demoBound) return;
els.dataPanel.dataset.demoBound = "1";
els.dataPanel.addEventListener("click", (event) => {
const target = event.target.closest("#btn-demo-load, #btn-demo-unload, #btn-demo-refresh");
if (!target) return;
if (target.id === "btn-demo-load") {
loadDemoData().catch((error) => {
const loadBtn = event.target.closest("[data-demo-size]");
if (loadBtn) {
const size = loadBtn.getAttribute("data-demo-size") || "small";
loadDemoData(size).catch((error) => {
showError(String(error));
toast("Failed to load demo data", "error");
toast("Failed to load cluster data", "error");
});
} else if (target.id === "btn-demo-unload") {
return;
}
const target = event.target.closest("#btn-demo-unload, #btn-demo-refresh");
if (!target) return;
if (target.id === "btn-demo-unload") {
unloadDemoData().catch((error) => {
showError(String(error));
toast("Failed to remove demo data", "error");
toast("Failed to reset to minimal", "error");
});
} else if (target.id === "btn-demo-refresh") {
renderDataPanel().catch((error) => showError(String(error)));
@@ -6281,12 +6466,24 @@
}
container.innerHTML = fields.map((f) => {
const stored = kind === "path" ? state.pathValues[f.name] : state.bodyValues[f.name];
const val = stored ?? f.example ?? "";
const desc = f.description ? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${f.description}</div>` : "";
const fallback = kind === "path" || !f.optional ? formatParamExample(f.example) : "";
const val = stored ?? fallback;
const hint = f.typetext || f.format || "";
const descParts = [];
if (f.description) descParts.push(f.description);
if (hint) descParts.push(`<code style="font-size:10px">${escapeHtml(String(hint))}</code>`);
if (f.template) descParts.push(`<span style="font-size:10px;color:var(--muted)">${escapeHtml(f.template)}${escapeHtml(f.name)}</span>`);
const desc = descParts.length
? `<div style="font-size:11px;color:var(--muted);margin-top:2px">${descParts.join(" · ")}</div>`
: "";
const placeholderExample = formatParamExample(f.example);
const placeholder = f.optional && placeholderExample
? ` placeholder="${placeholderExample.replaceAll('"', "&quot;")}"`
: "";
return `<tr>
<td><code>${f.name}</code>${desc}</td>
<td><code>${escapeHtml(f.name)}</code>${desc}</td>
<td>${formatParamTypeBadges(f)}</td>
<td><input data-field="${f.name}" data-kind="${kind}" value="${String(val).replaceAll('"', "&quot;")}"></td>
<td><input data-field="${escapeAttr(f.name)}" data-kind="${kind}" value="${String(val).replaceAll('"', "&quot;")}"${placeholder}></td>
</tr>`;
}).join("");
container.querySelectorAll("input[data-field]").forEach((input) => {
@@ -6393,9 +6590,11 @@
try {
const parsed = JSON.parse(body || "{}");
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
const { inner } = unwrapBodyExample(parsed);
for (const field of bodyFields) {
if (Object.prototype.hasOwnProperty.call(parsed, field.name)) {
state.bodyValues[field.name] = String(parsed[field.name] ?? "");
const value = getByPath(inner, wireBodyFieldName(field.name));
if (value !== undefined) {
state.bodyValues[field.name] = formatParamExample(value);
}
}
}
@@ -6468,31 +6667,106 @@
return target === "cluster" ? buildClusterUrl(apiPath) : buildEmulatorUrl(apiPath);
}
function wireBodyFieldName(name) {
// Contract templates use foo[n]; wire / body keys are foo0 (catalog already expands).
return String(name || "").includes("[n]") ? String(name).replaceAll("[n]", "0") : String(name || "");
}
function deepCloneJson(value) {
if (value == null) return value;
return JSON.parse(JSON.stringify(value));
}
function getByPath(root, path) {
if (!path) return root;
const parts = String(path).split(".");
let cur = root;
for (const part of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = cur[part];
}
return cur;
}
function setByPath(root, path, value) {
const parts = String(path).split(".");
let cur = root;
for (let i = 0; i < parts.length - 1; i += 1) {
const part = parts[i];
const next = parts[i + 1];
const wantArray = /^\d+$/.test(next);
if (cur[part] == null || typeof cur[part] !== "object") {
cur[part] = wantArray ? [] : {};
}
cur = cur[part];
}
cur[parts[parts.length - 1]] = value;
}
function deleteByPath(root, path) {
const parts = String(path).split(".");
let cur = root;
for (let i = 0; i < parts.length - 1; i += 1) {
if (cur == null || typeof cur !== "object") return;
cur = cur[parts[i]];
}
if (cur && typeof cur === "object") delete cur[parts[parts.length - 1]];
}
function unwrapBodyExample(example) {
if (!example || typeof example !== "object" || Array.isArray(example)) {
return { wrapKey: null, inner: {} };
}
const keys = Object.keys(example);
if (
keys.length === 1
&& example[keys[0]]
&& typeof example[keys[0]] === "object"
&& !Array.isArray(example[keys[0]])
) {
return { wrapKey: keys[0], inner: example[keys[0]] };
}
return { wrapKey: null, inner: example };
}
function formatParamExample(value) {
if (value == null) return "";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}
function syncBodyFromFields({ syncVisibility = true } = {}) {
if (!state.method) return;
const inputs = [...(els.bodyFields?.querySelectorAll("input[data-field]") || [])];
const body = {};
const example = state.method.body_example;
const { wrapKey, inner } = unwrapBodyExample(example);
const root = deepCloneJson(inner) || {};
let touched = false;
inputs.forEach((input) => {
const name = input.dataset.field;
const name = wireBodyFieldName(input.dataset.field);
if (!name) return;
const raw = input.value.trim();
if (!raw) return;
if (raw === "true" || raw === "false") body[name] = raw === "true";
else if (/^-?\d+$/.test(raw)) body[name] = Number(raw);
else if (/^-?\d+\.\d+$/.test(raw)) body[name] = Number(raw);
else body[name] = raw;
});
// If PARAM fields are empty, fall back to method body_example so the editor
// updates when switching verbs / endpoints.
if (!Object.keys(body).length) {
const example = state.method.body_example;
if (example && typeof example === "object" && !Array.isArray(example)) {
for (const [key, value] of Object.entries(example)) {
if (value === undefined || value === null || value === "") continue;
body[key] = value;
}
const existing = getByPath(root, name);
if (!raw && existing === undefined) return;
touched = true;
if (!raw) {
deleteByPath(root, name);
return;
}
let value = raw;
if (raw === "true" || raw === "false") value = raw === "true";
else if (/^-?\d+$/.test(raw)) value = Number(raw);
else if (/^-?\d+\.\d+$/.test(raw)) value = Number(raw);
setByPath(root, name, value);
});
// Empty PARAM inputs → keep full body_example; edits merge into nested paths.
if (!touched && example && typeof example === "object" && !Array.isArray(example)) {
els.body.value = Object.keys(example).length ? JSON.stringify(example, null, 2) : "";
updateBodyHighlight();
if (syncVisibility) updateBodyPaneVisibility(state.method);
return;
}
// Keep empty object out of the editor — do not show `{}`.
const body = wrapKey ? { [wrapKey]: root } : root;
els.body.value = Object.keys(body).length ? JSON.stringify(body, null, 2) : "";
updateBodyHighlight();
if (syncVisibility) {
@@ -6547,7 +6821,10 @@
const bodyFields = [...(d.body_fields || []), ...(d.indexed_fields || [])];
state.bodyValues = {};
for (const f of bodyFields) {
state.bodyValues[f.name] = resolveParamValue(f.name, storedParams?.bodyValues, f.example);
// Seed required fields from the contract/example; leave optional empty
// (placeholder shows the typetext-shaped example until the user fills it).
const fallback = f.optional ? "" : formatParamExample(f.example);
state.bodyValues[f.name] = resolveParamValue(f.name, storedParams?.bodyValues, fallback);
}
els.bodyFieldsWrap.hidden = !bodyFields.length;
renderParamRows(els.bodyFields, bodyFields, "body");
+18 -2
View File
@@ -161,7 +161,23 @@ async def ui_demo_state(request: Request) -> JSONResponse:
@router.post("/ui/api/demo/load", include_in_schema=False)
async def ui_demo_load(request: Request) -> JSONResponse:
pool = _database_pool(request)
profile = build_profile("demo-cluster")
requested = "small"
if request.query_params.get("profile"):
requested = str(request.query_params.get("profile"))
else:
try:
body = await request.json()
except Exception:
body = None
if isinstance(body, dict) and body.get("profile"):
requested = str(body["profile"])
allowed = {"small", "large", "big"}
if requested not in allowed:
raise HTTPException(
status_code=400,
detail=f"unknown cluster profile '{requested}' (expected: small, large, big)",
)
profile = build_profile(requested)
async with pool.acquire() as connection:
await apply_seed(connection, profile)
summary = await simulation_state_summary(connection)
@@ -179,7 +195,7 @@ async def ui_demo_unload(request: Request) -> JSONResponse:
summary = await simulation_state_summary(connection)
except Exception as error:
raise HTTPException(
status_code=500, detail=f"failed to remove demo data: {error}"
status_code=500, detail=f"failed to reset to minimal cluster: {error}"
) from error
return JSONResponse({"ok": True, "profile": profile.name, "summary": summary})