From 2a6195d0601b6c1008e877f97527a43eb411a449 Mon Sep 17 00:00:00 2001 From: brettchien Date: Sat, 15 Aug 2026 22:19:59 +0800 Subject: [PATCH] feat(desktop): Config tab to pin the oab-mcp target + reload core (UI) The UI half for #60: a Config tab (cluster / AWS profile / region) that reads the persisted target via `mcp_target_get`, and on save calls `mcp_target_set` (persist + reload the core onto the new hermetic env), then refreshes the roster. Browser build disables the form. Ports #10's UI onto #60's provider-tagged `McpTarget` shape ({ provider: "ecs", cluster, profile?, region? }). Verified locally: `tsc --noEmit && vite build` clean. Co-Authored-By: Claude Opus 4.8 --- console/index.html | 45 ++++++++++++++++++++ console/src/config.ts | 94 ++++++++++++++++++++++++++++++++++++++++++ console/src/main.ts | 4 ++ console/src/styles.css | 60 +++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 console/src/config.ts diff --git a/console/index.html b/console/index.html index 05ef879..34eb947 100644 --- a/console/index.html +++ b/console/index.html @@ -50,9 +50,54 @@
+
diff --git a/console/src/config.ts b/console/src/config.ts new file mode 100644 index 0000000..f1c7177 --- /dev/null +++ b/console/src/config.ts @@ -0,0 +1,94 @@ +// Config tab: read the current oab-mcp target from the backend, let the user pin +// cluster / profile / region, and on save persist + reload the core onto it. +// Only meaningful inside the Tauri shell; the browser build disables the form. +// +// The backend target is provider-tagged (`McpTarget`, config.rs). Today the only +// variant is ECS, so the form maps 1:1 to `{ provider: "ecs", cluster, profile, +// region }`; a future provider would add its own fields/section. + +export interface EcsTarget { + provider: "ecs"; + cluster: string; + profile?: string | null; + region?: string | null; +} +export type McpTarget = EcsTarget; + +type Invoke = (cmd: string, args?: Record) => Promise; + +function tauriInvoke(): Invoke | null { + const t = (globalThis as { __TAURI__?: { core?: { invoke?: Invoke } } }).__TAURI__; + return t?.core?.invoke ?? null; +} + +// Tauri command rejections arrive as plain strings, not Error objects. +function errText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +export interface ConfigHooks { + /** Fired with the target loaded from disk at startup. */ + onLoaded?: (t: McpTarget) => void; + /** Fired after a successful save + core reload. */ + onSaved?: (t: McpTarget) => void; +} + +export function initConfigTab(hooks: ConfigHooks = {}): void { + const form = document.getElementById("config-form") as HTMLFormElement | null; + const profile = document.getElementById("cfg-profile") as HTMLInputElement | null; + const region = document.getElementById("cfg-region") as HTMLInputElement | null; + const cluster = document.getElementById("cfg-cluster") as HTMLInputElement | null; + const save = document.getElementById("cfg-save") as HTMLButtonElement | null; + const status = document.getElementById("cfg-status"); + if (!form || !profile || !region || !cluster) return; + + const setStatus = (msg: string, cls = ""): void => { + if (status) { + status.textContent = msg; + status.className = cls ? `config-status ${cls}` : "config-status"; + } + }; + + const invoke = tauriInvoke(); + if (!invoke) { + // Browser build — no core to configure. + setStatus("browser build — config unavailable"); + for (const el of form.querySelectorAll( + "input, button", + )) { + el.disabled = true; + } + return; + } + + // Populate the form from the persisted (or env-seeded) target. + invoke("mcp_target_get") + .then((t) => { + cluster.value = t.cluster ?? ""; + profile.value = t.profile ?? ""; + region.value = t.region ?? ""; + hooks.onLoaded?.(t); + }) + .catch((e) => setStatus(`load failed: ${errText(e)}`, "err")); + + form.addEventListener("submit", async (ev) => { + ev.preventDefault(); + const target: McpTarget = { + provider: "ecs", + cluster: cluster.value.trim() || "oab", + profile: profile.value.trim() || null, + region: region.value.trim() || null, + }; + if (save) save.disabled = true; + setStatus("saving & reloading core…"); + try { + await invoke("mcp_target_set", { target }); + setStatus("saved — core reloaded", "ok"); + hooks.onSaved?.(target); + } catch (e) { + setStatus(`save failed: ${errText(e)}`, "err"); + } finally { + if (save) save.disabled = false; + } + }); +} diff --git a/console/src/main.ts b/console/src/main.ts index 7fbc72a..d3c8e3e 100644 --- a/console/src/main.ts +++ b/console/src/main.ts @@ -1,4 +1,5 @@ import { defaultSource } from "./source"; +import { initConfigTab } from "./config"; import { renderRoster, renderIdentity, @@ -750,6 +751,9 @@ async function boot(): Promise { note("info", `app: polling cluster "${activeCluster}" every ${POLL_MS / 1000}s`); setupUpdater(); await startCore(); + // Config tab: pin the oab-mcp target (cluster/profile/region → hermetic env); + // on save the backend reloads the core, so refresh the roster after. + initConfigTab({ onSaved: () => void tick() }); void refreshConfig(); void refreshIdentity(); void refreshRemote(); diff --git a/console/src/styles.css b/console/src/styles.css index c6e83d9..5cdedfa 100644 --- a/console/src/styles.css +++ b/console/src/styles.css @@ -810,3 +810,63 @@ button.act:disabled { border-color: var(--s-unhealthy); color: var(--s-unhealthy); } + +/* Config tab — pin the oab-mcp target (cluster / profile / region). */ +.pane.config { + padding: 16px; + overflow: auto; +} +.config-form { + display: flex; + flex-direction: column; + gap: 12px; + max-width: 420px; +} +.config-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + color: var(--muted); +} +.config-form input { + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg); + color: var(--text); + font: inherit; +} +.config-actions { + display: flex; + align-items: center; + gap: 12px; +} +.config-actions button { + padding: 6px 12px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--text); + cursor: pointer; +} +.config-actions button:disabled { + opacity: 0.5; + cursor: default; +} +.config-status { + font-size: 12px; + color: var(--muted); +} +.config-status.ok { + color: var(--ok); +} +.config-status.err { + color: var(--s-unhealthy); +} +.config-hint { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--muted); +}