feat(bat-api): 实现内嵌 dashboard
bat-rust / Build and test Rust (push) Canceled after 0s
bat-rust / Build and test Go API (push) Canceled after 0s

Closes #46
This commit is contained in:
2026-08-31 23:17:23 +08:00
parent ab21344773
commit 4ed81f0030
30 changed files with 3527 additions and 91 deletions
+19 -8
View File
@@ -1,17 +1,28 @@
# bat-api Admin Panel
# bat-api Dashboard
本目录预留给后续 `bat-api` 管理面板。当前后端已提供只读入口
`GET /admin/`,返回 health、ready、bootstrap、release、resources 和 OpenAPI
链接。
当前稳定交付的 dashboard 静态资产位于 `web/` 根目录,并由 Go embed 挂载到
`GET /admin/dashboard/`。本目录只保留后续完整管理后台的说明入口。`GET /admin/`
仍返回 health、ready、bootstrap、release、resources、OpenAPI、dashboard 和可用
控制链接。
需要配置 `BAT_API_AUTH_TOKEN` 的接口:
- `GET /admin/diagnostics`:转发 `daemon.doctor`,读取 daemon 诊断。
- `GET /admin/logs`:转发 `daemon.logs`,按 `tail` 读取 daemon 日志尾部。
- `GET /admin/tasks``GET /admin/tasks/status``GET /admin/tasks/logs`
转发 `task.*` 查询任务列表、单项状态和任务日志。
- `GET /admin/schedules`:转发 Rust 持有的 schedule 查询,支持 `id``group`
`enabled` 过滤。
- `POST /admin/control/schedule-{add,update,remove,run}`:转发
`schedule.*` 计划控制。
- `GET /admin/parse/status``GET /admin/parse/text-units`
`GET /admin/parse/errors`:转发 `parse.*` 只读查询当前 release 的解析状态、
TextUnit 明细和解析错误。
- `POST /admin/control/task-cancel`:转发 `task.cancel`,请求字段为 `task_id`
- `POST /admin/control/translation-task-update`:转发
`translation.task.update`,供外部 provider 流程回写任务状态
`translation.task.update`,供外部 provider 流程或人工校对流程回写任务状态
`status=completed` 时可带 `provider``provider_run_id`
`translation_results`
- `POST /admin/control/translation-worker-run`:转发
`translation.worker.run`,触发 Rust provider worker。
- `POST /admin/control/translation-proofread`:转发
@@ -27,6 +38,6 @@
- `GET /admin/translation/handoff`:转发 `translation.handoff`,读取当前
release 的完整翻译交接视图。
正式前端必须复用资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应
`Cache-Control: no-store` 策略。静态前端资产仍为 not implemented,尚未作为
本目录的稳定交付物。当前组件边界见 `docs/reports/GO_STATUS.md`
内嵌 dashboard 自身允许免 token 读取静态资产;所有写操作和 Rust 状态查询仍复用
资源 API 的 HTTP 鉴权、限流、访问日志、反代处理和动态响应 `Cache-Control: no-store`
策略。当前组件边界见 `docs/reports/GO_STATUS.md`
+861
View File
@@ -0,0 +1,861 @@
'use strict';
const $ = (id) => document.getElementById(id);
const $$ = (selector, root = document) => Array.from(root.querySelectorAll(selector));
const app = {
baseUrl: defaultBaseUrl(),
token: localStorage.getItem('bat-api-token') || '',
rememberToken: localStorage.getItem('bat-api-remember-token') === 'true',
refreshTimer: null,
resources: { offset: 0, limit: 50, total: 0, items: [], filter: '' },
schedules: [],
tasks: [],
selectedDaemonTaskId: '',
translation: { tasks: [], selectedTask: null, unitRows: [] },
};
function defaultBaseUrl() {
const saved = localStorage.getItem('bat-api-base-url');
if (saved) return saved;
if (window.location.protocol === 'http:' || window.location.protocol === 'https:') {
return window.location.origin;
}
return 'http://localhost:8080';
}
function html(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
})[char]);
}
function attr(value) {
return html(value).replace(/`/g, '&#96;');
}
function text(id, value) {
const node = $(id);
if (node) node.textContent = value ?? '-';
}
function setHTML(id, value) {
const node = $(id);
if (node) node.innerHTML = value;
}
function apiBase() {
return (app.baseUrl || window.location.origin).replace(/\/+$/, '');
}
async function requestJSON(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const response = await fetch(apiBase() + path, { ...options, headers });
const body = await response.text();
let parsed = {};
if (body) {
try {
parsed = JSON.parse(body);
} catch (error) {
throw new Error(`${path} 返回了非 JSON 响应`);
}
}
if (!response.ok) {
const detail = parsed.error?.message || parsed.message || response.statusText;
throw new Error(`${path} HTTP ${response.status}: ${detail}`);
}
return parsed;
}
function postControl(action, payload = {}) {
return requestJSON(`/admin/control/${action}`, {
method: 'POST',
body: JSON.stringify(payload),
});
}
function requireToken(scope) {
if (app.token) return true;
toast(`${scope} 需要 admin token`, 'warn');
return false;
}
function fmtBytes(value) {
if (value === null || value === undefined) return '-';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = Number(value);
let idx = 0;
while (size >= 1024 && idx < units.length - 1) {
size /= 1024;
idx += 1;
}
const digits = idx === 0 || size >= 10 ? 0 : 1;
return `${size.toFixed(digits)} ${units[idx]}`;
}
function fmtTime(seconds) {
if (!seconds) return '-';
const date = new Date(Number(seconds) * 1000);
return Number.isNaN(date.getTime()) ? String(seconds) : date.toLocaleString();
}
function shortHash(value) {
if (!value) return '-';
const str = String(value);
return str.length > 18 ? `${str.slice(0, 10)}...${str.slice(-6)}` : str;
}
function statusPill(status) {
const value = String(status || 'unknown');
const tone = /(ok|ready|completed|published|healthy|success|translated)/i.test(value)
? 'ok'
: /(fail|error|missing|unavailable|rejected)/i.test(value)
? 'bad'
: /(run|sync|queued|review|proofreading|progress)/i.test(value)
? 'warn'
: 'neutral';
return `<span class="pill pill-${tone}">${html(value)}</span>`;
}
function emptyRow(columns, message) {
return `<tr><td colspan="${columns}" class="empty">${html(message)}</td></tr>`;
}
function toast(message, tone = 'ok') {
const item = document.createElement('div');
item.className = `toast toast-${tone}`;
item.textContent = message;
$('toastRegion').appendChild(item);
window.setTimeout(() => item.remove(), 4200);
}
function setConnection(status, detail) {
const dot = $('connectionDot');
dot.className = `state-dot state-${status}`;
text('connectionTitle', status === 'ok' ? '已连接' : status === 'warn' ? '需要 token' : status === 'loading' ? '刷新中' : '未连接');
text('connectionSubtitle', detail || apiBase());
}
function setAdminPlaceholders() {
text('metricLocalized', 'locked');
text('metricLocalizedNote', '需要 admin token');
text('taskSummary', '需要 admin token');
setHTML('dashboardTaskList', '<div class="empty-block">需要 admin token</div>');
text('scheduleSummary', '需要 admin token');
setHTML('scheduleList', '<div class="empty-block">需要 admin token</div>');
text('translationTaskSummary', '需要 admin token');
setHTML('translationTaskRows', emptyRow(4, '需要 admin token'));
text('daemonTaskSummary', '需要 admin token');
setHTML('daemonTaskRows', emptyRow(5, '需要 admin token'));
text('doctorSummary', '需要 admin token');
setHTML('doctorChecks', '<div class="empty-block">需要 admin token</div>');
text('logSummary', '需要 admin token');
text('daemonLogBox', '');
}
async function refreshAll() {
setConnection('loading', apiBase());
await Promise.allSettled([loadHealthAndRelease(), loadResources(true)]);
if (!app.token) {
setConnection('warn', '未配置 admin token');
setAdminPlaceholders();
return;
}
await Promise.allSettled([
loadSchedules(),
loadDaemonTasks(),
loadDoctor(),
loadDaemonLogs(),
loadTranslationStatus(),
loadTranslationTasks(),
loadHandoff(),
]);
}
async function loadHealthAndRelease() {
try {
const health = await requestJSON('/healthz');
text('metricHealth', health.ready ? 'ready' : 'not ready');
text('metricHealthNote', health.source || 'healthz');
text('metricRpc', health.rpc_available ? 'connected' : 'offline');
text('metricRpcNote', health.socket || '-');
text('metricResources', `${health.present_count ?? 0}/${health.entry_count ?? 0}`);
text('metricResourcesNote', `${health.missing_count ?? 0} missing`);
setConnection('ok', apiBase());
} catch (error) {
setConnection('bad', error.message);
toast(error.message, 'bad');
}
try {
const release = await requestJSON('/v1/release');
const snap = release.snapshot || {};
text('releaseSummary', `${snap.app_version || '-'} / ${snap.bundle_version || '-'} / ${release.source || '-'}`);
renderKV('releaseDetails', {
version_id: snap.version_id,
status_code: snap.status_code || release.status_code,
distribution_status_code: snap.distribution_status_code,
resource_root: release.resource_root,
addressables_root: snap.addressables_root,
manifest_version: release.manifest_version,
entry_count: release.entry_count,
present_count: release.present_count,
missing_count: release.missing_count,
rpc_available: release.rpc_available,
doctor_healthy: release.doctor_healthy,
});
} catch (error) {
text('releaseSummary', error.message);
setHTML('releaseDetails', '');
}
}
function renderKV(id, values) {
const rows = Object.entries(values)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `<dt>${html(key)}</dt><dd>${html(String(value))}</dd>`)
.join('');
setHTML(id, rows || '<dd class="empty">无数据</dd>');
}
async function loadResources(sampleOnly = false) {
const limit = sampleOnly ? 8 : app.resources.limit;
const offset = sampleOnly ? 0 : app.resources.offset;
try {
const data = await requestJSON(`/v1/resources?offset=${offset}&limit=${limit}`);
const items = data.items || [];
if (sampleOnly) {
setHTML('dashboardResourceRows', renderResourceRows(items));
text('resourceSampleSummary', `${items.length}/${data.total ?? items.length}`);
return;
}
app.resources.items = items;
app.resources.total = data.total ?? items.length;
text('resourceListSummary', `${app.resources.total} entries`);
renderResourceList();
} catch (error) {
const target = sampleOnly ? 'dashboardResourceRows' : 'resourceRows';
setHTML(target, emptyRow(4, error.message));
if (!sampleOnly) text('resourceListSummary', error.message);
}
}
function renderResourceRows(items) {
if (!items.length) return emptyRow(4, '没有资源记录');
return items.map((item) => `
<tr>
<td><code>${html(item.relative_path || item.destination || '-')}</code></td>
<td>${html(fmtBytes(item.bytes))}</td>
<td>${statusPill(item.present && item.size_match ? 'present' : item.present ? 'size_mismatch' : 'missing')}</td>
<td><code>${html(shortHash(item.blake3))}</code></td>
</tr>
`).join('');
}
function renderResourceList() {
const needle = app.resources.filter.toLowerCase();
const items = needle
? app.resources.items.filter((item) => String(item.relative_path || item.destination || '').toLowerCase().includes(needle))
: app.resources.items;
setHTML('resourceRows', renderResourceRows(items));
const start = app.resources.total === 0 ? 0 : app.resources.offset + 1;
const end = Math.min(app.resources.offset + app.resources.items.length, app.resources.total);
text('resourcePagerText', `${start}-${end} / ${app.resources.total}`);
$('resourcePrevBtn').disabled = app.resources.offset === 0;
$('resourceNextBtn').disabled = app.resources.offset + app.resources.limit >= app.resources.total;
}
async function loadSchedules() {
if (!app.token) return setAdminPlaceholders();
const query = new URLSearchParams();
const id = $('scheduleIdFilter').value.trim();
const group = $('scheduleGroupFilter').value.trim();
const enabled = $('scheduleEnabledFilter').value;
if (id) query.set('id', id);
if (group) query.set('group', group);
if (enabled) query.set('enabled', enabled);
try {
const data = await requestJSON(`/admin/schedules${query.size ? `?${query}` : ''}`);
app.schedules = normalizeList(data, ['schedules', 'items', 'entries']);
text('scheduleSummary', `${app.schedules.length} schedules`);
setHTML('scheduleList', renderScheduleItems(app.schedules));
} catch (error) {
text('scheduleSummary', error.message);
setHTML('scheduleList', `<div class="empty-block">${html(error.message)}</div>`);
}
}
function normalizeList(data, keys) {
if (Array.isArray(data)) return data;
for (const key of keys) {
if (Array.isArray(data?.[key])) return data[key];
}
return [];
}
function renderScheduleItems(items) {
if (!items.length) return '<div class="empty-block">没有计划</div>';
return items.map((item) => {
const extras = [
item.every_seconds ? `every ${item.every_seconds}s` : '',
item.next_run_unix_seconds ? `next ${fmtTime(item.next_run_unix_seconds)}` : '',
item.remaining_count ? `left ${item.remaining_count}` : '',
].filter(Boolean).join(' / ');
return `
<article class="list-item">
<div>
<strong>${html(item.id || '-')}</strong>
<div class="muted">${html(item.group || '-')} ${extras ? `/ ${html(extras)}` : ''}</div>
</div>
<div class="row-actions">
${statusPill(item.enabled ? 'enabled' : 'disabled')}
<button class="btn btn-small" type="button" data-schedule-run="${attr(item.id || '')}">运行</button>
<button class="btn btn-small btn-danger" type="button" data-schedule-remove="${attr(item.id || '')}">删除</button>
</div>
</article>
`;
}).join('');
}
function schedulePayload() {
const id = $('scheduleIdInput').value.trim();
if (!id) throw new Error('schedule id 不能为空');
const payload = { id, enabled: $('scheduleEnabledInput').checked };
const group = $('scheduleGroupInput').value.trim();
const action = $('scheduleActionInput').value.trim();
const args = $('scheduleArgsInput').value.split(/\n|,/).map((arg) => arg.trim()).filter(Boolean);
if (group) payload.group = group;
if (action) payload.action = action;
if (args.length) payload.args = args;
for (const [field, idName] of [
['delay_seconds', 'scheduleDelayInput'],
['every_seconds', 'scheduleEveryInput'],
['count', 'scheduleCountInput'],
['next_run_unix_seconds', 'scheduleNextRunInput'],
]) {
const raw = $(idName).value.trim();
if (raw) payload[field] = Number(raw);
}
return payload;
}
async function mutateSchedule(action, payload) {
if (!requireToken('调度')) return;
try {
const result = await postControl(action, payload);
toast(`${action} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`);
await loadSchedules();
} catch (error) {
toast(error.message, 'bad');
}
}
async function loadDaemonTasks() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/tasks');
app.tasks = data.tasks || [];
text('daemonTaskSummary', `${app.tasks.length} tasks`);
text('taskSummary', `${app.tasks.length} tasks`);
setHTML('daemonTaskRows', renderDaemonTaskRows(app.tasks));
setHTML('dashboardTaskList', renderDashboardTaskList(app.tasks.slice(0, 5)));
} catch (error) {
text('daemonTaskSummary', error.message);
text('taskSummary', error.message);
setHTML('daemonTaskRows', emptyRow(5, error.message));
setHTML('dashboardTaskList', `<div class="empty-block">${html(error.message)}</div>`);
}
}
function renderDaemonTaskRows(tasks) {
if (!tasks.length) return emptyRow(5, '没有任务记录');
return tasks.map((task) => `
<tr class="clickable" data-task-id="${attr(task.id)}">
<td><code>${html(task.id)}</code></td>
<td>${html(task.kind || '-')}</td>
<td>${statusPill(task.status)}</td>
<td>${html(task.stage || '-')}</td>
<td>${html(fmtTime(task.updated_at))}</td>
</tr>
`).join('');
}
function renderDashboardTaskList(tasks) {
if (!tasks.length) return '<div class="empty-block">没有任务记录</div>';
return tasks.map((task) => `
<article class="list-item compact-item">
<div>
<strong>${html(task.kind || task.id)}</strong>
<div class="muted">${html(task.id)} / ${html(task.stage || '-')}</div>
</div>
${statusPill(task.status)}
</article>
`).join('');
}
async function selectDaemonTask(taskId) {
app.selectedDaemonTaskId = taskId;
text('daemonTaskDetailSummary', taskId);
await Promise.allSettled([loadDaemonTaskStatus(taskId), loadDaemonTaskLogs(taskId)]);
}
async function loadDaemonTaskStatus(taskId) {
try {
const task = await requestJSON(`/admin/tasks/status?task_id=${encodeURIComponent(taskId)}`);
renderKV('daemonTaskDetails', {
id: task.id,
kind: task.kind,
status: task.status,
stage: task.stage,
message: task.message,
created_at: fmtTime(task.created_at),
updated_at: fmtTime(task.updated_at),
started_at: fmtTime(task.started_at),
finished_at: fmtTime(task.finished_at),
error: task.error?.message,
});
} catch (error) {
setHTML('daemonTaskDetails', `<dd class="empty">${html(error.message)}</dd>`);
}
}
async function loadDaemonTaskLogs(taskId) {
try {
const logs = await requestJSON(`/admin/tasks/logs?task_id=${encodeURIComponent(taskId)}`);
text('daemonTaskLogBox', (logs.lines || []).join('\n'));
} catch (error) {
text('daemonTaskLogBox', error.message);
}
}
async function loadDoctor() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/diagnostics');
text('doctorSummary', data.healthy ? 'healthy' : (data.status || 'unhealthy'));
const checks = data.checks || [];
setHTML('doctorChecks', checks.length ? checks.map((check) => `
<article class="list-item compact-item">
<div>
<strong>${html(check.name || '-')}</strong>
<div class="muted">${html(check.message || '-')}</div>
</div>
${statusPill(check.ok ? 'ok' : 'failed')}
</article>
`).join('') : '<div class="empty-block">没有诊断项</div>');
} catch (error) {
text('doctorSummary', error.message);
setHTML('doctorChecks', `<div class="empty-block">${html(error.message)}</div>`);
}
}
async function loadDaemonLogs() {
if (!app.token) return setAdminPlaceholders();
const tail = Math.min(2000, Math.max(1, Number($('logTailInput').value || 200)));
try {
const data = await requestJSON(`/admin/logs?tail=${tail}`);
text('logSummary', `${data.returned_lines ?? 0}/${data.total_lines ?? 0} lines`);
text('daemonLogBox', data.content || '');
} catch (error) {
text('logSummary', error.message);
text('daemonLogBox', error.message);
}
}
async function loadTranslationStatus() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/translation/status');
const code = data.status_code || data.localized_status_code || data.localized_release_status || data.status || 'unknown';
text('metricLocalized', code);
text('metricLocalizedNote', data.current_localized_release_id || data.localized_release_id || data.message || '-');
text('localizedSummary', `${code} / ${data.localized_release_id || data.current_localized_release_id || '-'}`);
} catch (error) {
text('metricLocalized', 'error');
text('metricLocalizedNote', error.message);
text('localizedSummary', error.message);
}
}
async function loadHandoff() {
if (!app.token) return setAdminPlaceholders();
try {
const data = await requestJSON('/admin/translation/handoff');
const handoff = data.handoff || data;
text('handoffSummary', handoff.job?.status || data.status || (data.available === false ? 'unavailable' : 'available'));
renderKV('handoffDetails', {
available: data.available,
current_version_id: data.current_version_id,
job_id: handoff.job?.job_id,
job_status: handoff.job?.status,
unit_count: handoff.job?.unit_count,
provider_runs: handoff.provider_runs?.length,
updated_unix_seconds: fmtTime(handoff.job?.updated_unix_seconds),
});
} catch (error) {
text('handoffSummary', error.message);
setHTML('handoffDetails', '');
}
}
async function loadTranslationTasks() {
if (!app.token) return setAdminPlaceholders();
const query = new URLSearchParams({ offset: '0', limit: '50' });
const workerStatus = $('translationStatusFilter').value;
const destination = $('translationDestinationFilter').value.trim();
const taskId = $('translationTaskFilter').value.trim();
if (workerStatus) query.set('worker_status', workerStatus);
if (destination) query.set('destination', destination);
if (taskId) query.set('task_id', taskId);
try {
const data = await requestJSON(`/admin/translation/tasks?${query}`);
app.translation.tasks = data.entries || [];
text('translationTaskSummary', `${data.total_entries ?? app.translation.tasks.length} tasks`);
setHTML('translationTaskRows', renderTranslationTaskRows(app.translation.tasks));
} catch (error) {
text('translationTaskSummary', error.message);
setHTML('translationTaskRows', emptyRow(4, error.message));
}
}
function taskCore(entry) {
return entry?.task || entry || {};
}
function taskID(entry) {
return taskCore(entry).task_id || entry?.task_id || entry?.unit_id || '';
}
function renderTranslationTaskRows(tasks) {
if (!tasks.length) return emptyRow(4, '没有翻译任务');
return tasks.map((entry) => {
const task = taskCore(entry);
const resultCount = (entry.translation_results || task.translation_results || []).length;
return `
<tr class="clickable" data-translation-task-id="${attr(taskID(entry))}">
<td><code>${html(taskID(entry))}</code></td>
<td>${html(task.destination || '-')}<div class="muted">${html(task.archive_entry || '')}</div></td>
<td>${statusPill(entry.task_status || entry.worker_status || task.status)}</td>
<td>${html(String(resultCount))}</td>
</tr>
`;
}).join('');
}
async function selectTranslationTask(entry) {
app.translation.selectedTask = entry;
const task = taskCore(entry);
text('translationDetailSummary', taskID(entry));
$('manualProviderRunInput').value = entry.provider_run_id || '';
await loadTextUnitsForTask(entry);
}
async function loadTextUnitsForTask(entry) {
const task = taskCore(entry);
const existingResults = new Map((entry.translation_results || []).map((result) => [result.unit_id, result]));
let units = [];
if (task.destination) {
const query = new URLSearchParams({ destination: task.destination, limit: '1000' });
if (task.archive_entry) query.set('archive_entry', task.archive_entry);
try {
const data = await requestJSON(`/admin/parse/text-units?${query}`);
units = data.entries || [];
} catch (error) {
toast(error.message, 'bad');
}
}
if (!units.length && existingResults.size) {
units = Array.from(existingResults.values()).map((result) => ({
id: result.unit_id,
source_text: result.source_text,
destination: task.destination,
archive_entry: task.archive_entry,
}));
}
app.translation.unitRows = units.map((unit) => {
const id = unit.id || unit.unit_id;
const existing = existingResults.get(id) || {};
return {
id,
sourceText: unit.source_text || existing.source_text || '',
translatedText: existing.translated_text || '',
destination: unit.destination || task.destination || '',
archiveEntry: unit.archive_entry || task.archive_entry || '',
serializedFile: unit.serialized_file || '',
pathID: unit.path_id,
classID: unit.class_id,
fieldPath: unit.field_path || '',
assetName: unit.asset_name || '',
format: unit.format || '',
};
});
renderUnitEditor();
}
function renderUnitEditor() {
const rows = app.translation.unitRows;
if (!app.translation.selectedTask) {
setHTML('unitEditor', '<div class="empty-block">选择一个翻译任务</div>');
return;
}
if (!rows.length) {
setHTML('unitEditor', '<div class="empty-block">当前任务没有可显示 TextUnit 明细</div>');
return;
}
setHTML('unitEditor', rows.map((row, index) => `
<article class="unit-card">
<div class="unit-head">
<div>
<strong>${html(row.id)}</strong>
<div class="muted">${html(row.destination)}${row.archiveEntry ? ` / ${html(row.archiveEntry)}` : ''}</div>
</div>
${statusPill(row.translatedText ? 'reviewing' : 'empty')}
</div>
<div class="unit-meta">
<span>field: ${html(row.fieldPath || '-')}</span>
<span>asset: ${html(row.assetName || '-')}</span>
<span>path_id: ${html(row.pathID ?? '-')}</span>
<span>class_id: ${html(row.classID ?? '-')}</span>
<span>format: ${html(row.format || '-')}</span>
</div>
<label class="source-label">Source
<textarea readonly rows="3">${html(row.sourceText)}</textarea>
</label>
<label>Current translation
<textarea class="unit-translation" data-unit-index="${index}" rows="4">${html(row.translatedText)}</textarea>
</label>
</article>
`).join(''));
}
async function saveManualResults() {
if (!requireToken('人工校对')) return;
const entry = app.translation.selectedTask;
if (!entry) return toast('请选择翻译任务', 'warn');
const results = $$('.unit-translation').map((editor) => {
const row = app.translation.unitRows[Number(editor.dataset.unitIndex)];
return {
unit_id: row.id,
source_text: row.sourceText,
translated_text: editor.value,
};
}).filter((result) => result.translated_text.trim() !== '');
if (!results.length) return toast('没有可保存的译文', 'warn');
const payload = {
task_id: taskID(entry),
status: 'completed',
provider: 'manual',
translation_results: results,
};
const providerRunID = $('manualProviderRunInput').value.trim();
if (providerRunID) payload.provider_run_id = providerRunID;
try {
await postControl('translation-task-update', payload);
toast(`已保存 ${results.length} 条校对结果`);
await Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()]);
} catch (error) {
toast(error.message, 'bad');
}
}
async function runTranslationWorker() {
if (!requireToken('Provider worker')) return;
const payload = {
provider: $('workerProviderInput').value,
concurrency: Number($('workerConcurrencyInput').value || 8),
max_attempts: Number($('workerMaxAttemptsInput').value || 3),
lease_seconds: Number($('workerLeaseInput').value || 300),
retry_backoff_seconds: Number($('workerBackoffInput').value || 5),
};
const maxTasks = $('workerMaxTasksInput').value.trim();
const workerId = $('workerIdInput').value.trim();
const fixture = $('workerFixtureInput').value.trim();
if (maxTasks) payload.max_tasks = Number(maxTasks);
if (workerId) payload.worker_id = workerId;
if (fixture) payload.fixture_path = fixture;
try {
const result = await postControl('translation-worker-run', payload);
toast(`translation-worker-run accepted: ${result.result?.task_id || 'queued'}`);
await loadDaemonTasks();
} catch (error) {
toast(error.message, 'bad');
}
}
async function publishLocalized() {
if (!requireToken('汉化发布')) return;
const payload = { from_worker: true, force: $('localizedForceInput').checked };
const releaseID = $('localizedReleaseInput').value.trim();
if (releaseID) payload.localized_release_id = releaseID;
try {
await postControl('localized-publish', payload);
toast('localized-publish accepted');
await loadTranslationStatus();
} catch (error) {
toast(error.message, 'bad');
}
}
async function rollbackLocalized() {
if (!requireToken('汉化回滚')) return;
const payload = {};
const releaseID = $('localizedReleaseInput').value.trim();
if (releaseID) payload.localized_release_id = releaseID;
if (!window.confirm('确认回滚当前汉化 release?')) return;
try {
await postControl('localized-rollback', payload);
toast('localized-rollback accepted');
await loadTranslationStatus();
} catch (error) {
toast(error.message, 'bad');
}
}
function switchView(view) {
$$('.nav-item').forEach((item) => item.classList.toggle('is-active', item.dataset.view === view));
$$('.view').forEach((section) => section.classList.toggle('is-active', section.id === `view-${view}`));
const active = $(`view-${view}`);
text('viewTitle', active?.dataset.title || view);
text('viewSubtitle', active?.dataset.subtitle || '');
if (view === 'resources') loadResources();
if (view === 'schedules') loadSchedules();
if (view === 'translation') Promise.allSettled([loadTranslationTasks(), loadTranslationStatus(), loadHandoff()]);
if (view === 'tasks') loadDaemonTasks();
if (view === 'logs') Promise.allSettled([loadDoctor(), loadDaemonLogs()]);
}
function bindEvents() {
$$('.nav-item').forEach((item) => item.addEventListener('click', () => switchView(item.dataset.view)));
$$('[data-jump]').forEach((button) => button.addEventListener('click', () => switchView(button.dataset.jump)));
$('refreshAllBtn').addEventListener('click', refreshAll);
$('autoRefreshInput').addEventListener('change', configureAutoRefresh);
$('resourceLimitInput').addEventListener('change', () => {
app.resources.limit = Number($('resourceLimitInput').value || 50);
app.resources.offset = 0;
loadResources();
});
$('resourceFilterInput').addEventListener('input', () => {
app.resources.filter = $('resourceFilterInput').value.trim();
renderResourceList();
});
$('resourcePrevBtn').addEventListener('click', () => {
app.resources.offset = Math.max(0, app.resources.offset - app.resources.limit);
loadResources();
});
$('resourceNextBtn').addEventListener('click', () => {
app.resources.offset += app.resources.limit;
loadResources();
});
$$('[data-control]').forEach((button) => button.addEventListener('click', async () => {
if (!requireToken(button.dataset.control)) return;
try {
const result = await postControl(button.dataset.control, button.dataset.control === 'sync' ? { force: false } : {});
toast(`${button.dataset.control} accepted: ${result.result?.task_id || result.rpc_method || 'ok'}`);
await refreshAll();
} catch (error) {
toast(error.message, 'bad');
}
}));
$('scheduleRefreshBtn').addEventListener('click', loadSchedules);
['scheduleIdFilter', 'scheduleGroupFilter', 'scheduleEnabledFilter'].forEach((id) => $(id).addEventListener('input', loadSchedules));
$('scheduleAddBtn').addEventListener('click', () => {
try { mutateSchedule('schedule-add', schedulePayload()); } catch (error) { toast(error.message, 'warn'); }
});
$('scheduleUpdateBtn').addEventListener('click', () => {
try { mutateSchedule('schedule-update', schedulePayload()); } catch (error) { toast(error.message, 'warn'); }
});
$('scheduleList').addEventListener('click', (event) => {
const runID = event.target.closest('[data-schedule-run]')?.dataset.scheduleRun;
const removeID = event.target.closest('[data-schedule-remove]')?.dataset.scheduleRemove;
if (runID) mutateSchedule('schedule-run', { id: runID, force: true });
if (removeID && window.confirm(`删除计划 ${removeID}?`)) mutateSchedule('schedule-remove', { id: removeID });
});
$('daemonTaskRefreshBtn').addEventListener('click', loadDaemonTasks);
$('daemonTaskRows').addEventListener('click', (event) => {
const row = event.target.closest('[data-task-id]');
if (row) selectDaemonTask(row.dataset.taskId);
});
$('daemonTaskCancelBtn').addEventListener('click', async () => {
if (!app.selectedDaemonTaskId || !requireToken('任务取消')) return;
try {
await postControl('task-cancel', { task_id: app.selectedDaemonTaskId });
toast('task.cancel accepted');
await selectDaemonTask(app.selectedDaemonTaskId);
} catch (error) {
toast(error.message, 'bad');
}
});
$('doctorRefreshBtn').addEventListener('click', () => Promise.allSettled([loadDoctor(), loadDaemonLogs()]));
$('logTailInput').addEventListener('change', loadDaemonLogs);
$('translationRefreshBtn').addEventListener('click', () => Promise.allSettled([loadTranslationTasks(), loadHandoff(), loadTranslationStatus()]));
['translationStatusFilter', 'translationDestinationFilter', 'translationTaskFilter'].forEach((id) => $(id).addEventListener('input', loadTranslationTasks));
$('translationTaskRows').addEventListener('click', (event) => {
const row = event.target.closest('[data-translation-task-id]');
if (!row) return;
const entry = app.translation.tasks.find((item) => taskID(item) === row.dataset.translationTaskId);
if (entry) selectTranslationTask(entry);
});
$('workerRunBtn').addEventListener('click', runTranslationWorker);
$('manualSaveBtn').addEventListener('click', saveManualResults);
$('proofreadBtn').addEventListener('click', async () => {
if (!requireToken('人工校对状态')) return;
try {
await postControl('translation-proofread', {});
toast('translation.proofread accepted');
await loadTranslationStatus();
} catch (error) {
toast(error.message, 'bad');
}
});
$('localizedPublishBtn').addEventListener('click', publishLocalized);
$('localizedRollbackBtn').addEventListener('click', rollbackLocalized);
$('saveSettingsBtn').addEventListener('click', () => {
app.baseUrl = $('baseUrlInput').value.trim() || defaultBaseUrl();
app.token = $('tokenInput').value.trim();
app.rememberToken = $('rememberTokenInput').checked;
localStorage.setItem('bat-api-base-url', app.baseUrl);
localStorage.setItem('bat-api-remember-token', String(app.rememberToken));
if (app.rememberToken && app.token) localStorage.setItem('bat-api-token', app.token);
if (!app.rememberToken) localStorage.removeItem('bat-api-token');
refreshAll();
});
$('forgetSettingsBtn').addEventListener('click', () => {
localStorage.removeItem('bat-api-base-url');
localStorage.removeItem('bat-api-token');
localStorage.removeItem('bat-api-remember-token');
app.baseUrl = defaultBaseUrl();
app.token = '';
app.rememberToken = false;
syncSettingsForm();
refreshAll();
});
}
function syncSettingsForm() {
$('baseUrlInput').value = app.baseUrl;
$('tokenInput').value = app.token;
$('rememberTokenInput').checked = app.rememberToken;
}
function configureAutoRefresh() {
if (app.refreshTimer) window.clearInterval(app.refreshTimer);
app.refreshTimer = null;
if ($('autoRefreshInput').checked) {
app.refreshTimer = window.setInterval(refreshAll, 15000);
}
}
syncSettingsForm();
bindEvents();
configureAutoRefresh();
refreshAll();
+8
View File
@@ -0,0 +1,8 @@
package web
import "embed"
// Assets 是 bat-api dashboard 的无构建静态资源。
//
//go:embed index.html app.js styles.css
var Assets embed.FS
+421
View File
@@ -0,0 +1,421 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bat-api Dashboard - BlueArchiveToolkit</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="brand">
<div class="brand-mark" aria-hidden="true">BA</div>
<div>
<div class="brand-title">bat-api Dashboard</div>
<div class="brand-subtitle">BlueArchiveToolkit</div>
</div>
</div>
<nav class="nav" aria-label="dashboard navigation">
<button class="nav-item is-active" type="button" data-view="dashboard">总览</button>
<button class="nav-item" type="button" data-view="resources">资源</button>
<button class="nav-item" type="button" data-view="schedules">调度</button>
<button class="nav-item" type="button" data-view="translation">翻译</button>
<button class="nav-item" type="button" data-view="tasks">任务</button>
<button class="nav-item" type="button" data-view="logs">日志</button>
<button class="nav-item" type="button" data-view="settings">设置</button>
</nav>
<div class="connection-box">
<span class="state-dot" id="connectionDot"></span>
<div>
<div class="connection-title" id="connectionTitle">未连接</div>
<div class="connection-subtitle" id="connectionSubtitle">等待刷新</div>
</div>
</div>
</aside>
<main class="main">
<header class="topbar">
<div>
<h1 id="viewTitle">总览</h1>
<p id="viewSubtitle">读取 bat-api 与 Rust daemon 的当前状态。</p>
</div>
<div class="topbar-actions">
<label class="inline-check">
<input id="autoRefreshInput" type="checkbox" checked>
自动刷新
</label>
<button class="btn btn-primary" id="refreshAllBtn" type="button">刷新</button>
</div>
</header>
<section class="view is-active" id="view-dashboard" data-title="总览" data-subtitle="读取 bat-api 与 Rust daemon 的当前状态。">
<div class="status-grid">
<div class="metric">
<div class="metric-label">bat-api</div>
<div class="metric-value" id="metricHealth">unknown</div>
<div class="metric-note" id="metricHealthNote">-</div>
</div>
<div class="metric">
<div class="metric-label">Rust RPC</div>
<div class="metric-value" id="metricRpc">unknown</div>
<div class="metric-note" id="metricRpcNote">-</div>
</div>
<div class="metric">
<div class="metric-label">资源完整度</div>
<div class="metric-value" id="metricResources">-</div>
<div class="metric-note" id="metricResourcesNote">-</div>
</div>
<div class="metric">
<div class="metric-label">汉化状态</div>
<div class="metric-value" id="metricLocalized">unknown</div>
<div class="metric-note" id="metricLocalizedNote">需要 admin token</div>
</div>
</div>
<div class="grid two-col">
<section class="panel">
<div class="panel-head">
<div>
<h2>当前 release</h2>
<p id="releaseSummary">尚未读取</p>
</div>
</div>
<dl class="kv-list" id="releaseDetails"></dl>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>最近任务</h2>
<p id="taskSummary">需要 admin token</p>
</div>
<button class="btn btn-small" type="button" data-jump="tasks">打开任务</button>
</div>
<div class="list" id="dashboardTaskList"></div>
</section>
</div>
<section class="panel">
<div class="panel-head">
<div>
<h2>资源样本</h2>
<p id="resourceSampleSummary">读取 /v1/resources</p>
</div>
<button class="btn btn-small" type="button" data-jump="resources">查看资源</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>路径</th>
<th>大小</th>
<th>状态</th>
<th>BLAKE3</th>
</tr>
</thead>
<tbody id="dashboardResourceRows"></tbody>
</table>
</div>
</section>
</section>
<section class="view" id="view-resources" data-title="资源" data-subtitle="查看当前发布 release 的资源清单,并触发同步、校验或修复。">
<section class="panel">
<div class="panel-head split">
<div>
<h2>资源清单</h2>
<p id="resourceListSummary">-</p>
</div>
<div class="row-actions">
<button class="btn" type="button" data-control="sync">同步</button>
<button class="btn" type="button" data-control="verify">校验</button>
<button class="btn btn-danger" type="button" data-control="repair">修复</button>
</div>
</div>
<div class="filter-row">
<input id="resourceFilterInput" type="search" placeholder="过滤已加载路径">
<select id="resourceLimitInput">
<option value="20">20</option>
<option value="50" selected>50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>路径</th>
<th>大小</th>
<th>状态</th>
<th>BLAKE3</th>
</tr>
</thead>
<tbody id="resourceRows"></tbody>
</table>
</div>
<div class="pager">
<span id="resourcePagerText">-</span>
<div>
<button class="btn btn-small" id="resourcePrevBtn" type="button">上一页</button>
<button class="btn btn-small" id="resourceNextBtn" type="button">下一页</button>
</div>
</div>
</section>
</section>
<section class="view" id="view-schedules" data-title="调度" data-subtitle="管理 Rust 持久化 schedule,覆盖资源拉取、解析、翻译和发布任务。">
<div class="grid two-col">
<section class="panel">
<div class="panel-head">
<div>
<h2>计划列表</h2>
<p id="scheduleSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="scheduleRefreshBtn" type="button">刷新</button>
</div>
<div class="filter-row">
<input id="scheduleIdFilter" type="search" placeholder="id">
<input id="scheduleGroupFilter" type="search" placeholder="group: res / parse / i18n">
<select id="scheduleEnabledFilter">
<option value="">全部</option>
<option value="true">启用</option>
<option value="false">停用</option>
</select>
</div>
<div class="list" id="scheduleList"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>新增或更新</h2>
<p>schedule.add / schedule.update</p>
</div>
</div>
<form class="form" id="scheduleForm">
<label>ID <input id="scheduleIdInput" autocomplete="off"></label>
<label>Group <input id="scheduleGroupInput" placeholder="res"></label>
<label>Command <input id="scheduleActionInput" placeholder="res sync"></label>
<label>Args <textarea id="scheduleArgsInput" rows="3" placeholder="每行一个参数"></textarea></label>
<div class="form-grid">
<label>Delay seconds <input id="scheduleDelayInput" type="number" min="0"></label>
<label>Every seconds <input id="scheduleEveryInput" type="number" min="1"></label>
<label>Count <input id="scheduleCountInput" type="number" min="1"></label>
<label>Next run unix <input id="scheduleNextRunInput" type="number" min="0"></label>
</div>
<label class="inline-check"><input id="scheduleEnabledInput" type="checkbox" checked>启用</label>
<div class="row-actions">
<button class="btn btn-primary" id="scheduleAddBtn" type="button">新增</button>
<button class="btn" id="scheduleUpdateBtn" type="button">更新</button>
</div>
</form>
</section>
</div>
</section>
<section class="view" id="view-translation" data-title="翻译" data-subtitle="查看翻译任务、触发 provider worker、编辑人工校对结果并发布汉化资源。">
<div class="grid two-col">
<section class="panel">
<div class="panel-head split">
<div>
<h2>翻译任务</h2>
<p id="translationTaskSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="translationRefreshBtn" type="button">刷新</button>
</div>
<div class="filter-row">
<select id="translationStatusFilter">
<option value="">全部状态</option>
<option value="queued">queued</option>
<option value="running">running</option>
<option value="failed">failed</option>
<option value="completed">completed</option>
<option value="skipped">skipped</option>
</select>
<input id="translationDestinationFilter" type="search" placeholder="destination">
<input id="translationTaskFilter" type="search" placeholder="task_id">
</div>
<div class="table-wrap compact">
<table>
<thead>
<tr>
<th>任务</th>
<th>资源</th>
<th>状态</th>
<th>结果</th>
</tr>
</thead>
<tbody id="translationTaskRows"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>Provider worker</h2>
<p>translation.worker.run</p>
</div>
</div>
<form class="form">
<div class="form-grid">
<label>Provider
<select id="workerProviderInput">
<option value="mock">mock</option>
<option value="crowdin">crowdin</option>
</select>
</label>
<label>Concurrency <input id="workerConcurrencyInput" type="number" min="1" max="256" value="8"></label>
<label>Max attempts <input id="workerMaxAttemptsInput" type="number" min="1" value="3"></label>
<label>Lease seconds <input id="workerLeaseInput" type="number" min="1" value="300"></label>
<label>Backoff seconds <input id="workerBackoffInput" type="number" min="0" value="5"></label>
<label>Max tasks <input id="workerMaxTasksInput" type="number" min="1"></label>
</div>
<label>Worker ID <input id="workerIdInput" placeholder="dashboard-worker"></label>
<label>Fixture path <input id="workerFixtureInput" placeholder="/tmp/mock-provider.json"></label>
<button class="btn btn-primary" id="workerRunBtn" type="button">启动 worker</button>
</form>
</section>
</div>
<section class="panel">
<div class="panel-head split">
<div>
<h2>人工校对</h2>
<p id="translationDetailSummary">选择一个翻译任务</p>
</div>
<div class="row-actions">
<button class="btn" id="proofreadBtn" type="button">标记人工校对中</button>
<button class="btn btn-primary" id="manualSaveBtn" type="button">保存校对结果</button>
</div>
</div>
<div class="form manual-meta">
<label>Provider run ID <input id="manualProviderRunInput" placeholder="留空时由 Rust 生成"></label>
</div>
<div class="unit-editor" id="unitEditor"></div>
</section>
<div class="grid two-col">
<section class="panel">
<div class="panel-head">
<div>
<h2>汉化发布</h2>
<p id="localizedSummary">需要 admin token</p>
</div>
</div>
<div class="form">
<label>Localized release ID <input id="localizedReleaseInput" placeholder="可选"></label>
<label class="inline-check"><input id="localizedForceInput" type="checkbox">强制发布</label>
<div class="row-actions">
<button class="btn btn-primary" id="localizedPublishBtn" type="button">从 worker 结果发布</button>
<button class="btn btn-danger" id="localizedRollbackBtn" type="button">回滚</button>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<h2>交接视图</h2>
<p id="handoffSummary">translation.handoff</p>
</div>
</div>
<dl class="kv-list" id="handoffDetails"></dl>
</section>
</div>
</section>
<section class="view" id="view-tasks" data-title="任务" data-subtitle="轮询 Rust daemon 持久化任务,查看状态、日志并发起取消。">
<section class="panel">
<div class="panel-head split">
<div>
<h2>任务列表</h2>
<p id="daemonTaskSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="daemonTaskRefreshBtn" type="button">刷新</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Task ID</th>
<th>Kind</th>
<th>Status</th>
<th>Stage</th>
<th>Updated</th>
</tr>
</thead>
<tbody id="daemonTaskRows"></tbody>
</table>
</div>
</section>
<section class="panel">
<div class="panel-head split">
<div>
<h2>任务详情</h2>
<p id="daemonTaskDetailSummary">选择一个任务</p>
</div>
<button class="btn btn-danger" id="daemonTaskCancelBtn" type="button">取消任务</button>
</div>
<dl class="kv-list" id="daemonTaskDetails"></dl>
<pre class="log-box" id="daemonTaskLogBox"></pre>
</section>
</section>
<section class="view" id="view-logs" data-title="日志" data-subtitle="读取 Rust daemon 诊断和最近日志。">
<div class="grid two-col">
<section class="panel">
<div class="panel-head split">
<div>
<h2>诊断</h2>
<p id="doctorSummary">需要 admin token</p>
</div>
<button class="btn btn-small" id="doctorRefreshBtn" type="button">刷新</button>
</div>
<div class="list" id="doctorChecks"></div>
</section>
<section class="panel">
<div class="panel-head split">
<div>
<h2>Daemon log</h2>
<p id="logSummary">需要 admin token</p>
</div>
<label class="inline-label">Tail <input id="logTailInput" type="number" min="1" max="2000" value="200"></label>
</div>
<pre class="log-box" id="daemonLogBox"></pre>
</section>
</div>
</section>
<section class="view" id="view-settings" data-title="设置" data-subtitle="配置 dashboard 调用 bat-api 的地址和 admin token。">
<section class="panel settings-panel">
<div class="panel-head">
<div>
<h2>连接</h2>
<p>默认使用当前页面同源 bat-api。</p>
</div>
</div>
<form class="form">
<label>Base URL <input id="baseUrlInput" autocomplete="off"></label>
<label>Admin token <input id="tokenInput" type="password" autocomplete="off"></label>
<label class="inline-check"><input id="rememberTokenInput" type="checkbox">在本浏览器保存 token</label>
<div class="row-actions">
<button class="btn btn-primary" id="saveSettingsBtn" type="button">保存并刷新</button>
<button class="btn" id="forgetSettingsBtn" type="button">清除</button>
</div>
</form>
</section>
</section>
</main>
</div>
<div class="toast-region" id="toastRegion" aria-live="polite"></div>
<script src="app.js"></script>
</body>
</html>
+670
View File
@@ -0,0 +1,670 @@
:root {
color-scheme: light;
--bg: #f6f7fb;
--surface: #ffffff;
--surface-muted: #f0f3f8;
--border: #d9e0ea;
--border-strong: #bcc7d5;
--text: #172033;
--muted: #657286;
--blue: #2563eb;
--blue-dark: #1d4ed8;
--green: #15803d;
--amber: #b45309;
--red: #b91c1c;
--shadow: 0 10px 30px rgba(23, 32, 51, 0.08);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.45;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
border: 0;
}
code,
pre {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
}
.shell {
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
min-height: 100vh;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
padding: 20px 16px;
background: #111827;
color: #eef2ff;
display: flex;
flex-direction: column;
gap: 22px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 38px;
height: 38px;
border-radius: 8px;
background: #2563eb;
color: #ffffff;
display: grid;
place-items: center;
font-weight: 800;
font-size: 13px;
}
.brand-title {
font-weight: 700;
font-size: 15px;
}
.brand-subtitle,
.connection-subtitle,
.muted {
color: var(--muted);
}
.brand-subtitle,
.connection-subtitle {
color: #94a3b8;
font-size: 12px;
}
.nav {
display: grid;
gap: 5px;
}
.nav-item {
width: 100%;
padding: 9px 10px;
border-radius: 6px;
background: transparent;
color: #cbd5e1;
text-align: left;
cursor: pointer;
}
.nav-item:hover,
.nav-item.is-active {
background: rgba(255, 255, 255, 0.09);
color: #ffffff;
}
.connection-box {
margin-top: auto;
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
display: flex;
gap: 10px;
align-items: flex-start;
}
.connection-title {
font-weight: 700;
}
.state-dot {
width: 10px;
height: 10px;
margin-top: 5px;
border-radius: 50%;
background: #94a3b8;
flex: 0 0 auto;
}
.state-ok {
background: #22c55e;
}
.state-warn,
.state-loading {
background: #f59e0b;
}
.state-bad {
background: #ef4444;
}
.main {
min-width: 0;
padding: 24px;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 22px;
}
h1,
h2,
p {
margin: 0;
}
h1 {
font-size: 24px;
font-weight: 750;
}
h2 {
font-size: 15px;
font-weight: 730;
}
.topbar p,
.panel-head p,
.metric-note,
.muted {
color: var(--muted);
font-size: 12px;
}
.topbar-actions,
.row-actions,
.filter-row,
.pager {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.view {
display: none;
}
.view.is-active {
display: grid;
gap: 16px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.metric,
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: var(--shadow);
}
.metric {
padding: 14px;
}
.metric-label {
color: var(--muted);
font-size: 12px;
}
.metric-value {
margin-top: 6px;
font-size: 22px;
font-weight: 760;
overflow-wrap: anywhere;
}
.panel {
overflow: hidden;
}
.panel-head {
min-height: 58px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-head.split {
align-items: flex-start;
}
.grid {
display: grid;
gap: 16px;
}
.two-col {
grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.85fr);
}
.btn {
min-height: 34px;
padding: 7px 12px;
border-radius: 6px;
border: 1px solid var(--border-strong);
background: #ffffff;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.btn:hover {
border-color: var(--blue);
color: var(--blue-dark);
}
.btn-primary {
border-color: var(--blue);
background: var(--blue);
color: #ffffff;
}
.btn-primary:hover {
background: var(--blue-dark);
color: #ffffff;
}
.btn-danger {
border-color: #fecaca;
color: var(--red);
}
.btn-small {
min-height: 28px;
padding: 5px 9px;
font-size: 12px;
}
.inline-check,
.inline-label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--muted);
font-size: 12px;
}
.inline-label input {
width: 80px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: 6px;
background: #ffffff;
color: var(--text);
padding: 8px 10px;
outline: none;
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--blue);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
textarea {
resize: vertical;
}
.filter-row {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
}
.filter-row input,
.filter-row select {
max-width: 260px;
}
.form {
display: grid;
gap: 11px;
padding: 14px 16px;
}
.form label {
display: grid;
gap: 5px;
color: var(--muted);
font-size: 12px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.manual-meta {
border-bottom: 1px solid var(--border);
}
.manual-meta label {
max-width: 420px;
}
.table-wrap {
width: 100%;
overflow: auto;
}
.table-wrap.compact {
max-height: 560px;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
th {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
font-weight: 700;
}
td code {
overflow-wrap: anywhere;
}
tr.clickable {
cursor: pointer;
}
tr.clickable:hover {
background: var(--surface-muted);
}
.empty,
.empty-block {
color: var(--muted);
text-align: center;
}
.empty-block {
padding: 18px;
}
.pager {
justify-content: space-between;
padding: 12px 16px;
}
.list {
display: grid;
}
.list-item {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.list-item:last-child {
border-bottom: 0;
}
.compact-item {
padding-block: 10px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-muted);
color: var(--muted);
font-size: 12px;
white-space: nowrap;
}
.pill-ok {
border-color: #bbf7d0;
background: #f0fdf4;
color: var(--green);
}
.pill-warn {
border-color: #fde68a;
background: #fffbeb;
color: var(--amber);
}
.pill-bad {
border-color: #fecaca;
background: #fef2f2;
color: var(--red);
}
.pill-neutral {
border-color: var(--border);
background: var(--surface-muted);
color: var(--muted);
}
.kv-list {
display: grid;
grid-template-columns: 180px minmax(0, 1fr);
gap: 0;
padding: 6px 16px 14px;
}
.kv-list dt,
.kv-list dd {
margin: 0;
padding: 8px 0;
border-bottom: 1px solid var(--border);
}
.kv-list dt {
color: var(--muted);
font-size: 12px;
}
.kv-list dd {
overflow-wrap: anywhere;
}
.log-box {
min-height: 280px;
margin: 0;
padding: 14px;
background: #0f172a;
color: #dbeafe;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.unit-editor {
display: grid;
gap: 12px;
padding: 14px 16px 16px;
}
.unit-card {
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
background: #fbfcfe;
display: grid;
gap: 10px;
}
.unit-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.unit-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.unit-meta span {
padding: 3px 7px;
border-radius: 6px;
background: var(--surface-muted);
color: var(--muted);
font-size: 12px;
}
.source-label textarea {
background: #f8fafc;
color: #334155;
}
.settings-panel {
max-width: 720px;
}
.toast-region {
position: fixed;
right: 18px;
bottom: 18px;
display: grid;
gap: 8px;
z-index: 30;
}
.toast {
max-width: min(420px, calc(100vw - 36px));
padding: 10px 12px;
border-radius: 8px;
box-shadow: var(--shadow);
background: #ecfdf5;
color: var(--green);
border: 1px solid #bbf7d0;
}
.toast-warn {
background: #fffbeb;
color: var(--amber);
border-color: #fde68a;
}
.toast-bad {
background: #fef2f2;
color: var(--red);
border-color: #fecaca;
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.sidebar {
position: relative;
height: auto;
}
.nav {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.two-col,
.status-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 720px) {
.main {
padding: 16px;
}
.topbar {
flex-direction: column;
}
.topbar-actions,
.row-actions,
.filter-row {
width: 100%;
}
.btn,
.filter-row input,
.filter-row select {
max-width: none;
}
.status-grid,
.two-col,
.form-grid,
.nav {
grid-template-columns: 1fr;
}
.kv-list {
grid-template-columns: 1fr;
}
.kv-list dt {
border-bottom: 0;
padding-bottom: 2px;
}
.kv-list dd {
padding-top: 0;
}
}