Skip to content

feat: admin model info CRUD (provider/price/context/vision) (v0.6.4) - #97

Merged
argszero merged 1 commit into
mainfrom
feat/admin-models-crud
Aug 19, 2026
Merged

argszero merged 1 commit into
mainfrom
feat/admin-models-crud

Conversation

@argszero

Copy link
Copy Markdown
Owner

Summary

Admins can now manage model information (provider, model, price, context length, max output, vision, cache-hit price) with full CRUD (host rant 2026-08-19T20:40:29, project: aitokenpool).

Changes:

  • db (migration v7, idempotent ensure_column): models gains context_length, max_output, vision, cache_hit_input_per_m; seed_models now writes them from data/models.example.json (existing context_window kept, aligned on write)
  • API (admin-only, 403 otherwise): GET|POST /api/admin/models + PATCH|DELETE /api/admin/models/:id — validation (non-empty provider/model, non-negative prices, currency ∈ {USD,CNY}, vision ∈ {0,1}), (provider, model) uniqueness → 409, direct delete (per rant: calls for a deleted model bill at 0)
  • Market: GET /api/models now returns context_length / max_output / vision / cache_hit_input_per_m (real values for expandable market rows)
  • UI: new admin “模型管理” tab — search, inline add/edit form (reuses dept-form pattern), inline delete confirm; zh+en i18n; zero-mock load-error retry

Related Issue

Rant: 2026-08-19T20:40:29.994555+08:00 (管理员模型信息 CRUD — 厂商/模型/价格/上下文/读图等字段)

Tests

  • cargo test — 115/115 pass (2 new: admin_models_crud_full_cycle, market_models_include_new_fields)
  • cargo fmt --check clean
  • cargo clippy --all-targets -- -D warnings clean
  • node --check app.js / i18n.js
  • live smoke on 18080: login → list (10 seeded, new fields) → create (id=11) → patch → delete → market fields

…in UI tab (v0.6.4)

Rant 2026-08-19T20:40:29: admins manage model info (provider/model/
price/context/vision) with full CRUD.

- db: SCHEMA_VERSION 7; models + context_length/max_output/vision/
  cache_hit_input_per_m (ensure_column, idempotent); seed_models writes
  them from models.example.json (context_window stays aligned)
- api: GET|POST /api/admin/models + PATCH|DELETE /api/admin/models/:id
  (admin-only 403; (provider,model) uniqueness 409; validation 400;
  direct delete → 0 billing per rant)
- market: GET /api/models now includes context_length/max_output/
  vision/cache_hit_input_per_m (real values for expandable rows)
- ui: admin '模型管理' tab — search / inline add-edit form / inline
  delete confirm; zh+en i18n; zero-mock load-error retry
- tests: admin_models_crud_full_cycle (403/409/400/CRUD) +
  market_models_include_new_fields; 115/115 pass + clippy + fmt +
  node + live smoke (list/create/patch/delete on 18080)
@argszero
argszero merged commit 98a7a9b into main Aug 19, 2026
1 check passed
argszero added a commit that referenced this pull request Sep 13, 2026
## Summary

Two hand-written rosters in `ui/js/app.js` enumerated the "live" containers by id, and **both omitted the admin models table `#model-body`** — which was added later (PR #97) while the rosters date from PR #69/#79:

1. **`bindLiveRetry("<id>", fn)`** (13 entries, bound once at `DOMContentLoaded`) attached the load-error 重试 button of each container. `#model-body` was never registered, so `renderAdminModels()` rendered a 重试 button that **did nothing when clicked**. The smoking gun is the renderer itself: `loadErrorHtml(emptyLabel, retryFn, retryLabel)` declared a `retryFn` parameter and **never used it** — the renderer was always meant to hand its loader over, and the roster was the only thing wiring it up.
2. **`KBD_TABLE_IDS`** listed the tables reachable by ↑/↓/Enter. `#model-body` was absent, so its rows could not be activated (no `.row-active`) while all seven sibling tables could.

The fix **removes both rosters instead of extending them** — a roster that must be maintained by hand is the defect, not the one missing entry. Both containers are now derived from the DOM, so no future table can be forgotten:

| | Before | After |
|---|---|---|
| retry wiring | `bindLiveRetry(id, fn)` ×13 + per-container `addEventListener` | `setLiveError(container, html, loader)` — the renderer hands over its loader; one-time container delegation (`WeakMap`/`WeakSet`) dispatches `[data-live-retry]` |
| kbd container | `KBD_TABLE_IDS.indexOf(tb.id) >= 0` | `kbdTbodyOf(t) = t.closest("tbody")`, via a single **document-level** click delegation |

Runtime evidence (a jsdom probe booting the real `index.html` + the four scripts, only `fetch` stubbed so the admin endpoints 500):

| check | pre-change | post-change |
|---|---|---|
| `#dept-body` retry re-requests (control) | 5 | 5 |
| `#model-body` retry re-requests (test) | **0** | 5 |
| `#model-body` second retry click | **0** | 5 |
| `#dept-body` row click activates (control) | true | true |
| `#model-body` row click activates (test) | **false** | true |
| raise-requests rows activate (declared side effect) | **false** | true |
| a run-time-injected table's rows activate (no roster can cover it) | **false** | true |
| **verdict** | **DEFECT — 5/7 failed** | **OK — 7/7** |

## Related Issue

None — the repository carries no open issues. Found by reconciling the two enumerated rosters against the tables that actually exist.

## Changes

- [x] `ui/js/app.js`
  - `setLiveError(container, html, loader)` replaces the deleted `bindLiveRetry`; the dead `retryFn` parameter is dropped from `loadErrorHtml`. All **12** `loadErrorHtml`/`loadErrorRow` call sites now pass their loader through it.
  - `KBD_TABLE_IDS` deleted; the keyboard container is derived from the event target (`kbdTbodyOf` = `closest("tbody")`) and the row/table click delegations are merged into a single document-level listener.
  - `kbdRows`/`kbdContainerFrom` treat a container that has been rebuilt (`isConnected === false`) as "no active table" instead of highlighting rows that are gone.
- [x] `ui/index.html` — cache-bust `js/app.js?v=20260913-2` → `-3`.
- [x] `ui/README.md` — the keyboard-nav and degradation-pattern sections named both deleted rosters; they now describe the DOM-derived contract.
- [ ] 涉及配置/数据结构的改动已同步示例文件 — **N/A**: no config or data-structure change.

**Declared side effect** (a consequence of "every data table is navigable"): the JS-built raise-requests table now has keyboard row navigation. It was outside the old roster *and* passed `null` for the retry callback, so its degraded state had no 重试 button at all; it now reloads through `loadAdmin()`.

## Tests

- [x] `cargo test` 全部通过 — **190 passed / 0 failed**, i.e. **unchanged** from `main` (this change is JS + docs only; the count staying put is the evidence that no Rust behaviour moved).
- [x] `cargo fmt --check` 通过
- [x] `cargo clippy --all-targets -- -D warnings` 通过
- [ ] 新增/更新了单元测试 — **N/A**: `ui/` carries no test harness in-repo (the probes live outside it), and the change's assertion is the jsdom A/B above: with the pre-change `app.js` the probe is **red (5/7)**, with this branch it is **green (7/7)**, and the `#dept-body` control passes in both legs.

## Checklist

- [x] 分支命名符合约定 — `fix/ui-live-container-rosters`
- [x] Commit message 使用 Conventional Commits 格式 — `fix(ui): derive the live containers from the DOM, not from rosters`
- [x] 单一职责,改动最小化 — one defect class (enumerated live containers), no opportunistic refactor; `node --check ui/js/app.js` clean.
argszero added a commit that referenced this pull request Sep 13, 2026
ui/ writes each table's column count by hand in several places — the <thead>,
the empty-state emptyRow(N, …) colspan, the load-failure loadErrorRow(N, …)
colspan, and the number of <td> in the row template. Nothing fails at runtime
when they disagree: a short colspan just paints the row narrow, a long one is
silently clipped, and the empty/error row is only visible when the list is
empty or the request failed. That is how #97 happened (adding an 8th column to
the admin models table left the empty state at colspan=7); #162 only fixed the
symptom by hand.

src/table_gate.rs is a test-only gate in the same shape as catalog_gate.rs and
i18n_pack.rs (#[cfg(test)] in main.rs, zero new dependencies, no regex, no JS
execution, no browser). It reads ui/index.html and ui/js/app.js as text and
asserts, for every <tbody> table:

  - positive control: exactly the seven registered tables are found, each with
    its true <thead> column count (a scanner bug that returns an empty set fails
    here instead of letting the set assertions pass vacuously);
  - emptyRow(N) colspan == the table's column count;
  - loadErrorRow(N) colspan == the table's column count;
  - the row template has exactly one <td> per column, with whole-row colspan
    cells (the marketplace detail row) excluded;
  - the only literal colspan is that detail row, and index.html contains none;
  - the transactions table derives its colspan from columns.length instead of
    hard-coding a number;
  - <tbody> containers receive <tr>-shaped rows (loadErrorRow) and never a bare
    <div> — the browser hoists a bare <div> out of the table and the retry
    button then loses setLiveError's container-level delegation.

The assertions are split per dimension so a single failure names the dimension
that drifted. Site-to-container attribution is positional (the nearest preceding
$("#id").innerHTML assignment / setLiveError($("#id"), …)), not a JS parse.

Tests: 9 new; cargo test 226 -> 235, fmt and clippy clean.

A/B: six mutations, each applied alone and restored byte-identically (md5
verified), produce five distinct red sets — empty colspan, thead column count,
row-template cell count, error-row colspan, and the literal/dynamic colspan pair.
An 8th thead column and an unregistered new table both trip the positive control
(same assertion, different inputs — recorded honestly). The unmutated tree is
green on all six legs, as a live control.
argszero added a commit that referenced this pull request Sep 13, 2026
…ow number (#227)

Three tables rebuild their list with .filter() and then tag every row button with
the index *inside the searched list*, while the handlers resolve that index
against the full cached array:

  #api-keys   copyKey / renameKey / deleteKey   -> Live.apiKeys[i]
  #model-body editModelRow / deleteModel        -> Live.adminModels[i]
  #dept-body  openDeptForm / deleteDept         -> Live.departments[i]

So as soon as the search box hides one earlier row, a button acts on a different
record than the row it sits in: the delete removed the wrong API key / model /
department, copy handed out another key's secret, and the edit form opened with
another record's values.

Both correct idioms already exist in the same file — #emp-body tags rows with
users.indexOf(u) and #ops-body with u.id — these three tables were simply never
updated when their search boxes were added (#79, #80, #97).

- api-keys: carry the cached index through the mapping, add data-key-row and
  resolve the row by that locator instead of nth-child
- model-body / dept-body: derive the locator with indexOf, mirroring #emp-body
- bump the app.js cache-bust
argszero added a commit that referenced this pull request Sep 13, 2026
`#model-confirm` is wired through a click listener that already applies the busy
state -- `addEventListener("click", (e) => withLoading(e.currentTarget, confirmModel))`
-- and `confirmModel` then wrapped its own request in a *second* `withLoading`.
`withLoading` opens with `if (!btn || btn.dataset.loading) return;`, and the outer
wrapper has already set `btn.dataset.loading = "1"` when the click was dispatched,
so the inner call returns immediately: the request was never sent. Adding and
editing a model were both dead, and the button silently went back to its idle
label after ~320ms, which is why it looked like nothing happened.

Both wrappers and the listener come from 98a7a9b (#97), the commit that introduced
the admin models tab, so this has never worked.

Fix: drop the inner wrapper and keep the request inline, mirroring its sibling
`confirmDept` (same admin pane, same listener pattern) -- the loading state still
comes from the listener, so double-click protection and the spinner are unchanged.
The other handlers were checked: `confirmTopup` / `confirmRaise` / `confirmDept`
call the API directly, while `commitNewKey` owns its `withLoading` because its
listener does not wrap it. `confirmModel` was the only one with both.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant