Approve UI mockups and write the full implementation plan

This commit is contained in:
Emil
2026-08-02 23:30:23 +03:00
parent f8ff0956b9
commit 41cae546ff
3 changed files with 782 additions and 161 deletions
+150 -161
View File
@@ -1,192 +1,181 @@
# План: Coordinator Admin UI и Worker Setup UI
# План реализации: Coordinator Admin UI и Worker Setup UI
> Статус: план. Текущий `/ui` — демонстрационный control room (джобы, ворклоады, docs).
> Два новых UI решают две разные задачи и живут в **разных бинарниках**:
> админка кластера — в `coordinator`, визард установки воркера — в
> `worker-agent` (для тех, кто ставит ТОЛЬКО воркер и не имеет координатора
> на своей машине).
> Статус: **в работе**. Визуал утверждён — мокапы:
> [`ui-mockups/coordinator-admin.html`](ui-mockups/coordinator-admin.html),
> [`ui-mockups/worker-setup.html`](ui-mockups/worker-setup.html).
> Два UI живут в **разных бинарниках**: админка кластера — в `coordinator`
> (`/ui/admin`), визард установки воркера — в `worker-agent`
> (`worker-agent setup`, локально на 127.0.0.1).
## 1. Цель
1. **Coordinator Admin UI** (`/ui/admin`) — полноценная консоль админа
распределённого кластера: система, джобы, воркеры, юзеры/роли,
ворклоады, настройки, метрики. Живёт в бинарнике `coordinator`.
2. **Worker Setup UI** — локальный визард **в бинарнике `worker-agent`**
(`worker-agent setup` → браузер на 127.0.0.1): пошаговое подключение
машины к координатору — URL/токен/ключ, рабочий каталог, проверка
соединения, запуск и статус воркера. Не требует установки координатора
и работает на машине, где есть только воркер.
1. **Coordinator Admin UI** — консоль админа распределённого кластера:
System, Jobs, Workers, Users & keys, Workloads, Metrics, Settings.
Реальные данные, никакой симуляции; только роль `admin`.
2. **Worker Setup UI** — локальный визард в бинарнике `worker-agent` для
машины, на которой стоит только воркер: подключение (URL + токен/ключ),
параметры машины, preflight-проверка, запуск/остановка, статус и лог.
## 2. Принципы
## 2. Что уже есть в коде (фундамент)
- **Coordinator UI** (админка кластера): `/ui/admin/*` — только `admin`,
bounded read model, браузер не касается БД, оба движка (sqlite/postgres)
одинаково, секреты (`worker.token`) только админу с подтверждением.
- **Worker UI** (локальный визард в `worker-agent`): слушает только
`127.0.0.1`, без аутентификации (локальная машина), не требует никаких
внешних сервисов; конфигурация сохраняется в JSON-файле рядом с
рабочим каталогом воркера; запуск воркера — отдельным процессом
(`worker-agent --config <path>`), который визард же может остановить.
- Никаких секретов в логах и в самом UI после сохранения.
- `requireAdmin` middleware и `/ui/admin` (`internal/transport/http/ui_admin.go`)
— сейчас минимальная панель: прокси `promote/demote/verify/unverify` в
userservice.
- Userservice (`internal/userservice/`): `POST /users/{id}/promote|demote|
verify|unverify` (admin), `GET/POST/DELETE /worker-keys` (per-user),
`POST /worker-tokens/exchange`. **Нет**: list users, list all keys.
- `domain.Worker.TrustLevel` (`trusted`/`untrusted`) + quorum для untrusted —
колонка в БД есть, нужен только метод смены и отображение.
- `domain.Job.OwnerID *uuid.UUID` — владелец джобы из JWT `sub` (может быть nil).
- Каталог ворклоадов `internal/workloads` (embedded `workloads.json`) —
enable/disable кладём поверх через таблицу настроек.
- UIReadRepo (`ui_read_repo.go` в обоих движках) — bounded read model для UI.
- Агент целиком конфигурируется env (таблица в `mkdocs/sdk/worker-integration.md`);
`--config` маппится на ту же поверхность `agent.Config`.
## 3. Coordinator Admin UI (`/ui/admin`)
## 3. Coordinator Admin UI
### 3.1 Экран «Обзор системы» — `/ui/admin`
- движок БД (sqlite/postgres), версия бинарника, data dir, uptime;
- занятое место blob-хранилища (байты по артефактам) и число артефактов;
- чипы: джобы/воркеры/таски по статусам (из существующих business metrics);
- токен воркера: кнопка «Показать worker token» (admin only, подтверждение).
### 3.1 Страницы ↔ API ↔ данные
### 3.2 Экран «Джобы» — `/ui/admin/jobs`
- таблица: id, ворклоад, статус, владелец, создан, завершён, результат;
- фильтры: статус, ворклоад, владелец; поиск по id (частичное совпадение);
- пагинация (limit/offset), переход в существующий detail-просмотр;
- действие: cancel (существующий usecase).
Все API — JSON, под `withUISession` + `requireAdmin`, префикс
`/ui/admin/api/`. Страница `admin.html` — оболочка из мокапа (сайдбар,
7 разделов), данные подтягивает JS через fetch (паттерн dashboard).
### 3.3 Экран «Воркеры» — `/ui/admin/workers`
- таблица: имя, id, статус (online/busy/offline), capabilities, trust,
владелец, последний heartbeat;
- переключение trust (trusted/untrusted) для воркеров — новый usecase;
- индикация «какие ворклоады умеет» (capabilities из реестра).
### 3.4 Экран «Юзеры и ключи» — `/ui/admin/users`
- список юзеров через userservice (существующий прокси `callUserserviceAuthed`);
- verify/unverify, promote/demote (существующие действия);
- worker-ключи юзера: просмотр (без секрета), revoke.
### 3.5 Экран «Ворклоады» — `/ui/admin/workloads`
- каталог из embedded `workloads.json` (уже есть read view);
- enable/disable: новая таблица `workload_settings (workload TEXT PK, enabled
BOOLEAN)`, миграции sqlite + postgres; каталог читается с учётом оверрайдов;
- параметры: схема, reduction, upload_ready, ui_elements (read-only).
### 3.6 Экран «Настройки» — `/ui/admin/settings`
- read-only конфигурация без секретов: addr, storage dir, docs dir,
лимиты (max upload, attempts, quorum), lease/reaper интервалы, engine;
- подсказки: где лежат миграции, как сделать бэкап sqlite (скопировать файл).
### 3.7 API (все — admin, JSON)
| Метод | Путь | Назначение |
| Раздел | API | Данные |
| --- | --- | --- |
| GET | `/ui/admin/api/system` | движок, версия, storage usage, counts, worker token (admin) |
| GET | `/ui/admin/api/jobs` | пагинированный список с фильтрами |
| GET | `/ui/admin/api/workers` | список воркеров |
| POST | `/ui/admin/api/workers/{id}/trust` | переключить trust |
| GET | `/ui/admin/api/users` | список юзеров (userservice) |
| GET | `/ui/admin/api/workloads` | каталог + enabled |
| POST | `/ui/admin/api/workloads/{name}/enabled` | enable/disable |
| GET | `/ui/admin/api/settings` | конфигурация (без секретов) |
| System | `GET /system` | version, uptime, storage stats, health (db/userservice/reducer), node info |
| Jobs | `GET /jobs?status=&page=` | пагинированный список + счётчики по статусам; owner email резолвится через userservice |
| Workers | `GET /workers`, `POST /workers/{id}/trust` | список + trust, смена trust (trusted/untrusted) |
| Users & keys | `GET /users`, `POST /users/{id}/role`, `GET /worker-keys`, `POST /worker-keys/{id}/revoke` | прокси/агрегация userservice |
| Workloads | `GET /workloads`, `POST /workloads/{name}/enabled` | каталог + persisted enabled-флаг |
| Metrics | `GET /metrics` | jobs/day (7d), jobs by workload, shards, failure rate, avg shard time |
| Settings | `GET /settings`, `POST /token/reveal` | read-only конфиг + reveal токена (audit-лог) |
## 4. Worker Setup UI (в бинарнике `worker-agent`)
### 3.2 Userservice — новые эндпоинты
### 4.1 Как это выглядит
- `GET /users` (admin) — `id, email, role, verified, created_at`.
- `GET /worker-keys/all` (admin) — все ключи + owner email.
- Репозитории: `ListUsers(ctx)`, `ListWorkerKeysAll(ctx)` в `memstore` и
`storage/sqlite` (+ тесты). Координатор вызывает их через
`callUserserviceAuthed` и отдаёт в свой bounded API — браузер юзерсервис
не касается.
```bash
curl -fsSL .../install.sh | bash -s worker
worker-agent setup # печатает URL и открывает браузер
# → http://127.0.0.1:12700 — локальный визард, ничего устанавливать не нужно
```
### 3.3 Storage координатора — новые методы
### 4.2 Визард (шаги)
- `WorkerRepository.SetTrust(ctx, id, trust)` — оба движка + тесты.
- `UIReadRepo.ListJobsPaginated(ctx, status string, limit, offset int)`
→ `([]UIJob, total int, counts map[string]int)` — фильтр по статусу,
счётчики для табов.
- `UIReadRepo.JobMetrics(ctx, since time.Time)` → jobs/day, by workload,
shards completed/failed, avg shard duration (из `tasks`).
- `UIReadRepo.StorageStats(ctx)` → суммы байт по kind артефактов
(datasets/artifacts) + размер файла БД (sqlite: `page_count*page_size`;
postgres: `pg_database_size(current_database())`).
- Миграция `0002_workload_settings` (оба движка):
`workload_settings(workload TEXT PRIMARY KEY, enabled BOOL NOT NULL,
updated_at TIMESTAMP NOT NULL)`; отсутствие строки = enabled (default).
Repo: `WorkloadSettingsRepo{ List, Set }`.
- **Enforcement**: `SubmitDataset` отклоняет disabled-ворклоад;
`/ui/api/workloads` и форма new-job помечают disabled (скрываем из выбора).
1. **Координатор**: URL (например `http://192.168.1.10:8080`) + способ
аутентификации: токен (serve) или worker key (кластер);
2. **Рабочий каталог**: путь + имя машины (WORKER_NAME);
3. **Проверка**: кнопка «Проверить соединение» — `GET /health` координатора
(+ обмен ключа, если key), версии; чек-лист зависимостей (Python 3,
scimesh) с командой установки;
4. **Запуск**: «Запустить воркер» — визард сохраняет `config.json` и
запускает `worker-agent --config <path>` отдельным процессом.
### 3.4 Usecase — `internal/usecase/admin.go`
### 4.3 Статусная страница (та же вкладка после запуска)
`AdminSystem`, `ListJobsAdmin(status,page)` (+owner emails), `ListWorkersAdmin`,
`SetWorkerTrust`, `ListUsersAdmin`, `SetUserRole` (через userservice promote/demote),
`ListWorkerKeysAdmin`, `RevokeWorkerKeyAdmin`, `ListWorkloadsAdmin`,
`SetWorkloadEnabled`, `AdminMetrics`, `RevealWorkerToken` (читает
`worker.token`/env, пишет audit-лог).
- состояние: не запущен / запущен, worker id, зарегистрирован ли;
- статистика: забрано задач, выполнено, ошибок, последний heartbeat;
- runtime: python, scimesh, capabilities (из каталога);
- кнопки: Остановить / Запустить / Открыть лог (хвост).
### 3.5 Шаблон
### 4.4 API визарда (локальный HTTP, 127.0.0.1)
`templates/admin.html` заменяется на дизайн мокапа: сайдбар (Operate /
Access / Platform), topbar с env-бейджем (serve/postgres, addr), 7 разделов,
рендер через JS. Имя ворклоада — реальное `descriptor-batch`.
| Метод | Путь | Назначение |
| --- | --- | --- |
| GET | `/api/status` | конфиг (без секрета), состояние, статистика |
| POST | `/api/config` | сохранить конфигурацию в `config.json` |
| POST | `/api/test` | проверка соединения с координатором |
| POST | `/api/start` | запустить воркера (self `--config`) |
| POST | `/api/stop` | остановить |
| GET | `/api/logs` | хвост лога воркера |
## 4. Worker Setup UI (бинарник `worker-agent`)
### 4.5 Изменения в агенте
- `worker-agent setup [--port 12700] [--no-open]` — локальный сервер визарда;
- `worker-agent --config <path>` — запуск демона из JSON-конфига (URL, токен
или ключ, work dir, имя, task runner); env-переменные имеют приоритет;
- `worker-agent --check [--coordinator-url URL]` — пинг `/health` + версии,
exit 0/1 (используется визардом на шаге 3);
- `config.json` по умолчанию: `~/.scimesh-worker/config.json` (переопределяется
через `WORKER_CONFIG`); секрет хранится с правами 0600.
## 5. Компоненты и структура кода
### 4.1 Новые файлы
```
# Coordinator Admin UI (бинарник coordinator)
coordinator/internal/transport/http/
ui_admin.go # admin-хендлеры + admin.html
server.go # роуты /ui/admin/* (requireAdmin)
coordinator/internal/usecase/
admin.go # AdminSystem/ListJobsAdmin/ListWorkersAdmin/WorkloadSettings
ports.go # + PaginatedJobs(ctx, filter, limit, offset)
coordinator/internal/storage/{sqlite,postgres}/
ui_read_repo.go # + ListJobsPaginated, StorageStats
migrations/*.sql # + workload_settings
# Worker Setup UI (бинарник worker-agent)
coordinator/internal/agent/
setup/ # локальный визард: сервер, шаблоны, API, config.json
setup_ui.go # worker-agent setup (сервер 127.0.0.1:12700)
configfile.go # --config <path>: JSON-конфиг демона
check.go # --check: пинг /health + версии
configfile.go # ConfigFile (json), Load/Save (0600), путь по умолчанию
check.go # CheckCoordinator(url, auth) — health + версии + python/scimesh
setupui/
server.go # локальный HTTP 127.0.0.1:12700, API + запуск/остановка
template.html # визард + статус (по мокапу), go:embed
coordinator/cmd/worker-agent/main.go # + setup / --config / --check
```
## 6. Милестоуны
### 4.2 CLI
- **M1. Admin-фундамент**: экран «Система» + таблица джобов (фильтры,
пагинация) + storage usage. API + шаблоны + тесты.
- **M2. Admin-управление**: воркеры (trust), юзеры/ключи (userservice),
ворклоады enable/disable (таблица `workload_settings` + миграции обоих
движков), настройки (read-only).
- **M3. Worker Setup (в worker-agent)**: `setup`-сервер с визардом
(config/test/start/stop/logs), `--config`, `--check`; E2E: визард запускает
реального воркера, тот регистрируется и берёт джоб.
- **M4. Полировка и верификация**: пустые состояния, mobile, копирование,
E2E в браузере (admin-флоу и setup-флоу с реальным worker-agent),
обновление mkdocs/standalone.md, CI зелёный.
- `worker-agent setup [--port 12700] [--no-open]` — визард; печатает URL,
открывает браузер.
- `worker-agent --config <path>` — демон из JSON-конфига; env имеет приоритет.
- `worker-agent --check [--coordinator-url URL]` — пинг `/health`, auth
(token → claim-endpoint 401/200 probe или exchange для ключа), python3 +
`import scimesh`; exit 0/1. Используется визардом на шаге 3.
## 7. Тестирование
### 4.3 config.json
- **Go (unit)**: usecase admin (memstore + sqlite), permission-тесты
(admin vs user → 403), фильтры/пагинация, enable/disable ворклоадов;
агент: визард API (config persist 0600, test без/с координатором,
start/stop), `--config`, `--check` (с/без координатора).
- **Go (integration, postgres)**: пагинация и storage-метрики на реальной БД.
- **E2E (браузер)**: вход админом → `/ui/admin` показывает реальную систему;
на «голой» машине: `worker-agent setup` → визард → запуск → воркер
регистрируется в координаторе и берёт джоб.
- **CI**: существующие джобы + новые тесты в общем `go test -race`; sqlite и
postgres пути одинаково зелёные.
`~/.scimesh-worker/config.json` (переопределяется `WORKER_CONFIG`), права 0600:
```json
{
"coordinator_url": "http://192.168.1.10:8080",
"token": "…", // или
"worker_key": "…", "userservice_url": "http://…:8081",
"work_dir": "…", "worker_name": "emil-laptop",
"cpu_count": 8, "memory_mb": 16384,
"task_runner": ["python", "-m", "scimesh.worker.task"]
}
```
## 8. Открытые решения
### 4.4 API визарда (только 127.0.0.1, без auth)
- **enable/disable ворклоадов**: хранить в `workload_settings` (миграции
обоих движков); решение принято — таблица добавляется в M2.
- **Токен в UI**: только админ, с подтверждением; в командах визарда —
плейсхолдер, чтобы не светить секрет в логах истории.
- **`worker-agent --check`**: без регистрации, только health + версии.
- **Локальный визард без аутентификации**: сервер слушает только
`127.0.0.1`; любой локальный процесс может управлять воркером — это
сознательное упрощение для одной машины.
- Скоуп v1: без редактирования конфигурации координатора и без управления
миграциями из UI (это задача `setup`/CLI).
| Метод | Путь | Назначение |
| --- | --- | --- |
| GET | `/api/status` | конфиг (секрет маскирован), running (pid alive), статистика из лога, runtime |
| POST | `/api/config` | валидация + сохранение `config.json` (0600) |
| POST | `/api/test` | preflight: coordinator reachable, auth ok, python, scimesh |
| POST | `/api/start` | spawn `worker-agent --config <path>` (лог → `worker.log`, pid-файл) |
| POST | `/api/stop` | SIGTERM по pid-файлу |
| GET | `/api/logs?tail=` | хвост `worker.log` |
Статистика — парсинг лога (registered/claimed/completed/failed/heartbeat).
Spawner — интерфейс, в тестах подменяется.
## 5. Милестоуны
- **M1. Admin-фундамент**: оболочка `admin.html` по мокапу + `GET /system`
(storage stats, health, node) + `GET /jobs` (фильтр+пагинация+счётчики) +
`GET /metrics`. Тесты: pagination/metrics/storage-stats на sqlite,
permission-тесты 403.
- **M2. Access & Platform**: userservice `ListUsers`/`ListWorkerKeysAll`
(mem+sqlite+http), `SetTrust` (оба движка), users/keys/trust API и
разделы, `workload_settings` миграция 0002 + enable/disable + enforcement
в `SubmitDataset` и форме, settings + token reveal (audit). Тесты на каждый
слой.
- **M3. Worker Setup**: `configfile.go`, `--config`, `--check`, `setupui`
(server + шаблон по мокапу, 6 API), тесты (roundtrip конфига, права 0600,
check с httptest, API визарда с подменённым spawner).
- **M4. E2E + docs**: браузерный E2E (admin видит реальные данные; визард
запускает реального воркера против тестового координатора → регистрация →
джоб), обновить `mkdocs/index.md`, `mkdocs/standalone.md`
(`worker-agent setup` вместо ручных export), README, чекбоксы CTX-19/20.
## 6. Тестирование
- **Go unit**: usecase admin (fakes + sqlite), repos обоих движков
(SetTrust, pagination, metrics, settings, storage stats), userservice
List/ListAll (mem+sqlite), HTTP permission (user → 403 на все `/ui/admin/api/*`),
enforcement disabled-ворклоада, configfile/check/setupui агента.
- **Go integration (postgres)**: миграция 0002 и parity новых методов —
через существующий docker-хелпер, skip без docker.
- **E2E браузер**: M4.
## 7. Открытые решения (зафиксировано)
- Локальный визард без аутентификации — слушает только 127.0.0.1.
- Settings-раздел v1 — read-only + reveal токена; редактирование конфигурации
и prune/reset (danger zone) — v2, в UI помечены как таковые.
- `owner` джобы: email из userservice по `OwnerID`; при nil — «cluster token».
- Статистика воркера в визарде — из лога, без новых эндпоинтов координатора.
+388
View File
@@ -0,0 +1,388 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh · Coordinator Admin</title>
<style>
:root{
--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;
--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;
--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;
--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;
color-scheme:dark;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
input,select{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:8px;padding:8px 11px;outline:none}
input:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
code{font-family:var(--mono);font-size:.86em}
.layout{display:flex;min-height:100vh}
/* ── sidebar ─────────────────────────────── */
.sidebar{position:sticky;top:0;height:100vh;width:232px;flex:none;display:flex;flex-direction:column;background:var(--panel);border-right:1px solid var(--border-soft)}
.brand{display:flex;align-items:center;gap:11px;padding:20px 20px 18px;border-bottom:1px solid var(--border-soft)}
.brand-mark{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 14px #5b8cff40}
.brand-mark svg{width:17px;height:17px;stroke:#fff}
.brand-name{font-weight:700;font-size:14.5px;letter-spacing:-.01em}
.brand-sub{font-size:11px;color:var(--text-3);letter-spacing:.02em}
.nav{flex:1;overflow-y:auto;padding:14px 12px}
.nav-label{margin:16px 10px 6px;font-size:10.5px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--text-3)}
.nav-label:first-child{margin-top:0}
.nav-item{display:flex;align-items:center;gap:10px;width:100%;padding:8px 10px;border-radius:8px;color:var(--text-2);font-weight:500;text-align:left;transition:background .12s,color .12s}
.nav-item svg{width:16px;height:16px;stroke:currentColor;flex:none}
.nav-item:hover{background:var(--panel-2);color:var(--text)}
.nav-item.active{background:var(--accent-soft);color:var(--accent);font-weight:600}
.nav-item .count{margin-left:auto;font-size:11px;font-weight:600;color:var(--text-3);background:var(--panel-2);border-radius:99px;padding:1px 7px}
.nav-item.active .count{color:var(--accent);background:#5b8cff26}
.side-foot{padding:14px;border-top:1px solid var(--border-soft)}
.user-chip{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:9px;background:var(--panel-2)}
.avatar{display:grid;place-items:center;width:28px;height:28px;border-radius:8px;background:linear-gradient(135deg,#3fce8a,#2ea56c);color:#08130d;font-weight:800;font-size:12px;flex:none}
.user-chip b{display:block;font-size:12.5px;line-height:1.25}
.user-chip span{display:block;font-size:11px;color:var(--text-3)}
.back-link{display:block;margin-top:9px;padding:7px 10px;color:var(--text-3);font-size:12.5px;text-decoration:none;border-radius:8px}
.back-link:hover{color:var(--text);background:var(--panel-2)}
/* ── main ────────────────────────────────── */
.main{flex:1;min-width:0;display:flex;flex-direction:column}
.topbar{position:sticky;top:0;z-index:5;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:16px 32px;background:#0b0e13e6;backdrop-filter:blur(10px);border-bottom:1px solid var(--border-soft)}
.topbar h1{font-size:17px;font-weight:700;letter-spacing:-.015em}
.topbar p{font-size:12.5px;color:var(--text-3);margin-top:1px}
.env-badge{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--text-2);border:1px solid var(--border);border-radius:99px;padding:5px 12px;background:var(--panel)}
.env-badge i{width:7px;height:7px;border-radius:50%;background:var(--green);box-shadow:0 0 8px var(--green)}
.content{flex:1;padding:26px 32px 60px;max-width:1120px;width:100%;margin:0 auto}
.page{display:none}.page.active{display:block;animation:fade .18s ease}
@keyframes fade{from{opacity:0;transform:translateY(4px)}to{opacity:1}}
/* ── shared components ───────────────────── */
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px}
.card-pad{padding:20px}
.card-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:15px 20px;border-bottom:1px solid var(--border-soft)}
.card-head h3{font-size:13.5px;font-weight:650}
.card-head span{font-size:12px;color:var(--text-3)}
.grid-kpi{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:14px}
.kpi{background:var(--panel);border:1px solid var(--border-soft);border-radius:13px;padding:16px 18px}
.kpi .k-label{display:flex;align-items:center;gap:7px;font-size:11.5px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text-3)}
.kpi .k-label svg{width:14px;height:14px;stroke:currentColor}
.kpi .k-value{margin-top:8px;font-size:26px;font-weight:700;letter-spacing:-.03em;line-height:1}
.kpi .k-sub{margin-top:6px;font-size:12px;color:var(--text-2)}
.kpi .k-sub.up{color:var(--green)}
.pill{display:inline-flex;align-items:center;gap:6px;border-radius:99px;padding:3px 10px;font-size:11.5px;font-weight:650;white-space:nowrap}
.pill i{width:6px;height:6px;border-radius:50%;background:currentColor}
.pill-success{background:var(--green-soft);color:var(--green)}
.pill-active{background:var(--accent-soft);color:var(--accent)}
.pill-waiting{background:#ffffff12;color:var(--text-2)}
.pill-danger{background:var(--red-soft);color:var(--red)}
.pill-amber{background:var(--amber-soft);color:var(--amber)}
.btn{display:inline-flex;align-items:center;gap:7px;border-radius:8px;padding:8px 14px;font-weight:600;font-size:13px;border:1px solid transparent;transition:filter .12s,background .12s}
.btn svg{width:14px;height:14px;stroke:currentColor}
.btn-primary{background:var(--accent);color:#0a1222}
.btn-primary:hover{background:var(--accent-strong);color:#fff}
.btn-ghost{background:var(--panel-2);border-color:var(--border);color:var(--text-2)}
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
.btn-danger{background:var(--red-soft);color:var(--red)}
.btn-danger:hover{background:#f2647c2e}
.btn-sm{padding:5px 10px;font-size:12px;border-radius:7px}
table{width:100%;border-collapse:collapse}
th{padding:10px 20px;text-align:left;font-size:11px;font-weight:650;letter-spacing:.07em;text-transform:uppercase;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
td{padding:12px 20px;border-bottom:1px solid var(--border-soft);vertical-align:middle}
tr:last-child td{border-bottom:0}
tbody tr{transition:background .1s}
tbody tr:hover{background:var(--panel-2)}
.t-main{font-weight:600;font-size:13.5px}
.t-sub{font-size:11.5px;color:var(--text-3);font-family:var(--mono)}
.cap{display:inline-block;margin:2px 4px 2px 0;padding:2px 7px;border:1px solid var(--border);border-radius:6px;font:11px var(--mono);color:var(--text-2)}
.bar{height:5px;width:130px;border-radius:99px;background:#ffffff10;overflow:hidden}
.bar span{display:block;height:100%;border-radius:inherit;background:linear-gradient(90deg,#5b8cff,#3fce8a)}
.bar-label{font-size:11.5px;color:var(--text-2);font-family:var(--mono);margin-top:5px}
.tabs{display:flex;gap:4px;padding:4px;background:var(--panel);border:1px solid var(--border-soft);border-radius:10px;width:max-content;margin-bottom:14px}
.tab{padding:6px 13px;border-radius:7px;font-size:12.5px;font-weight:600;color:var(--text-2)}
.tab:hover{color:var(--text)}
.tab.active{background:var(--panel-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)}
.tab .n{color:var(--text-3);font-weight:500;margin-left:5px}
.tab.active .n{color:var(--accent)}
.section-title{margin:26px 0 12px;font-size:13px;font-weight:700;letter-spacing:-.01em;color:var(--text)}
.section-title:first-child{margin-top:0}
.section-note{font-size:12px;color:var(--text-3);margin:-8px 0 12px}
.kv{display:grid;grid-template-columns:210px 1fr;row-gap:0}
.kv dt{padding:11px 20px;font-size:12.5px;color:var(--text-3);border-bottom:1px solid var(--border-soft)}
.kv dd{padding:11px 20px;font-size:13px;border-bottom:1px solid var(--border-soft)}
.kv dt:last-of-type,.kv dd:last-of-type{border-bottom:0}
.stack{display:grid;gap:14px}
.split{display:grid;grid-template-columns:1fr 1fr;gap:14px}
.toggle{position:relative;width:36px;height:20px;border-radius:99px;background:#ffffff17;transition:background .15s;flex:none}
.toggle:after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#8b95a5;transition:transform .15s,background .15s}
.toggle.on{background:var(--accent)}
.toggle.on:after{transform:translateX(16px);background:#fff}
.storage-bar{display:flex;height:10px;border-radius:99px;overflow:hidden;margin:14px 20px 6px}
.storage-bar div{height:100%}
.legend{display:flex;gap:20px;padding:10px 20px 18px;flex-wrap:wrap}
.legend span{display:flex;align-items:center;gap:7px;font-size:12px;color:var(--text-2)}
.legend i{width:9px;height:9px;border-radius:3px}
.footer-row{display:flex;align-items:center;justify-content:space-between;padding:11px 20px;font-size:12px;color:var(--text-3)}
.pager{display:flex;gap:4px}
.pager button{width:26px;height:26px;border-radius:7px;font-size:12px;color:var(--text-2)}
.pager button.cur{background:var(--accent-soft);color:var(--accent);font-weight:700}
.secret{display:flex;align-items:center;gap:10px}
.secret code{background:var(--panel-2);border:1px solid var(--border);border-radius:7px;padding:6px 10px;letter-spacing:.08em}
.chart{width:100%;height:auto;display:block}
.chart-bar{fill:#2c3a52;rx:4}
.chart-bar.hot{fill:var(--accent)}
.chart-grid{stroke:#ffffff08}
.chart-label{font:10px var(--mono);fill:var(--text-3)}
.warn-strip{display:flex;gap:10px;align-items:flex-start;background:var(--amber-soft);border:1px solid #e5b64f33;border-radius:10px;padding:12px 14px;font-size:12.5px;color:#eecf8d}
.warn-strip svg{width:15px;height:15px;stroke:var(--amber);flex:none;margin-top:1px}
@media(max-width:960px){.sidebar{display:none}.grid-kpi,.split{grid-template-columns:1fr 1fr}}
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
<div><div class="brand-name">SciMesh</div><div class="brand-sub">Coordinator Admin</div></div>
</div>
<nav class="nav" id="nav">
<div class="nav-label">Operate</div>
<button class="nav-item active" data-page="system"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="3" width="8" height="8" rx="2"/><rect x="13" y="3" width="8" height="5" rx="2"/><rect x="13" y="10" width="8" height="11" rx="2"/><rect x="3" y="13" width="8" height="8" rx="2"/></svg>System</button>
<button class="nav-item" data-page="jobs"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Jobs<span class="count">26</span></button>
<button class="nav-item" data-page="workers"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/></svg>Workers<span class="count">3</span></button>
<div class="nav-label">Access</div>
<button class="nav-item" data-page="users"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="9" cy="8" r="3.2"/><path d="M3.5 19c.7-3 2.9-4.5 5.5-4.5s4.8 1.5 5.5 4.5"/><circle cx="17" cy="9" r="2.4"/><path d="M15.5 14.6c2.6.2 4.3 1.7 5 4.4"/></svg>Users &amp; keys</button>
<div class="nav-label">Platform</div>
<button class="nav-item" data-page="workloads"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>Workloads</button>
<button class="nav-item" data-page="metrics"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><path d="M4 19V5M4 19h16"/><path d="M8 15v-4M12 15V7M16 15v-6M20 15V9"/></svg>Metrics</button>
<button class="nav-item" data-page="settings"><svg viewBox="0 0 24 24" fill="none" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="3"/><path d="M19 12a7 7 0 0 0-.1-1.2l2-1.6-2-3.4-2.4 1a7 7 0 0 0-2-1.2L14 3h-4l-.5 2.6a7 7 0 0 0-2 1.2l-2.4-1-2 3.4 2 1.6A7 7 0 0 0 5 12c0 .4 0 .8.1 1.2l-2 1.6 2 3.4 2.4-1a7 7 0 0 0 2 1.2L10 21h4l.5-2.6a7 7 0 0 0 2-1.2l2.4 1 2-3.4-2-1.6c.1-.4.1-.8.1-1.2z"/></svg>Settings</button>
</nav>
<div class="side-foot">
<div class="user-chip"><div class="avatar">A</div><div><b>admin@scimesh.local</b><span>admin · serve bootstrap</span></div></div>
<a class="back-link" href="#">← Back to control room</a>
</div>
</aside>
<div class="main">
<header class="topbar">
<div><h1 id="page-title">System</h1><p id="page-sub">Cluster state and node information</p></div>
<div class="env-badge"><i></i>serve mode · sqlite · 127.0.0.1:8080</div>
</header>
<div class="content">
<!-- ═══ SYSTEM ═══ -->
<section class="page active" id="page-system">
<div class="grid-kpi">
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>Version</div><div class="k-value">1.1.0</div><div class="k-sub">alpha.1 · linux/amd64</div></div>
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>Uptime</div><div class="k-value">3d 14h</div><div class="k-sub">since Jul 30, 09:12</div></div>
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h10"/></svg>Active jobs</div><div class="k-value">2</div><div class="k-sub up">1 running · 1 waiting</div></div>
<div class="kpi"><div class="k-label"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8"/></svg>Workers online</div><div class="k-value">3<span style="font-size:15px;color:var(--text-3);font-weight:500"> / 5</span></div><div class="k-sub">2 busy · 1 idle</div></div>
</div>
<div class="split">
<div class="card">
<div class="card-head"><h3>Storage usage</h3><span>2.1 GB total</span></div>
<div class="storage-bar"><div style="width:38%;background:#5b8cff"></div><div style="width:57%;background:#7c5cff"></div><div style="width:5%;background:#3fce8a"></div></div>
<div class="legend">
<span><i style="background:#5b8cff"></i>Datasets · 812 MB</span>
<span><i style="background:#7c5cff"></i>Artifacts · 1.2 GB</span>
<span><i style="background:#3fce8a"></i>Database · 34 MB</span>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Health</h3><span>all checks pass</span></div>
<dl class="kv">
<dt>Database</dt><dd><span class="pill pill-success"><i></i>Connected</span> <span style="color:var(--text-3);font-size:12px">sqlite · wal</span></dd>
<dt>Userservice</dt><dd><span class="pill pill-success"><i></i>Embedded</span> <span style="color:var(--text-3);font-size:12px">127.0.0.1:41273</span></dd>
<dt>Reducer</dt><dd><span class="pill pill-waiting"><i></i>Idle</span> <span style="color:var(--text-3);font-size:12px">last merge 12 min ago</span></dd>
</dl>
</div>
</div>
<div class="section-title">Node information</div>
<div class="card">
<dl class="kv">
<dt>Binary</dt><dd><code>/usr/local/bin/coordinator</code></dd>
<dt>Listen address</dt><dd><code>127.0.0.1:8080</code></dd>
<dt>Data directory</dt><dd><code>~/.scimesh</code> <span style="color:var(--text-3)">(jwt.secret · worker.token · venv)</span></dd>
<dt>Database engine</dt><dd>sqlite · <code>~/.scimesh/scimesh.db</code></dd>
<dt>Public URL</dt><dd><code>http://192.168.1.10:8080</code></dd>
</dl>
</div>
</section>
<!-- ═══ JOBS ═══ -->
<section class="page" id="page-jobs">
<div class="tabs">
<button class="tab active">All<span class="n">26</span></button>
<button class="tab">Running<span class="n">1</span></button>
<button class="tab">Waiting<span class="n">1</span></button>
<button class="tab">Completed<span class="n">21</span></button>
<button class="tab">Failed<span class="n">2</span></button>
<button class="tab">Cancelled<span class="n">1</span></button>
</div>
<div class="card">
<table>
<thead><tr><th>Job</th><th>Workload</th><th>Owner</th><th>Status</th><th>Progress</th><th>Submitted</th><th></th></tr></thead>
<tbody>
<tr><td><div class="t-main">kinase-inhibitors-topk</div><div class="t-sub">7f3a…c91e</div></td><td><code style="color:var(--text-2)">similarity-search</code></td><td style="color:var(--text-2)">alice@lab.org</td><td><span class="pill pill-active"><i></i>Running</span></td><td><div class="bar"><span style="width:62%"></span></div><div class="bar-label">124 / 200 shards</div></td><td style="color:var(--text-2);font-size:12.5px">12 min ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
<tr><td><div class="t-main">chembl-2d-descriptors</div><div class="t-sub">a102…77bd</div></td><td><code style="color:var(--text-2)">descriptors</code></td><td style="color:var(--text-2)">bob@lab.org</td><td><span class="pill pill-waiting"><i></i>Waiting</span></td><td><div class="bar"><span style="width:0%"></span></div><div class="bar-label">0 / 48 shards</div></td><td style="color:var(--text-2);font-size:12.5px">4 min ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
<tr><td><div class="t-main">fragment-library-graph</div><div class="t-sub">55e0…0a3f</div></td><td><code style="color:var(--text-2)">similarity-graph</code></td><td style="color:var(--text-2)">alice@lab.org</td><td><span class="pill pill-success"><i></i>Completed</span></td><td><div class="bar"><span style="width:100%"></span></div><div class="bar-label">96 / 96 shards · 3m 41s</div></td><td style="color:var(--text-2);font-size:12.5px">1 h ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
<tr><td><div class="t-main">leadopt-molwt-450</div><div class="t-sub">9b41…e2c8</div></td><td><code style="color:var(--text-2)">molwt-filter</code></td><td style="color:var(--text-2)">admin@scimesh.local</td><td><span class="pill pill-success"><i></i>Completed</span></td><td><div class="bar"><span style="width:100%"></span></div><div class="bar-label">12 / 12 shards · 24s</div></td><td style="color:var(--text-2);font-size:12.5px">3 h ago</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
<tr><td><div class="t-main">hts-plate-14-search</div><div class="t-sub">c7d9…4b60</div></td><td><code style="color:var(--text-2)">similarity-search</code></td><td style="color:var(--text-2)">bob@lab.org</td><td><span class="pill pill-danger"><i></i>Failed</span></td><td><div class="bar"><span style="width:34%;background:linear-gradient(90deg,#5b8cff,#f2647c)"></span></div><div class="bar-label">18 / 53 shards · 2 failed</div></td><td style="color:var(--text-2);font-size:12.5px">yesterday</td><td><button class="btn btn-ghost btn-sm">Open</button></td></tr>
</tbody>
</table>
<div class="footer-row"><span>15 of 26 jobs</span><div class="pager"><button></button><button class="cur">1</button><button>2</button><button>3</button><button></button></div></div>
</div>
</section>
<!-- ═══ WORKERS ═══ -->
<section class="page" id="page-workers">
<div class="section-note">Workers register themselves. Trust controls whether a machine may claim tasks from this cluster.</div>
<div class="card">
<table>
<thead><tr><th>Worker</th><th>Status</th><th>Capabilities</th><th>Trust</th><th>Completed</th><th>Last signal</th></tr></thead>
<tbody>
<tr><td><div class="t-main">lab-node-01</div><div class="t-sub">w-01HZZ…a4</div></td><td><span class="pill pill-active"><i></i>Busy</span></td><td><span class="cap">similarity-search</span><span class="cap">similarity-graph</span></td><td><select><option selected>Trusted</option><option>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">341</td><td style="color:var(--text-2);font-size:12.5px">4 s ago</td></tr>
<tr><td><div class="t-main">emil-laptop</div><div class="t-sub">w-01HZZ…9c</div></td><td><span class="pill pill-success"><i></i>Idle</span></td><td><span class="cap">similarity-search</span><span class="cap">descriptors</span><span class="cap">molwt-filter</span></td><td><select><option selected>Trusted</option><option>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">87</td><td style="color:var(--text-2);font-size:12.5px">2 s ago</td></tr>
<tr><td><div class="t-main">lab-node-02</div><div class="t-sub">w-01HZZ…d1</div></td><td><span class="pill pill-active"><i></i>Busy</span></td><td><span class="cap">similarity-search</span></td><td><select><option>Trusted</option><option selected>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">12</td><td style="color:var(--text-2);font-size:12.5px">6 s ago</td></tr>
<tr><td><div class="t-main">old-workstation</div><div class="t-sub">w-01HZX…7f</div></td><td><span class="pill pill-waiting"><i></i>Offline</span></td><td><span class="cap">similarity-search</span></td><td><select><option selected>Trusted</option><option>Untrusted</option></select></td><td style="font-family:var(--mono);font-size:12.5px">1 204</td><td style="color:var(--text-2);font-size:12.5px">2 days ago</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ═══ USERS & KEYS ═══ -->
<section class="page" id="page-users">
<div class="section-title">Users</div>
<div class="card">
<table>
<thead><tr><th>Email</th><th>Role</th><th>Jobs</th><th>Created</th><th>Last active</th><th></th></tr></thead>
<tbody>
<tr><td><div class="t-main">admin@scimesh.local</div></td><td><span class="pill pill-amber"><i></i>admin</span></td><td style="font-family:var(--mono);font-size:12.5px">9</td><td style="color:var(--text-2);font-size:12.5px">Jul 30</td><td style="color:var(--text-2);font-size:12.5px">now</td><td><button class="btn btn-ghost btn-sm">Edit</button></td></tr>
<tr><td><div class="t-main">alice@lab.org</div></td><td><select><option selected>user</option><option>admin</option></select></td><td style="font-family:var(--mono);font-size:12.5px">11</td><td style="color:var(--text-2);font-size:12.5px">Jul 30</td><td style="color:var(--text-2);font-size:12.5px">12 min ago</td><td><button class="btn btn-ghost btn-sm">Edit</button></td></tr>
<tr><td><div class="t-main">bob@lab.org</div></td><td><select><option selected>user</option><option>admin</option></select></td><td style="font-family:var(--mono);font-size:12.5px">6</td><td style="color:var(--text-2);font-size:12.5px">Jul 31</td><td style="color:var(--text-2);font-size:12.5px">1 h ago</td><td><button class="btn btn-ghost btn-sm">Edit</button></td></tr>
</tbody>
</table>
<div class="footer-row"><span>3 users · bootstrap admin cannot be demoted</span><button class="btn btn-ghost btn-sm"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>Invite user</button></div>
</div>
<div class="section-title">Worker keys</div>
<div class="section-note">Keys let lab machines register as workers under a user account. Served instances can also use the cluster token.</div>
<div class="card">
<table>
<thead><tr><th>Label</th><th>Key prefix</th><th>Owner</th><th>Created</th><th>Last used</th><th></th></tr></thead>
<tbody>
<tr><td class="t-main">lab-node-01</td><td><code style="color:var(--text-2)">smk_7f3a…</code></td><td style="color:var(--text-2)">alice@lab.org</td><td style="color:var(--text-2);font-size:12.5px">Jul 30</td><td style="color:var(--text-2);font-size:12.5px">4 s ago</td><td><button class="btn btn-danger btn-sm">Revoke</button></td></tr>
<tr><td class="t-main">emil-laptop</td><td><code style="color:var(--text-2)">smk_b291…</code></td><td style="color:var(--text-2)">admin@scimesh.local</td><td style="color:var(--text-2);font-size:12.5px">Jul 31</td><td style="color:var(--text-2);font-size:12.5px">2 s ago</td><td><button class="btn btn-danger btn-sm">Revoke</button></td></tr>
<tr><td class="t-main" style="color:var(--text-3)">old-workstation</td><td><code style="color:var(--text-3)">smk_44cd…</code></td><td style="color:var(--text-3)">alice@lab.org</td><td style="color:var(--text-3);font-size:12.5px">Jul 30</td><td style="color:var(--text-3);font-size:12.5px">2 days ago</td><td><span class="pill pill-danger"><i></i>Revoked</span></td></tr>
</tbody>
</table>
<div class="footer-row"><span>2 active keys</span><button class="btn btn-primary btn-sm"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 5v14M5 12h14"/></svg>Issue key</button></div>
</div>
</section>
<!-- ═══ WORKLOADS ═══ -->
<section class="page" id="page-workloads">
<div class="section-note">Disabled workloads are rejected at submit time and hidden from the job form. The catalog is compiled into the binary; settings persist in the database.</div>
<div class="card">
<table>
<thead><tr><th>Workload</th><th>Reduction</th><th>Parameters</th><th>Dataset upload</th><th>Enabled</th></tr></thead>
<tbody>
<tr><td><div class="t-main">similarity-search</div><div class="t-sub">top-k Tanimoto search over a TSV of SMILES</div></td><td><span class="pill pill-active"><i></i>top-k</span></td><td style="color:var(--text-2);font-size:12.5px">4 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle on" aria-label="enabled"></button></td></tr>
<tr><td><div class="t-main">similarity-graph</div><div class="t-sub">threshold graph edges between all pairs</div></td><td><span class="pill pill-active"><i></i>ordered-concat</span></td><td style="color:var(--text-2);font-size:12.5px">2 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle on" aria-label="enabled"></button></td></tr>
<tr><td><div class="t-main">descriptors</div><div class="t-sub">2D RDKit descriptors per molecule</div></td><td><span class="pill pill-active"><i></i>ordered-concat</span></td><td style="color:var(--text-2);font-size:12.5px">1 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle" aria-label="disabled"></button></td></tr>
<tr><td><div class="t-main">molwt-filter</div><div class="t-sub">keep molecules inside a weight window</div></td><td><span class="pill pill-active"><i></i>ordered-concat</span></td><td style="color:var(--text-2);font-size:12.5px">2 declared</td><td><span class="pill pill-success"><i></i>ready</span></td><td><button class="toggle on" aria-label="enabled"></button></td></tr>
</tbody>
</table>
</div>
</section>
<!-- ═══ METRICS ═══ -->
<section class="page" id="page-metrics">
<div class="grid-kpi">
<div class="kpi"><div class="k-label">Jobs · 7 days</div><div class="k-value">26</div><div class="k-sub up">+8 vs previous week</div></div>
<div class="kpi"><div class="k-label">Shards completed</div><div class="k-value">1 644</div><div class="k-sub">across 5 workers</div></div>
<div class="kpi"><div class="k-label">Avg shard time</div><div class="k-value">2.3s</div><div class="k-sub up">0.4s this week</div></div>
<div class="kpi"><div class="k-label">Failure rate</div><div class="k-value" style="color:var(--amber)">1.2%</div><div class="k-sub">20 failed shards</div></div>
</div>
<div class="split">
<div class="card">
<div class="card-head"><h3>Jobs per day</h3><span>last 7 days</span></div>
<div style="padding:16px 20px 10px">
<svg class="chart" viewBox="0 0 460 150">
<line class="chart-grid" x1="0" y1="30" x2="460" y2="30"/><line class="chart-grid" x1="0" y1="70" x2="460" y2="70"/><line class="chart-grid" x1="0" y1="110" x2="460" y2="110"/>
<rect class="chart-bar" x="18" y="70" width="44" height="60"/><rect class="chart-bar" x="82" y="50" width="44" height="80"/>
<rect class="chart-bar" x="146" y="90" width="44" height="40"/><rect class="chart-bar" x="210" y="40" width="44" height="90"/>
<rect class="chart-bar hot" x="274" y="20" width="44" height="110"/><rect class="chart-bar" x="338" y="60" width="44" height="70"/>
<rect class="chart-bar" x="402" y="80" width="44" height="50"/>
<text class="chart-label" x="18" y="145">Mon</text><text class="chart-label" x="82" y="145">Tue</text><text class="chart-label" x="146" y="145">Wed</text>
<text class="chart-label" x="210" y="145">Thu</text><text class="chart-label" x="274" y="145">Fri</text><text class="chart-label" x="338" y="145">Sat</text>
<text class="chart-label" x="402" y="145">Sun</text>
</svg>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Jobs by workload</h3><span>all time</span></div>
<dl class="kv">
<dt>similarity-search</dt><dd><div class="bar" style="width:100%"><span style="width:78%"></span></div></dd>
<dt>similarity-graph</dt><dd><div class="bar" style="width:100%"><span style="width:41%"></span></div></dd>
<dt>molwt-filter</dt><dd><div class="bar" style="width:100%"><span style="width:22%"></span></div></dd>
<dt>descriptors</dt><dd><div class="bar" style="width:100%"><span style="width:9%"></span></div></dd>
</dl>
</div>
</div>
</section>
<!-- ═══ SETTINGS ═══ -->
<section class="page" id="page-settings">
<div class="warn-strip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><path d="M12 9v4M12 17h.01M10.3 3.9L2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/></svg><div>The cluster token below authenticates <b>any</b> worker. Reveal it only on a trusted machine, and rotate it if it may have leaked.</div></div>
<div class="section-title">Cluster</div>
<div class="card">
<dl class="kv">
<dt>Worker token</dt><dd><div class="secret"><code id="tok">••••••••••••••••••••••••</code><button class="btn btn-ghost btn-sm" id="reveal">Reveal</button><button class="btn btn-ghost btn-sm">Rotate…</button></div></dd>
<dt>Public URL</dt><dd><input value="http://192.168.1.10:8080" style="width:320px"></dd>
<dt>Listen address</dt><dd><input value="127.0.0.1:8080" style="width:200px"> <span style="color:var(--text-3);font-size:12px">takes effect after restart</span></dd>
</dl>
</div>
<div class="section-title">Storage</div>
<div class="card">
<dl class="kv">
<dt>Data directory</dt><dd><code>~/.scimesh</code></dd>
<dt>Database engine</dt><dd><select><option selected>sqlite (embedded)</option><option>postgres (external)</option></select></dd>
<dt>Auto-migrate on start</dt><dd><button class="toggle on"></button></dd>
</dl>
<div class="footer-row"><span></span><button class="btn btn-primary btn-sm">Save changes</button></div>
</div>
<div class="section-title" style="color:var(--red)">Danger zone</div>
<div class="card" style="border-color:#f2647c33">
<dl class="kv">
<dt>Prune artifacts</dt><dd style="display:flex;justify-content:space-between;align-items:center"><span style="color:var(--text-2);font-size:12.5px">Delete results of completed jobs older than 30 days · frees ~400 MB</span><button class="btn btn-ghost btn-sm">Prune…</button></dd>
<dt>Reset cluster</dt><dd style="display:flex;justify-content:space-between;align-items:center"><span style="color:var(--text-2);font-size:12.5px">Drop all jobs, workers and keys. Users are kept.</span><button class="btn btn-danger btn-sm">Reset…</button></dd>
</dl>
</div>
</section>
</div>
</div>
</div>
<script>
const titles={system:['System','Cluster state and node information'],jobs:['Jobs','Every computation, filterable and paginated'],workers:['Workers','Fleet overview and trust management'],users:['Users & keys','Accounts, roles and worker keys'],workloads:['Workloads','Catalog entries and availability'],metrics:['Metrics','Throughput and reliability, last 7 days'],settings:['Settings','Cluster, storage and security']};
document.querySelectorAll('.nav-item').forEach(btn=>btn.addEventListener('click',()=>{
document.querySelectorAll('.nav-item').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));
btn.classList.add('active');
document.getElementById('page-'+btn.dataset.page).classList.add('active');
const [t,s]=titles[btn.dataset.page];
document.getElementById('page-title').textContent=t;
document.getElementById('page-sub').textContent=s;
}));
document.querySelectorAll('.toggle').forEach(t=>t.addEventListener('click',()=>t.classList.toggle('on')));
document.getElementById('reveal').addEventListener('click',e=>{
const tok=document.getElementById('tok');
const shown=tok.textContent.startsWith('•');
tok.textContent=shown?'sm_live_9f2c7ab41d06e8f3c5a7':'••••••••••••••••••••••••';
e.target.textContent=shown?'Hide':'Reveal';
});
</script>
</body>
</html>
+244
View File
@@ -0,0 +1,244 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SciMesh Worker · Setup</title>
<style>
:root{
--bg:#0b0e13;--panel:#11151d;--panel-2:#161c27;--border:#1f2634;--border-soft:#181f2b;
--text:#e9ecf3;--text-2:#98a2b5;--text-3:#5d6879;
--accent:#5b8cff;--accent-soft:#5b8cff1f;--accent-strong:#3f6fe0;
--green:#3fce8a;--green-soft:#3fce8a1a;--amber:#e5b64f;--amber-soft:#e5b64f1a;--red:#f2647c;--red-soft:#f2647c1a;
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,monospace;
color-scheme:dark;
}
*{box-sizing:border-box;margin:0;padding:0}
body{background:radial-gradient(900px 500px at 50% -180px,#16233d66,transparent),var(--bg);color:var(--text);font:14px/1.55 Inter,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;min-height:100vh}
button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
input{font:inherit;color:var(--text);background:var(--panel-2);border:1px solid var(--border);border-radius:9px;padding:10px 13px;width:100%;outline:none;transition:border-color .12s,box-shadow .12s}
input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
input::placeholder{color:var(--text-3)}
code{font-family:var(--mono);font-size:.86em}
.shell{max-width:660px;margin:0 auto;padding:44px 22px 70px}
.brand{display:flex;align-items:center;justify-content:center;gap:11px;margin-bottom:8px}
.brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,#5b8cff,#7c5cff);box-shadow:0 4px 16px #5b8cff40}
.brand-mark svg{width:18px;height:18px;stroke:#fff}
.brand-name{font-weight:700;font-size:16px;letter-spacing:-.01em}
.brand-name span{color:var(--text-3);font-weight:500}
.tagline{text-align:center;color:var(--text-3);font-size:12.5px;margin-bottom:34px}
.tagline code{color:var(--text-2)}
/* step indicator */
.steps{display:flex;align-items:center;justify-content:center;gap:0;margin-bottom:30px}
.step{display:flex;flex-direction:column;align-items:center;gap:7px;width:96px}
.step-dot{display:grid;place-items:center;width:30px;height:30px;border-radius:50%;border:1.5px solid var(--border);background:var(--panel);color:var(--text-3);font-size:12.5px;font-weight:700;transition:all .2s}
.step-label{font-size:11px;font-weight:600;color:var(--text-3);letter-spacing:.02em}
.step.active .step-dot{border-color:var(--accent);background:var(--accent-soft);color:var(--accent);box-shadow:0 0 0 4px #5b8cff14}
.step.active .step-label{color:var(--text)}
.step.done .step-dot{border-color:var(--green);background:var(--green-soft);color:var(--green)}
.step.done .step-label{color:var(--text-2)}
.step-line{flex:1;max-width:44px;height:1.5px;background:var(--border);margin:0 6px 22px;position:relative;overflow:hidden}
.step-line.done:after{content:"";position:absolute;inset:0;background:var(--green)}
.card{background:var(--panel);border:1px solid var(--border-soft);border-radius:15px;padding:26px 28px;box-shadow:0 24px 60px #0000004d}
.card h1{font-size:18px;font-weight:700;letter-spacing:-.02em;margin-bottom:4px}
.card .sub{color:var(--text-2);font-size:13px;margin-bottom:22px}
.field{margin-bottom:16px}
.field label{display:block;font-size:12px;font-weight:650;letter-spacing:.04em;text-transform:uppercase;color:var(--text-3);margin-bottom:7px}
.field .hint{margin-top:6px;font-size:12px;color:var(--text-3)}
.field .hint code{color:var(--text-2)}
.radio-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.radio-card{border:1px solid var(--border);border-radius:11px;padding:13px 14px;cursor:pointer;transition:all .13s;background:var(--panel-2)}
.radio-card:hover{border-color:#2a3446}
.radio-card.sel{border-color:var(--accent);background:var(--accent-soft);box-shadow:0 0 0 3px #5b8cff14}
.radio-card b{display:flex;align-items:center;gap:8px;font-size:13.5px}
.radio-card b svg{width:15px;height:15px;stroke:var(--accent)}
.radio-card p{margin-top:4px;font-size:12px;color:var(--text-2)}
.check-row{display:flex;align-items:center;gap:12px;padding:11px 14px;border:1px solid var(--border-soft);border-radius:10px;margin-bottom:9px;background:var(--panel-2)}
.check-ic{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;flex:none}
.check-ic svg{width:13px;height:13px;stroke-width:2.6}
.check-ok{background:var(--green-soft)}.check-ok svg{stroke:var(--green)}
.check-bad{background:var(--red-soft)}.check-bad svg{stroke:var(--red)}
.check-wait{background:#ffffff10}.check-wait svg{stroke:var(--text-3)}
.check-row b{font-size:13.5px;font-weight:600}
.check-row span{display:block;font-size:12px;color:var(--text-3)}
.check-row .ms{margin-left:auto;font:11.5px var(--mono);color:var(--text-3)}
.actions{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:24px}
.btn{display:inline-flex;align-items:center;gap:8px;border-radius:9px;padding:10px 18px;font-weight:650;font-size:13.5px;border:1px solid transparent;transition:all .13s}
.btn svg{width:15px;height:15px;stroke:currentColor}
.btn-primary{background:var(--accent);color:#0a1222}
.btn-primary:hover{background:var(--accent-strong);color:#fff}
.btn-ghost{border-color:var(--border);color:var(--text-2);background:var(--panel-2)}
.btn-ghost:hover{color:var(--text);border-color:#2a3446}
.btn-danger{background:var(--red-soft);color:var(--red)}
.btn-lg{padding:12px 24px;font-size:14.5px;border-radius:10px}
.link{color:var(--text-3);font-size:13px}
.link:hover{color:var(--text)}
/* status view */
.status-head{display:flex;align-items:center;gap:14px;margin-bottom:22px}
.pulse{position:relative;width:12px;height:12px;border-radius:50%;background:var(--green);flex:none}
.pulse:after{content:"";position:absolute;inset:-5px;border-radius:50%;border:2px solid var(--green);opacity:.5;animation:ping 1.6s ease-out infinite}
@keyframes ping{from{transform:scale(.6);opacity:.7}to{transform:scale(1.4);opacity:0}}
.status-head h1{font-size:19px}
.status-head .sub{font-size:12.5px;color:var(--text-3)}
.stat-row{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-bottom:18px}
.stat{background:var(--panel-2);border:1px solid var(--border-soft);border-radius:11px;padding:12px 14px}
.stat b{display:block;font-size:20px;font-weight:700;letter-spacing:-.02em}
.stat span{font-size:11px;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--text-3)}
.stat.bad b{color:var(--red)}
.logbox{background:#0a0d12;border:1px solid var(--border-soft);border-radius:11px;padding:14px 16px;font:12px/1.7 var(--mono);color:#8fa3bf;max-height:190px;overflow-y:auto}
.logbox .t{color:var(--text-3)}
.logbox .ok{color:var(--green)}
.logbox .hl{color:#c9d6ea}
.meta-line{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:99px;padding:4px 11px;font-size:12px;color:var(--text-2);background:var(--panel-2)}
.chip svg{width:12px;height:12px;stroke:var(--text-3)}
.wizard-page{display:none}.wizard-page.active{display:block;animation:fade .18s ease}
@keyframes fade{from{opacity:0;transform:translateY(5px)}to{opacity:1}}
.demo-toggle{position:fixed;bottom:14px;right:16px;font-size:11.5px;color:var(--text-3);background:var(--panel);border:1px solid var(--border);border-radius:99px;padding:6px 13px;opacity:.8}
.demo-toggle:hover{color:var(--text)}
</style>
</head>
<body>
<div class="shell">
<div class="brand">
<div class="brand-mark"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round"><circle cx="6" cy="6" r="2.4"/><circle cx="18" cy="7" r="2.4"/><circle cx="12" cy="17" r="2.4"/><path d="M8 7.5l7.6-1M7 8.3l3.7 6.5M16.6 9.2l-3.2 5.6"/></svg></div>
<div class="brand-name">SciMesh <span>· Worker setup</span></div>
</div>
<p class="tagline">Local wizard served by <code>worker-agent setup</code> · <code>127.0.0.1:12700</code></p>
<!-- ═══ WIZARD VIEW ═══ -->
<div id="view-wizard">
<div class="steps">
<div class="step done" id="st1"><div class="step-dot"></div><div class="step-label">Connect</div></div>
<div class="step-line done" id="sl1"></div>
<div class="step active" id="st2"><div class="step-dot">2</div><div class="step-label">Machine</div></div>
<div class="step-line" id="sl2"></div>
<div class="step" id="st3"><div class="step-dot">3</div><div class="step-label">Check</div></div>
<div class="step-line" id="sl3"></div>
<div class="step" id="st4"><div class="step-dot">4</div><div class="step-label">Run</div></div>
</div>
<!-- step 1 (kept in DOM for the mockup; step 2 shown) -->
<div class="wizard-page" id="wp1">
<div class="card">
<h1>Connect to a coordinator</h1>
<p class="sub">The coordinator hands out work and collects results. Ask your cluster admin for its address.</p>
<div class="field"><label>Coordinator URL</label><input value="http://192.168.1.10:8080" placeholder="http://192.168.1.10:8080"><p class="hint">For a served instance this is the address printed by <code>coordinator serve</code>.</p></div>
<div class="field"><label>Authentication</label>
<div class="radio-grid">
<div class="radio-card sel"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>Cluster token</b><p>Serve instances: one token for every worker.</p></div>
<div class="radio-card"><b><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="8" cy="14" r="4"/><path d="M10.8 11.2L20 2M15 4l3 3"/></svg>Worker key</b><p>Shared clusters: a key tied to your account.</p></div>
</div>
</div>
<div class="field"><label>Token</label><input type="password" value="sm_live_9f2c7ab41d06e8f3c5a7"><p class="hint">The admin can copy it from <code>coordinator token</code> on the server.</p></div>
<div class="actions"><span class="link">Step 1 of 4</span><button class="btn btn-primary" onclick="goto(2)">Continue →</button></div>
</div>
</div>
<!-- step 2 -->
<div class="wizard-page active" id="wp2">
<div class="card">
<h1>This machine</h1>
<p class="sub">Where tasks run and how the machine appears in the cluster.</p>
<div class="field"><label>Worker name</label><input value="emil-laptop"><p class="hint">Shown in the coordinators worker list.</p></div>
<div class="field"><label>Work directory</label><input value="/home/emil/scimesh-worker"><p class="hint">Datasets and shard results live here. ~1 GB free space recommended.</p></div>
<div class="field"><label>Compute resources advertised</label>
<div class="radio-grid">
<div class="radio-card sel"><b>8 CPUs · 16 GB</b><p>Detected automatically.</p></div>
<div class="radio-card"><b>Custom…</b><p>Limit what this machine advertises.</p></div>
</div>
</div>
<div class="actions"><button class="btn btn-ghost" onclick="goto(1)">← Back</button><button class="btn btn-primary" onclick="goto(3)">Continue →</button></div>
</div>
</div>
<!-- step 3 -->
<div class="wizard-page" id="wp3">
<div class="card">
<h1>Preflight check</h1>
<p class="sub">Making sure this machine can reach the coordinator and run SciMesh workloads.</p>
<div class="check-row"><div class="check-ic check-ok"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M4 12l5 5L20 6"/></svg></div><div><b>Coordinator reachable</b><span>http://192.168.1.10:8080 · coordinator 1.1.0-alpha.1</span></div><span class="ms">34 ms</span></div>
<div class="check-row"><div class="check-ic check-ok"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M4 12l5 5L20 6"/></svg></div><div><b>Token accepted</b><span>authenticated as cluster worker</span></div><span class="ms">51 ms</span></div>
<div class="check-row"><div class="check-ic check-ok"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M4 12l5 5L20 6"/></svg></div><div><b>Python 3.12 found</b><span>/usr/bin/python3</span></div><span class="ms">8 ms</span></div>
<div class="check-row"><div class="check-ic check-bad"><svg viewBox="0 0 24 24" fill="none" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg></div><div><b>scimesh package missing</b><span>run: pip install scimesh — or let the wizard install it</span></div><button class="btn btn-ghost" style="margin-left:auto;padding:6px 12px;font-size:12px">Install</button></div>
<div class="actions"><button class="btn btn-ghost" onclick="goto(2)">← Back</button><button class="btn btn-primary" onclick="goto(4)">Continue anyway →</button></div>
</div>
</div>
<!-- step 4 -->
<div class="wizard-page" id="wp4">
<div class="card" style="text-align:center;padding:40px 28px">
<div class="brand-mark" style="margin:0 auto 18px;width:46px;height:46px;border-radius:13px"><svg viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" style="width:22px;height:22px"><path d="M6 4l14 8-14 8V4z"/></svg></div>
<h1 style="font-size:20px">Ready to join the cluster</h1>
<p class="sub" style="max-width:380px;margin:8px auto 26px">The wizard will save your configuration to <code>~/.scimesh-worker/config.json</code> and start the worker as a background process.</p>
<div class="meta-line" style="justify-content:center">
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/></svg>emil-laptop</span>
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M4 6h16M4 12h16M4 18h16"/></svg>8 CPUs · 16 GB</span>
<span class="chip"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/></svg>4 workloads</span>
</div>
<div class="actions" style="justify-content:space-between;margin-top:30px"><button class="btn btn-ghost" onclick="goto(3)">← Back</button><button class="btn btn-primary btn-lg" onclick="showStatus()">Start worker</button></div>
</div>
</div>
</div>
<!-- ═══ STATUS VIEW ═══ -->
<div id="view-status" style="display:none">
<div class="card">
<div class="status-head">
<div class="pulse"></div>
<div><h1>emil-laptop is working</h1><div class="sub">worker id <code>w-01HZZ4T9C6</code> · registered with 192.168.1.10:8080 · up 42 min</div></div>
<button class="btn btn-danger" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>Stop</button>
</div>
<div class="stat-row">
<div class="stat"><b>14</b><span>claimed</span></div>
<div class="stat"><b>12</b><span>completed</span></div>
<div class="stat bad"><b>0</b><span>failed</span></div>
<div class="stat"><b>2.1s</b><span>avg shard</span></div>
</div>
<div class="meta-line">
<span class="chip">similarity-search</span><span class="chip">similarity-graph</span><span class="chip">descriptors</span><span class="chip">molwt-filter</span>
<span class="chip" style="margin-left:auto"><svg viewBox="0 0 24 24" fill="none" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>heartbeat 2 s ago</span>
</div>
<div class="logbox">
<div><span class="t">14:02:11</span> agent 1.1.0-alpha.1 · config ~/.scimesh-worker/config.json</div>
<div><span class="t">14:02:11</span> coordinator http://192.168.1.10:8080 · token auth</div>
<div><span class="t">14:02:12</span> <span class="ok">registered</span> as <span class="hl">emil-laptop</span> (w-01HZZ4T9C6)</div>
<div><span class="t">14:02:12</span> capabilities: similarity-search, similarity-graph, descriptors, molwt-filter</div>
<div><span class="t">14:03:40</span> claimed task <span class="hl">7f3a…c91e/007</span> · similarity-search</div>
<div><span class="t">14:03:42</span> <span class="ok">completed</span> 7f3a…c91e/007 · uploaded shard (2.1s)</div>
<div><span class="t">14:05:03</span> claimed task <span class="hl">7f3a…c91e/031</span> · similarity-search</div>
<div><span class="t">14:05:05</span> <span class="ok">completed</span> 7f3a…c91e/031 · uploaded shard (1.9s)</div>
</div>
<div class="actions"><span class="link">Configuration: <code>~/.scimesh-worker/config.json</code></span><button class="btn btn-ghost" onclick="showWizard()">Reconfigure…</button></div>
</div>
</div>
</div>
<button class="demo-toggle" onclick="toggleView()">mockup · toggle wizard / status</button>
<script>
const pages=['wp1','wp2','wp3','wp4'];
function goto(n){
pages.forEach((id,i)=>document.getElementById(id).classList.toggle('active',i===n-1));
for(let i=1;i<=4;i++){
const st=document.getElementById('st'+i);
st.classList.toggle('done',i<n);st.classList.toggle('active',i===n);
st.querySelector('.step-dot').textContent=i<n?'✓':i;
if(i<4)document.getElementById('sl'+i).classList.toggle('done',i<n);
}
}
function showStatus(){document.getElementById('view-wizard').style.display='none';document.getElementById('view-status').style.display='block'}
function showWizard(){document.getElementById('view-wizard').style.display='block';document.getElementById('view-status').style.display='none'}
function toggleView(){document.getElementById('view-status').style.display==='none'?showStatus():showWizard()}
document.querySelectorAll('.radio-grid').forEach(g=>g.querySelectorAll('.radio-card').forEach(c=>c.addEventListener('click',()=>{g.querySelectorAll('.radio-card').forEach(x=>x.classList.remove('sel'));c.classList.add('sel')})));
</script>
</body>
</html>