Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions docs/superpowers/plans/2026-04-30-admin-models-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Models & Providers Page — Plan 5c

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development.

**Goal:** Replace the `models` route stub with a provider-focused view of the existing mapping data. No new backend — pure client-side aggregation of `/api/mappings`.

**Architecture:** Single new page module `src/admin-ui/pages/models-and-providers.ts`. Fetches `/api/mappings`, builds a summary card + per-mapping table. Honest scope: model identifiers themselves live in `WORKFLOW.md` / `PLANNING.md` front matter in each target repo; we don't surface those (would require GitHub API fan-out + would add rate-limit pressure). The page documents this in an info banner.

**Branching:** `admin-overhaul-5c-models` off `admin-overhaul`.

---

## File Structure

```
src/admin-ui/pages/models-and-providers.ts — NEW.
src/admin-ui/pages/stubs.ts — MODIFIED. Remove "models" entry.
src/admin-ui/index.ts — MODIFIED. Inject + script.
src/admin-ui/__tests__/models-and-providers.test.ts — NEW. Structural tests.
```

No backend changes; no helper modules.

---

## Task 1: Page module

**File:** `src/admin-ui/pages/models-and-providers.ts`

### Markup

```html
<section data-page="models" hidden>
<header class="page-header">
<div class="page-header-left">
<h1 class="page-title">Models & providers</h1>
<div class="page-subtitle" id="mp-subtitle">—</div>
</div>
<div class="page-header-actions">
<button class="btn btn-sm" onclick="loadModelsAndProviders()">↻ Refresh</button>
</div>
</header>
<div class="page-body">
<div id="mp-error" class="alert fail" hidden></div>

<div class="alert info">
<div style="flex:1">
<div class="alert-title">Where models are configured</div>
<div class="alert-desc">Model identifiers live in each target repo's <span class="mono">WORKFLOW.md</span> (and <span class="mono">PLANNING.md</span>) front matter, not in the orchestrator. This page summarizes the <em>provider</em> and <em>region</em> chosen per project. Edit the model itself in the target repo.</div>
</div>
</div>

<div class="kpi-grid" id="mp-kpis" hidden>
<div class="kpi"><div class="kpi-label">Projects</div><div class="kpi-value" id="kpi-mp-projects">0</div></div>
<div class="kpi"><div class="kpi-label">Anthropic</div><div class="kpi-value" id="kpi-mp-anthropic">0</div></div>
<div class="kpi"><div class="kpi-label">Bedrock</div><div class="kpi-value" id="kpi-mp-bedrock">0</div></div>
<div class="kpi"><div class="kpi-label">Bedrock regions</div><div class="kpi-value" id="kpi-mp-regions">0</div></div>
</div>

<div class="card">
<div class="card-header">
<h2 class="card-title">Per-project providers</h2>
<div class="card-subtitle">Edit a row's provider on the Projects page.</div>
</div>
<div class="card-body tight">
<table class="tbl">
<thead>
<tr><th>Team</th><th>Repo</th><th>Provider</th><th>Region</th><th>Planning</th><th>Runner</th></tr>
</thead>
<tbody id="mp-rows"></tbody>
</table>
<div id="mp-empty" class="hidden text-tertiary" style="padding:12px">No projects configured. Add one on the Projects page.</div>
</div>
</div>
</div>
</section>
```

### Script (IIFE)

- `async function loadModelsAndProviders()`:
1. Hide error.
2. Fetch `/api/mappings`. On failure: show error alert, hide KPIs, hide empty state, clear rows, set subtitle to '—'. Return.
3. Otherwise: parse, render.
- `renderKpis(mappings)`:
- `projects = Object.keys(mappings).length`
- `anthropic = count where provider === 'anthropic'`
- `bedrock = count where provider === 'bedrock'`
- `regions = unique non-empty awsRegion values from bedrock mappings, count distinct`
- Set the four `kpi-mp-*` element textContents. Unhide the grid.
- `renderRows(mappings)`:
- Sort entries by teamKey ascending.
- One row per entry. Cells:
- Team: `<span class="mono">${esc(teamKey)}</span>`
- Repo: `<span class="mono">${esc(owner)}/${esc(repo)}</span>`
- Provider: `<span class="badge ${kind}">${esc(provider)}</span>` where anthropic→info, bedrock→warn.
- Region: if bedrock and awsRegion → `<span class="mono text-secondary">${esc(awsRegion)}</span>`; if bedrock without region → `<span class="badge fail">missing</span>`; else `<span class="text-tertiary">—</span>`.
- Planning: `<span class="badge ${planningEnabled ? 'success' : 'neutral'}">${planningEnabled ? 'enabled' : 'disabled'}</span>`. Add `· auto-approve` text-tertiary if `autoApprovePlans` is true.
- Runner: `<span class="badge ${execKind}">${esc(executionMode)}</span>` where github-actions→info, fly-machines→success.
- Toggle `#mp-empty.hidden` based on count.
- `renderSubtitle(count)`: `${count} project${count === 1 ? '' : 's'} configured`. Or '—' on error.
- `window.loadModelsAndProviders = loadModelsAndProviders;`
- `window.registerPage('models', () => { loadModelsAndProviders(); setInterval(loadModelsAndProviders, 60000); });`

`const`/`let` only. `window.api`/`window.esc` only. Uses the existing `/api/mappings` endpoint.

Commit: `feat(admin): add models-and-providers page module`.

---

## Task 2: Wire + remove stub

- `src/admin-ui/index.ts`: import `modelsAndProvidersHtml` / `modelsAndProvidersScript`, inject into shell + script tag.
- `src/admin-ui/pages/stubs.ts`: remove ONLY the `models` entry from the stubs array.

`npm run typecheck && npm test` — pass.

Commit: `feat(admin): wire models-and-providers page, remove its stub`.

---

## Task 3: Structural tests

**File:** `src/admin-ui/__tests__/models-and-providers.test.ts`

Standard 5-test pattern:
1. Required ids: `mp-subtitle`, `mp-error`, `mp-kpis`, `mp-rows`, `mp-empty`, `kpi-mp-projects`, `kpi-mp-anthropic`, `kpi-mp-bedrock`, `kpi-mp-regions`.
2. Registers `'models'` route + exposes `loadModelsAndProviders` on window.
3. Calls `/api/mappings` (note: not `/api/models` — anti-test against accidentally inventing a new endpoint).
4. No bare `api(`/`esc(`.
5. No `var`.

Commit: `test(admin): structural tests for models-and-providers page module`.

---

## Risks

- **Bedrock region count interpretation:** "Bedrock regions" KPI shows the count of distinct regions used across bedrock mappings. If two bedrock mappings use `us-west-2`, the KPI shows 1, not 2. Documented behavior.
- **Mapping with provider=bedrock but no awsRegion:** the backend should reject this on save, but legacy rows could exist. Render "missing" badge so the operator can fix it.
- **Sort by teamKey only:** stable enough for the typical handful-of-projects scale.
26 changes: 26 additions & 0 deletions src/admin-ui/__tests__/models-and-providers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { modelsAndProvidersHtml, modelsAndProvidersScript } from "../pages/models-and-providers.js";

describe("models-and-providers page", () => {
it("declares the expected ids", () => {
for (const id of ["mp-subtitle", "mp-error", "mp-kpis", "mp-rows", "mp-empty", "kpi-mp-projects", "kpi-mp-anthropic", "kpi-mp-bedrock", "kpi-mp-regions"]) {
expect(modelsAndProvidersHtml).toContain(`id="${id}"`);
}
});
it("registers the 'models' route and exposes loadModelsAndProviders", () => {
expect(modelsAndProvidersScript).toContain("window.registerPage('models'");
expect(modelsAndProvidersScript).toContain("window.loadModelsAndProviders = loadModelsAndProviders");
});
it("calls /api/mappings (no new endpoint)", () => {
expect(modelsAndProvidersScript).toContain("/api/mappings");
expect(modelsAndProvidersScript).not.toMatch(/\/api\/(models|providers|model-providers)\b/);
});
it("uses window.api/window.esc only", () => {
const stripped = modelsAndProvidersScript.replace(/window\.api\(/g, "").replace(/window\.esc\(/g, "");
expect(stripped).not.toMatch(/\bapi\(/);
expect(stripped).not.toMatch(/\besc\(/);
});
it("uses const/let, not var", () => {
expect(modelsAndProvidersScript).not.toMatch(/\bvar\s+\w/);
});
});
4 changes: 3 additions & 1 deletion src/admin-ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { pullsHtml, pullsScript } from "./pages/pulls.js";
import { blockersHtml, blockersScript } from "./pages/blockers.js";
import { customizationsHtml, customizationsScript } from "./pages/customizations.js";
import { pipelinesAndStepsHtml, pipelinesAndStepsScript } from "./pages/pipelines-and-steps.js";
import { modelsAndProvidersHtml, modelsAndProvidersScript } from "./pages/models-and-providers.js";
import { stubsHtml } from "./pages/stubs.js";
import { drawerHtml, drawerScript } from "./drawer.js";
import { stepperHtml, stepperScript } from "./stepper.js";
Expand Down Expand Up @@ -47,6 +48,7 @@ const shell = `<div id="admin-page" class="app-shell hidden">
${blockersHtml}
${customizationsHtml}
${pipelinesAndStepsHtml}
${modelsAndProvidersHtml}
${stubsHtml}
</main>
</div>`;
Expand All @@ -63,7 +65,7 @@ const body = `<body>
${shell}
${drawerHtml}
${stepperHtml}
<script>${themeJs}${authJs}${routerJs}${overviewScript}${settingsScript}${projectsScript}${pipelinesScript}${reaperScript}${sessionsScript}${auditScript}${issuesScript}${pullsScript}${blockersScript}${customizationsScript}${pipelinesAndStepsScript}${drawerScript}${stepperScript}</script>
<script>${themeJs}${authJs}${routerJs}${overviewScript}${settingsScript}${projectsScript}${pipelinesScript}${reaperScript}${sessionsScript}${auditScript}${issuesScript}${pullsScript}${blockersScript}${customizationsScript}${pipelinesAndStepsScript}${modelsAndProvidersScript}${drawerScript}${stepperScript}</script>
</body></html>`;

export const adminHtml = head + body;
143 changes: 143 additions & 0 deletions src/admin-ui/pages/models-and-providers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
export const modelsAndProvidersHtml = `
<section data-page="models" hidden>
<header class="page-header">
<div class="page-header-left">
<h1 class="page-title">Models & providers</h1>
<div class="page-subtitle" id="mp-subtitle">—</div>
</div>
<div class="page-header-actions">
<button class="btn btn-sm" onclick="loadModelsAndProviders()">↻ Refresh</button>
</div>
</header>
<div class="page-body">
<div id="mp-error" class="alert fail" hidden></div>

<div class="alert info">
<div style="flex:1">
<div class="alert-title">Where models are configured</div>
<div class="alert-desc">Model identifiers live in each target repo's <span class="mono">WORKFLOW.md</span> (and <span class="mono">PLANNING.md</span>) front matter, not in the orchestrator. This page summarizes the <em>provider</em> and <em>region</em> chosen per project. Edit the model itself in the target repo.</div>
</div>
</div>

<div class="kpi-grid" id="mp-kpis" hidden>
<div class="kpi"><div class="kpi-label">Projects</div><div class="kpi-value" id="kpi-mp-projects">0</div></div>
<div class="kpi"><div class="kpi-label">Anthropic</div><div class="kpi-value" id="kpi-mp-anthropic">0</div></div>
<div class="kpi"><div class="kpi-label">Bedrock</div><div class="kpi-value" id="kpi-mp-bedrock">0</div></div>
<div class="kpi"><div class="kpi-label">Bedrock regions</div><div class="kpi-value" id="kpi-mp-regions">0</div></div>
</div>

<div class="card">
<div class="card-header">
<h2 class="card-title">Per-project providers</h2>
<div class="card-subtitle">Edit a row's provider on the Projects page.</div>
</div>
<div class="card-body tight">
<table class="tbl">
<thead>
<tr><th>Team</th><th>Repo</th><th>Provider</th><th>Region</th><th>Planning</th><th>Runner</th></tr>
</thead>
<tbody id="mp-rows"></tbody>
</table>
<div id="mp-empty" class="hidden text-tertiary" style="padding:12px">No projects configured. Add one on the Projects page.</div>
</div>
</div>
</div>
</section>`;

export const modelsAndProvidersScript = `(function () {
async function loadModelsAndProviders() {
const errorEl = document.getElementById('mp-error');
const kpisEl = document.getElementById('mp-kpis');
const emptyEl = document.getElementById('mp-empty');
const rowsEl = document.getElementById('mp-rows');
const subtitleEl = document.getElementById('mp-subtitle');

errorEl.hidden = true;

const res = await window.api('/api/mappings');
if (!res.ok) {
let msg = 'Failed to load mappings';
try {
const data = await res.json();
if (data && data.error) msg += ': ' + data.error;
} catch (_) {}
errorEl.innerHTML = '<div class="alert-title">Failed to load mappings</div><div class="alert-desc">' + window.esc(msg) + '</div>';
errorEl.hidden = false;
kpisEl.hidden = true;
emptyEl.classList.add('hidden');
rowsEl.innerHTML = '';
subtitleEl.textContent = '—';
return;
}

const mappings = await res.json();
renderKpis(mappings);
renderRows(mappings);
const count = Object.keys(mappings).length;
renderSubtitle(count);
}

function renderKpis(mappings) {
const entries = Object.values(mappings);
const total = entries.length;
const anthropic = entries.filter(function (m) { return m.provider !== 'bedrock'; }).length;
const bedrock = entries.filter(function (m) { return m.provider === 'bedrock'; }).length;
const regions = new Set(entries.filter(function (m) { return m.provider === 'bedrock' && m.awsRegion; }).map(function (m) { return m.awsRegion; })).size;

document.getElementById('kpi-mp-projects').textContent = String(total);
document.getElementById('kpi-mp-anthropic').textContent = String(anthropic);
document.getElementById('kpi-mp-bedrock').textContent = String(bedrock);
document.getElementById('kpi-mp-regions').textContent = String(regions);
document.getElementById('mp-kpis').hidden = false;
}

function renderRows(mappings) {
const entries = Object.entries(mappings).sort(function (a, b) { return a[0].localeCompare(b[0]); });
const emptyEl = document.getElementById('mp-empty');
const rowsEl = document.getElementById('mp-rows');

if (entries.length === 0) {
emptyEl.classList.remove('hidden');
} else {
emptyEl.classList.add('hidden');
}

rowsEl.innerHTML = '';
for (const [teamKey, m] of entries) {
let regionCell;
if (m.provider === 'bedrock' && m.awsRegion) {
regionCell = '<td class="mono text-secondary">' + window.esc(m.awsRegion) + '</td>';
} else if (m.provider === 'bedrock') {
regionCell = '<td><span class="badge fail">missing</span></td>';
} else {
regionCell = '<td><span class="text-tertiary">—</span></td>';
}

const planningKind = m.planningEnabled ? 'success' : 'neutral';
const planningLabel = m.planningEnabled ? 'enabled' : 'disabled';
const autoText = (m.planningEnabled && m.autoApprovePlans)
? '<span class="text-tertiary" style="margin-left:6px;font-size:11.5px">· auto-approve</span>'
: '';

const runnerKind = m.executionMode === 'fly-machines' ? 'success' : 'info';

const tr = document.createElement('tr');
tr.innerHTML =
'<td><span class="mono">' + window.esc(teamKey) + '</span></td>' +
'<td><span class="mono">' + window.esc(m.owner) + '/' + window.esc(m.repo) + '</span></td>' +
'<td><span class="badge ' + (m.provider === 'bedrock' ? 'warn' : 'info') + '">' + window.esc(m.provider) + '</span></td>' +
regionCell +
'<td><span class="badge ' + planningKind + '">' + planningLabel + '</span>' + autoText + '</td>' +
'<td><span class="badge ' + runnerKind + '">' + window.esc(m.executionMode) + '</span></td>';
rowsEl.appendChild(tr);
}
}

function renderSubtitle(count) {
const subtitleEl = document.getElementById('mp-subtitle');
subtitleEl.textContent = count + ' project' + (count === 1 ? '' : 's') + ' configured';
}

window.loadModelsAndProviders = loadModelsAndProviders;
window.registerPage('models', function () { loadModelsAndProviders(); setInterval(loadModelsAndProviders, 60000); });
})();`;
1 change: 0 additions & 1 deletion src/admin-ui/pages/stubs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ function stubPage(route: string, title: string, subtitle: string, phase: string,
}

export const stubsHtml = [
stubPage("models", "Models & providers", "Per-step models, provider failover, runner profiles", "Plan 5", "Configure provider chains and per-step model IDs."),
stubPage("channels", "Triggers & channels", "Input triggers + output notifications", "Plan 5", "Linear, webhook, MCP triggers; Slack, Teams, GitHub PR comment channels."),
stubPage("policies", "Policies & risk", "Auto-merge thresholds, risk rubric, CI gates", "Plan 5", "Edge vs stable channels, risk dimensions."),
stubPage("runners", "Runners", "Fly Machines, GitHub Actions, warm pools", "Plan 5", "Per-runner profiles, image overrides, health metrics."),
Expand Down
Loading