Files
site/WESP_REL/static/admin.html
T
2026-07-17 12:57:18 +03:00

1842 lines
143 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<script>
(function () {
try {
var root = document.documentElement;
root.setAttribute("data-theme", "ultra-dark");
root.classList.add("dark");
root.classList.remove("light");
} catch (e) {}
try {
document.documentElement.classList.add("wesp-admin-loading");
} catch (e2) {}
})();
</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WESP Admin</title>
<!-- Ant Design (MIT) + тема админки; файлы в /static/vendor/admin-ui/ -->
<link rel="stylesheet" href="/static/vendor/admin-ui/antd.min.css">
<link rel="stylesheet" href="/static/vendor/admin-ui/custom.min.css">
<link rel="stylesheet" href="/static/css/wesp-admin-panel.css?v=20260714">
<link rel="stylesheet" href="/static/css/wesp-logo-loader.css">
<link rel="stylesheet" href="/static/css/wesp-setup.css">
<link rel="stylesheet" href="/static/css/wesp-startup.css">
</head>
<body>
<div id="app" class="ant-layout ant-layout-has-sider" style="min-height: 100vh">
<aside
id="wespAdminSider"
class="ant-layout-sider ant-layout-sider-dark ant-layout-sider-has-trigger ant-layout-sider-collapsed wesp-admin-sider"
>
<div class="ant-layout-sider-children">
<div class="wesp-admin-brand">
<div class="wesp-admin-brand-title">WESP</div>
</div>
<!-- Иконки: встроенный SVG (контуры в духе открытых наборов Lucide/MIT, без внешних шрифтов) -->
<ul
role="menu"
class="ant-menu ant-menu-dark ant-menu-root ant-menu-inline"
style="border-right: none;"
>
<li class="ant-menu-item ant-menu-item-selected" role="menuitem" data-section="dashboard" title="Дашборд">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg></span>
<span class="wesp-menu-label">Дашборд</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="system" title="Система">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/></svg></span>
<span class="wesp-menu-label">Система</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="auto-update" title="Обновления (Gitea)">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg></span>
<span class="wesp-menu-label">Обновления</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="sync" title="Синхронизация">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/></svg></span>
<span class="wesp-menu-label">Синхронизация</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="network" title="Сеть: имя в LAN, mDNS, публичный URL">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></svg></span>
<span class="wesp-menu-label">Сеть</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="security" title="Безопасность">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10"/></svg></span>
<span class="wesp-menu-label">Безопасность</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="hardware" title="Железо">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></svg></span>
<span class="wesp-menu-label">Железо</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="diagnostics" title="Диагностика: сеть, безопасность, периферия">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg></span>
<span class="wesp-menu-label">Диагностика</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="users" title="Пользователи">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg></span>
<span class="wesp-menu-label">Пользователи</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="assistant" title="Локальный ИИ: чат, сводки, диагностика (без облака)">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg></span>
<span class="wesp-menu-label">Ассистент</span>
</li>
<li class="ant-menu-item" role="menuitem" data-section="links" title="Переходы">
<span class="wesp-menu-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></span>
<span class="wesp-menu-label">Переходы</span>
</li>
</ul>
</div>
<div
class="ant-layout-sider-trigger"
id="wespAdminSiderTrigger"
role="button"
tabindex="0"
aria-controls="wespAdminSider"
aria-expanded="false"
title="Развернуть меню"
>
<span class="wesp-sider-trigger-icon" aria-hidden="true"></span>
</div>
</aside>
<div class="ant-layout" id="content-layout">
<main class="ant-layout-content" style="margin: 24px 16px; padding: 0; min-height: auto;">
<div class="wesp-admin-content-topbar">
<div class="wesp-admin-content-topbar-actions">
<a class="ant-btn" href="/recipes"><span>Назад в рецепты</span></a>
<button type="button" class="ant-btn ant-btn-primary" id="refreshSummaryBtn">
<span>Обновить</span>
</button>
</div>
</div>
<section class="wesp-admin-section is-active" data-section-content="dashboard">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-body">
<!-- Сетка метрик: две колонки md-12, по два круговых индикатора (ant-col-12). -->
<div class="ant-row" id="systemGaugesGrid" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-sm-24 ant-col-md-12" style="padding: 8px;">
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="text-center ant-col ant-col-12" style="padding: 8px;">
<div class="wesp-gauge" data-gauge="cpu">
<div class="wesp-gauge-ring wesp-gauge-ring--adm">
<svg class="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle class="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" stroke-width="6"/>
<circle class="wesp-gauge-fill wesp-gauge-fill--adm" cx="50" cy="50" r="47" fill="none" stroke-width="6"
stroke-linecap="round" transform="rotate(-90 50 50)"/>
</svg>
<div class="wesp-gauge-center">
<span class="wesp-gauge-pct" data-gauge-pct></span><span class="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div class="wesp-gauge-caption wesp-gauge-caption--with-btn">
<span class="wesp-gauge-caption-text"><b>CPU</b> <span data-gauge-detail></span></span>
<button
type="button"
class="ant-btn ant-btn-circle ant-btn-sm wesp-gauge-history-btn"
id="btnDashHardwareChart"
title="load timeseries; retention per summary poll"
aria-label="Open load chart"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="64 64 896 896" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M536.1 273H488c-4.4 0-8 3.6-8 8v275.3c0 2.6 1.2 5 3.3 6.5l165.3 120.7c3.6 2.6 8.6 1.9 11.2-1.7l28.6-39c2.7-3.7 1.9-8.7-1.7-11.2L544.1 528.5V281c0-4.4-3.6-8-8-8zm219.8 75.2l156.8 38.3c5 1.2 9.9-2.6 9.9-7.7l.8-161.5c0-6.7-7.7-10.5-12.9-6.3L752.9 334.1a8 8 0 0 0 3 14.1zm167.7 301.1l-56.7-19.5a8 8 0 0 0-10.1 4.8c-1.9 5.1-3.9 10.1-6 15.1-17.8 42.1-43.3 80-75.9 112.5a353 353 0 0 1-112.5 75.9 352.18 352.18 0 0 1-137.7 27.8c-47.8 0-94.1-9.3-137.7-27.8a353 353 0 0 1-112.5-75.9c-32.5-32.5-58-70.4-75.9-112.5A353.44 353.44 0 0 1 171 512c0-47.8 9.3-94.2 27.8-137.8 17.8-42.1 43.3-80 75.9-112.5a353 353 0 0 1 112.5-75.9C430.6 167.3 477 158 524.8 158s94.1 9.3 137.7 27.8A353 353 0 0 1 775 261.7c10.2 10.3 19.8 21 28.6 32.3l59.8-46.8C784.7 146.6 662.2 81.9 524.6 82 285 82.1 92.6 276.7 95 516.4 97.4 751.9 288.9 942 524.8 942c185.5 0 343.5-117.6 403.7-282.3 1.5-4.2-.7-8.9-4.9-10.4z"/></svg>
</button>
</div>
</div>
</div>
<div class="text-center ant-col ant-col-12" style="padding: 8px;">
<div class="wesp-gauge" data-gauge="ram">
<div class="wesp-gauge-ring wesp-gauge-ring--adm">
<svg class="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle class="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" stroke-width="6"/>
<circle class="wesp-gauge-fill wesp-gauge-fill--adm" cx="50" cy="50" r="47" fill="none" stroke-width="6"
stroke-linecap="round" transform="rotate(-90 50 50)"/>
</svg>
<div class="wesp-gauge-center">
<span class="wesp-gauge-pct" data-gauge-pct></span><span class="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div class="wesp-gauge-caption"><b>RAM</b> <span data-gauge-detail></span></div>
</div>
</div>
</div>
</div>
<div class="ant-col ant-col-sm-24 ant-col-md-12" style="padding: 8px;">
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="text-center ant-col ant-col-12" style="padding: 8px;">
<div class="wesp-gauge" data-gauge="swap">
<div class="wesp-gauge-ring wesp-gauge-ring--adm">
<svg class="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle class="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" stroke-width="6"/>
<circle class="wesp-gauge-fill wesp-gauge-fill--adm" cx="50" cy="50" r="47" fill="none" stroke-width="6"
stroke-linecap="round" transform="rotate(-90 50 50)"/>
</svg>
<div class="wesp-gauge-center">
<span class="wesp-gauge-pct" data-gauge-pct></span><span class="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div class="wesp-gauge-caption"><b>swap</b> <span data-gauge-detail></span></div>
</div>
</div>
<div class="text-center ant-col ant-col-12" style="padding: 8px;">
<div class="wesp-gauge" data-gauge="disk">
<div class="wesp-gauge-ring wesp-gauge-ring--adm">
<svg class="wesp-gauge-svg" viewBox="0 0 100 100" aria-hidden="true">
<circle class="wesp-gauge-track" cx="50" cy="50" r="47" fill="none" stroke-width="6"/>
<circle class="wesp-gauge-fill wesp-gauge-fill--adm" cx="50" cy="50" r="47" fill="none" stroke-width="6"
stroke-linecap="round" transform="rotate(-90 50 50)"/>
</svg>
<div class="wesp-gauge-center">
<span class="wesp-gauge-pct" data-gauge-pct></span><span class="wesp-gauge-pct-suffix">%</span>
</div>
</div>
<div class="wesp-gauge-caption"><b>disk</b> <span data-gauge-detail></span></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ant-row wesp-dash-service-row" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-xs-24 ant-col-xl-12" style="padding: 8px;">
<div class="ant-card ant-card-bordered wesp-dash-service-card">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">WESP</div>
<div class="ant-card-extra wesp-dash-status">
<span class="wesp-status-dot wesp-status-dot--ok" aria-hidden="true"></span>
<span id="wespRuntimeStatus">OK</span>
</div>
</div>
</div>
<div class="ant-card-body">
<div class="wesp-dash-wesp-meta">
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">version</span>
<span class="ant-tag ant-tag-green" id="wespDashVersion">v—</span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">first_run</span>
<span class="wesp-dash-wesp-value" id="wespFirstLaunch"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">warranty_until</span>
<span class="wesp-dash-wesp-value" id="wespWarrantyUntil"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">warranty_days</span>
<span class="wesp-dash-wesp-value" id="wespWarrantyDays"></span>
</div>
</div>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-xl-12" style="padding: 8px;">
<div class="ant-card ant-card-bordered wesp-dash-service-card">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Ops</div>
</div>
</div>
<div class="ant-card-body">
<div class="wesp-dash-manage-list">
<div class="wesp-dash-manage-row">
<div class="wesp-dash-manage-info">
<span class="wesp-dash-manage-title">log</span>
<span class="wesp-dash-manage-hint">tail; WESP_ADMIN_LOG_PATH</span>
</div>
<div class="wesp-dash-manage-actions">
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary" id="btnManageLog"><span>open</span></button>
<button type="button" class="ant-btn ant-btn-sm" id="btnManageLogDownload" title="GET /api/admin/server-log/download"><span>download</span></button>
<button type="button" class="ant-btn ant-btn-sm" id="btnManageClientLogs" title="Хвост лога с выбранного клиента (выгрузка /api/sync/client-log)"><span>client…</span></button>
<button type="button" class="ant-btn ant-btn-sm" id="btnManageClientLogsDownload" title="GET /api/admin/client-uploaded-logs/download"><span>client dl</span></button>
</div>
</div>
<div class="wesp-dash-manage-row">
<div class="wesp-dash-manage-info">
<span class="wesp-dash-manage-title">config</span>
<span class="wesp-dash-manage-hint">rollup → section System</span>
</div>
<div class="wesp-dash-manage-actions">
<button type="button" class="ant-btn ant-btn-sm" id="btnManageConfig"><span>go</span></button>
</div>
</div>
<div class="wesp-dash-manage-row">
<div class="wesp-dash-manage-info">
<span class="wesp-dash-manage-title">backup</span>
<span class="wesp-dash-manage-hint">SQLite→ZIP; restore writes *.restore-bak-*</span>
</div>
<div class="wesp-dash-manage-actions">
<input type="file" id="restoreSqliteFile" accept=".zip,application/zip" hidden />
<button type="button" class="ant-btn ant-btn-sm" id="btnManageBackup"><span>GET ZIP</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" id="btnManageRestore"><span>restore…</span></button>
</div>
</div>
<div class="wesp-dash-manage-row wesp-dash-manage-row--service">
<div class="wesp-dash-manage-info">
<span class="wesp-dash-manage-title">service</span>
<span class="wesp-dash-manage-hint">WESP_ADMIN_STOP_CMD / WESP_ADMIN_RESTART_CMD</span>
</div>
<div class="wesp-dash-manage-actions">
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" id="btnWespStop" title="needs WESP_ADMIN_STOP_CMD"><span>stop</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary" id="btnWespRestart" title="needs WESP_ADMIN_RESTART_CMD"><span>restart</span></button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-xs-24 ant-col-lg-12" style="padding: 8px;">
<div class="ant-card ant-card-bordered wesp-traffic-card">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">nx throughput (instant)</div>
</div>
</div>
<div class="ant-card-body">
<div class="wesp-traffic-two-col">
<div class="wesp-traffic-col">
<div class="wesp-traffic-label">tx</div>
<div class="wesp-traffic-value"><span class="wesp-traffic-arrow"></span> <span id="netUpload"></span></div>
</div>
<div class="wesp-traffic-col">
<div class="wesp-traffic-label">rx</div>
<div class="wesp-traffic-value"><span class="wesp-traffic-arrow"></span> <span id="netDownload"></span></div>
</div>
</div>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-lg-12" style="padding: 8px;">
<div class="ant-card ant-card-bordered wesp-traffic-card">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title" title="cumulative since boot (kernel counters)">nx cumulative</div>
</div>
</div>
<div class="ant-card-body">
<div class="wesp-traffic-two-col">
<div class="wesp-traffic-col">
<div class="wesp-traffic-label">bytes tx</div>
<div class="wesp-traffic-value"><span class="wesp-traffic-icon" aria-hidden="true">☁↑</span> <span id="netVolumeSent"></span></div>
</div>
<div class="wesp-traffic-col">
<div class="wesp-traffic-label">bytes rx</div>
<div class="wesp-traffic-value"><span class="wesp-traffic-icon" aria-hidden="true">☁↓</span> <span id="netVolumeRecv"></span></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-xs-24" style="padding: 8px;">
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">host addrs (L3)</div>
<div class="ant-card-extra">
<button type="button" class="ant-btn ant-btn-sm ant-btn-icon-only wesp-ip-reveal-btn is-ip-hidden" id="btnToggleServerIp" aria-pressed="true" title="reveal / mask">
<span class="wesp-ip-reveal-btn__icon wesp-ip-reveal-btn__icon--show" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg></span>
<span class="wesp-ip-reveal-btn__icon wesp-ip-reveal-btn__icon--hide" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.88 9.88a3 3 0 1 0 4.24 4.24"/><path d="M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68"/><path d="M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61"/><line x1="2" x2="22" y1="2" y2="22"/></svg></span>
</button>
</div>
</div>
</div>
<div class="ant-card-body">
<div id="serverIpList" class="wesp-server-ip-list wesp-ip-masked"></div>
</div>
</div>
</div>
</div>
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-xs-24 ant-col-lg-12" style="padding: 8px;">
<div class="ant-card ant-card-bordered" style="height: 100%;">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">loadavg 1/5/15</div>
</div>
</div>
<div class="ant-card-body">
<div
id="loadAvgPills"
title="loadavg ≠ CPU%; compare to logical cores (CPU gauge)"
></div>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-lg-12" style="padding: 8px;">
<div class="ant-card ant-card-bordered" style="height: 100%;">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">uptime · sockets</div>
</div>
</div>
<div class="ant-card-body wesp-dash-meta-pills">
<span class="ant-tag ant-tag-green" id="pillHostUptime">host: —</span>
<span class="ant-tag ant-tag-green" id="pillWespUptime">wesp: —</span>
<span class="ant-tag ant-tag-green" id="pillConnections">TCP — · UDP —</span>
</div>
</div>
</div>
</div>
<div class="ant-row" style="margin: 16px -8px 0;">
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<div
class="ant-card ant-card-bordered ant-card-small wesp-admin-stat-card wesp-admin-stat-card--clickable"
data-stat-nav="users"
role="button"
tabindex="0"
title="users · Alt+click: SQLite"
>
<div class="ant-card-body" style="padding: 16px;">
<div style="opacity: 0.65; font-size: 13px;">users</div>
<div id="statUsers" style="font-size: 26px; font-weight: 600; margin-top: 6px;">-</div>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<div
class="ant-card ant-card-bordered ant-card-small wesp-admin-stat-card wesp-admin-stat-card--clickable"
data-stat-nav="recipes"
role="button"
tabindex="0"
title="recipes · Alt+click: SQLite"
>
<div class="ant-card-body" style="padding: 16px;">
<div style="opacity: 0.65; font-size: 13px;">recipes</div>
<div id="statRecipes" style="font-size: 26px; font-weight: 600; margin-top: 6px;">-</div>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<div
class="ant-card ant-card-bordered ant-card-small wesp-admin-stat-card wesp-admin-stat-card--clickable"
data-stat-nav="components"
role="button"
tabindex="0"
title="components · Alt+click: SQLite"
>
<div class="ant-card-body" style="padding: 16px;">
<div style="opacity: 0.65; font-size: 13px;">components</div>
<div id="statComponents" style="font-size: 26px; font-weight: 600; margin-top: 6px;">-</div>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<div
class="ant-card ant-card-bordered ant-card-small wesp-admin-stat-card wesp-admin-stat-card--clickable"
data-stat-nav="sync"
role="button"
tabindex="0"
title="sync · Alt+click: SQLite"
>
<div class="ant-card-body" style="padding: 16px;">
<div style="opacity: 0.65; font-size: 13px;">Sync queue, pending</div>
<div id="statQueuePending" style="font-size: 26px; font-weight: 600; margin-top: 6px;">-</div>
</div>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered" style="margin-top: 16px;">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">instance rollup</div>
</div>
</div>
<div class="ant-card-body">
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-xs-24 ant-col-md-12" style="padding: 8px;">
<div class="wesp-dash-summary-block-title">Runtime</div>
<div class="wesp-dash-wesp-meta">
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">CONFIG_MODE</span>
<span class="wesp-dash-wesp-value" id="dashMetaConfigMode"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">DEBUG</span>
<span class="wesp-dash-wesp-value" id="dashMetaDebug"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">version</span>
<span class="wesp-dash-wesp-value" id="dashMetaVersion"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">sync.role</span>
<span class="wesp-dash-wesp-value" id="dashMetaSyncRole"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">first_run</span>
<span class="wesp-dash-wesp-value" id="dashMetaFirstLaunch"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">warranty_until</span>
<span class="wesp-dash-wesp-value" id="dashMetaWarrantyUntil"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">warranty_days</span>
<span class="wesp-dash-wesp-value" id="dashMetaWarrantyDays"></span>
</div>
</div>
</div>
<div class="ant-col ant-col-xs-24 ant-col-md-12" style="padding: 8px;">
<div class="wesp-dash-summary-block-title">DB row counts</div>
<div class="wesp-dash-wesp-meta">
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">users</span>
<span class="wesp-dash-wesp-value" id="dashCountUsers"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">recipes</span>
<span class="wesp-dash-wesp-value" id="dashCountRecipes"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">ingredients</span>
<span class="wesp-dash-wesp-value" id="dashCountIngredients"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">components</span>
<span class="wesp-dash-wesp-value" id="dashCountComponents"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">feeders</span>
<span class="wesp-dash-wesp-value" id="dashCountDispensers"></span>
</div>
<div class="wesp-dash-wesp-row">
<span class="wesp-dash-wesp-label">feed_periods</span>
<span class="wesp-dash-wesp-value" id="dashCountPeriods"></span>
</div>
</div>
</div>
</div>
<p class="wesp-dash-summary-generated" id="dashMetaGenerated"></p>
</div>
</div>
</section>
<section class="wesp-admin-section" id="wespSectionSystem" data-section-content="system">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Система</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<div class="wesp-adm-list-wrap">
<ul id="systemDataList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Базы данных</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<div class="wesp-adm-list-wrap">
<ul id="dbDataList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Киоск Pi — всё сразу</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">
Одна кнопка: <code>wesp.env</code> + <code>myscript.service</code> +
киоск Chromium без рабочего стола + перезагрузка.
Во время настройки — та же анимация, что на <code>/starting</code>.
</p>
<p class="wesp-sync-hint" id="kioskFullSetupHint"></p>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px;">
<button type="button" class="ant-btn ant-btn-primary" id="btnKioskFullSetup" title="env + киоск + reboot">
<span>Настроить киоск Pi</span>
</button>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Настройка Pi (systemd + wesp.env)</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint wesp-sync-hint--muted">
Отдельные шаги — если нужно только env/systemd без киоска.
</p>
<div class="ant-card-body">
<p class="wesp-sync-hint">
То же, что <code>install_fresh.sh</code>: <code>/etc/wesp/wesp.env</code>
(restart, OTA, автоперезагрузка, киоск) и <code>myscript.service</code>.
Нужен WESP от <strong>root</strong>. После применения служба перезапускается (~2 с).
</p>
<p class="wesp-sync-hint" id="piPlatformSetupHint"></p>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px;">
<button type="button" class="ant-btn ant-btn-primary" id="btnPiPlatformApply" title="wesp.env + systemd enable">
<span>Применить настройки Pi</span>
</button>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Радужный экран Pi (firmware)</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">
Большой цветной квадрат <strong>до</strong> Plymouth — splash GPU в
<code>/boot/firmware/config.txt</code>. Добавляет <code>disable_splash=1</code> в секцию
<code>[all]</code> (нужен sudo).
</p>
<p class="wesp-sync-hint" id="piRainbowSplashHint"></p>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px;">
<button type="button" class="ant-btn ant-btn-primary" id="btnPiRainbowSplashDisable" title="disable_splash=1">
<span>Отключить радугу</span>
</button>
<button type="button" class="ant-btn ant-btn-sm" id="btnPiRainbowSplashEnable" title="Убрать disable_splash из config.txt">
<span>Вернуть (как с завода)</span>
</button>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Заставка загрузки (Plymouth)</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">
Анимация логотипа WESP при включении (800×600, тёмная тема), как в setup/login с тёмной темой.
Нужны <code>plymouth</code>, <code>python3-pil</code> и sudo для установки темы.
</p>
<p class="wesp-sync-hint" id="plymouthStatusHint"></p>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px;">
<button type="button" class="ant-btn ant-btn-primary" id="btnPlymouthInstall" title="Собрать кадры из logo2.png и установить тему wesp">
<span>Установить тему Plymouth</span>
</button>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Киоск: Chromium при входе</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">
Для первой настройки удобнее кнопка <strong>«Настроить киоск Pi»</strong> выше.
Chromium kiosk: сначала тёмный <code>/starting</code> с подписями, затем
<code>/scales</code>. Без перевода страницы (расширенные флаги Chromium).
WESP обычно работает от root (<code>myscript.service</code>) — запись в
<code>~/.config/autostart/</code> без пароля.
</p>
<p class="wesp-sync-hint" id="kioskBootStatusHint"></p>
<label class="wesp-sync-hint" style="display:flex;align-items:center;gap:8px;margin-top:8px;">
<input type="checkbox" id="kioskBootHideDesktop" checked />
<span>Без рабочего стола (патч <code>/etc/xdg/labwc/autostart</code> — нужен root/WESP)</span>
</label>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px;">
<button type="button" class="ant-btn ant-btn-primary" id="btnKioskBootEnable" title="chromium-kiosk.desktop">
<span>Включить киоск</span>
</button>
<button type="button" class="ant-btn ant-btn-sm" id="btnKioskBootDisable" title="Удалить autostart">
<span>Выключить киоск</span>
</button>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Сброс данных</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">
Удаляет содержимое <code>data/</code> (БД, JSON настроек, бэкапы, клиентские каталоги).
Исходный код и статика не затрагиваются. После сброса перезапустите WESP и пройдите <code>/setup</code>.
</p>
<p class="wesp-sync-hint" id="factoryResetPreservedNote" hidden></p>
<div class="wesp-adm-list-wrap" style="margin: 12px 0;">
<ul id="factoryResetPreviewList" class="wesp-adm-list" role="list">
<li class="wesp-adm-list-item"><span class="wesp-adm-list-key"></span><span class="wesp-adm-list-value">Загрузка…</span></li>
</ul>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-bottom: 12px;">
<div class="wesp-sync-field">
<label class="wesp-sync-label">
<input type="checkbox" id="factoryResetSafetyBackup" checked />
ZIP копия SQLite в data/backups/ перед сбросом
</label>
</div>
<div class="wesp-sync-field">
<label class="wesp-sync-label">
<input type="checkbox" id="factoryResetKeepLogs" />
Не удалять data/logs
</label>
</div>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; align-items: flex-end; gap: 8px;">
<div class="wesp-sync-field" style="flex: 1; min-width: 200px;">
<label class="wesp-sync-label" for="factoryResetConfirmPhrase">Фраза подтверждения</label>
<input id="factoryResetConfirmPhrase" class="ant-input" type="text" autocomplete="off" spellcheck="false" placeholder="СБРОС ДАННЫХ" />
</div>
<button type="button" class="ant-btn ant-btn-sm" id="btnFactoryResetPreview"><span>Обновить список</span></button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" id="btnFactoryReset"><span>Сбросить данные…</span></button>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" id="wespSectionAutoUpdate" data-section-content="auto-update">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Обновления (Gitea)</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint" id="auRuntimeHint"></p>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap">
<div class="wesp-sync-field">
<label class="wesp-sync-label"><input type="checkbox" id="auEnabled" /> Проверять обновления</label>
</div>
<div class="wesp-sync-field">
<label class="wesp-sync-label" for="auCheckInterval">Интервал проверки (сек)</label>
<input id="auCheckInterval" class="ant-input" type="number" min="60" max="86400" step="1" style="max-width: 120px" />
</div>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-top: 12px">
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="auGiteaUrl">Gitea URL</label>
<input id="auGiteaUrl" class="ant-input" type="text" placeholder="https://git.example.com" autocomplete="off" />
</div>
<div class="wesp-sync-field">
<label class="wesp-sync-label" for="auGiteaOwner">owner</label>
<input id="auGiteaOwner" class="ant-input" type="text" autocomplete="off" />
</div>
<div class="wesp-sync-field">
<label class="wesp-sync-label" for="auGiteaRepo">repo</label>
<input id="auGiteaRepo" class="ant-input" type="text" autocomplete="off" />
</div>
</div>
<div class="wesp-sync-settings-row" style="margin-top: 12px">
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="auRepositoryUrl">Полный URL репозитория (опционально)</label>
<input id="auRepositoryUrl" class="ant-input" type="text" placeholder="https://git.example.com/owner/repo" autocomplete="off" />
</div>
<div class="wesp-sync-field wesp-sync-field--actions">
<label class="wesp-sync-label" for="auSave">&nbsp;</label>
<button type="button" class="ant-btn ant-btn-primary" id="auSave"><span>Сохранить</span></button>
</div>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-top: 16px">
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="auGiteaToken">Токен API Gitea</label>
<input id="auGiteaToken" class="ant-input" type="password" autocomplete="new-password" placeholder="вставьте новый токен, чтобы записать" />
</div>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-top: 12px">
<div class="wesp-sync-field">
<label class="wesp-sync-label" for="auGiteaApiUser">Логин API (basic auth)</label>
<input id="auGiteaApiUser" class="ant-input" type="text" autocomplete="off" />
</div>
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="auGiteaApiPass">Пароль API</label>
<input id="auGiteaApiPass" class="ant-input" type="password" autocomplete="new-password" />
</div>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-top: 12px; align-items: center">
<div class="wesp-sync-field">
<label class="wesp-sync-label"><input type="checkbox" id="auClearSecrets" /> Удалить секреты из <code>.secret/gitea_secrets.json</code></label>
</div>
<div class="wesp-sync-field wesp-sync-field--actions">
<button type="button" class="ant-btn ant-btn-primary" id="auSaveSecrets"><span>Сохранить секреты API</span></button>
</div>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="sync">
<div id="syncInitialSyncBanner" class="ant-alert ant-alert-info" style="display:none; margin-bottom: 12px;" role="status">
<span>Идёт первая синхронизация с сервером. Прогресс — по <strong>успешной записи в локальную БД</strong>, не только по получению пачек с сервера. Дождитесь завершения (флаг <code>first_bootstrap_done</code> в состоянии узла).</span>
</div>
<div id="syncInitialSyncProgressWrap" style="display:none; margin-bottom: 12px;" role="region" aria-label="Прогресс первой синхронизации">
<div class="wesp-sync-progress-bar" aria-hidden="true">
<div id="syncInitialSyncProgressFill" class="wesp-sync-progress-fill" style="width:0%"></div>
</div>
<p id="syncInitialSyncProgressText" class="wesp-sync-progress-text" style="margin:8px 0 0;font-size:13px;color:rgba(0,0,0,0.75);"></p>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap" id="syncDiagnosticsCard">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Диагностика синхронизации</div>
<div class="ant-card-extra">
<button type="button" class="ant-btn ant-btn-sm" id="syncDiagRefreshBtn" title="Обновить данные диагностики">
<span>Обновить</span>
</button>
</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint" style="padding: 12px 16px 0; margin: 0;" id="syncDiagHint">
Загрузка… Метрики runtime относятся к <strong>этому</strong> процессу воркера (см. блок Runtime).
</p>
<div class="wesp-adm-list-wrap" style="padding: 12px 16px;">
<ul id="syncDiagOverviewList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Runtime (process-local)</div>
<ul id="syncDiagRuntimeList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Очередь (серверная БД)</div>
<ul id="syncDiagQueueStatsList" class="wesp-adm-list" role="list"></ul>
<div id="syncDiagByTableWrap" class="wesp-sync-diag-table-wrap"></div>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Bootstrap / первая синхронизация</div>
<ul id="syncDiagBootstrapList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Push отчётов (локальная очередь на клиенте)</div>
<ul id="syncDiagReportPushList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Проблемные задачи</div>
<div id="syncDiagProblemsWrap" class="wesp-sync-diag-table-wrap"></div>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Конфликты</div>
<ul id="syncDiagConflictsList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Consistency (digest)</div>
<ul id="syncDiagConsistencyList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-subcard">
<div class="wesp-sync-diag-subtitle">Клиенты и доставки</div>
<ul id="syncDiagClientsExtraList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-sync-diag-actions" style="padding: 12px 16px 16px;">
<div class="wesp-sync-diag-subtitle">Действия</div>
<p class="wesp-sync-hint">Опасные операции могут повлиять на очередь; подтверждайте осознанно.</p>
<div class="wesp-adm-list-actions" style="flex-wrap: wrap; gap: 8px;">
<button type="button" class="ant-btn ant-btn-sm ant-btn-danger" id="syncActionRequeueStuck" title="processing дольше порога → pending">
<span>Requeue stuck</span>
</button>
<button type="button" class="ant-btn ant-btn-sm" id="syncActionRestartLocal" title="Перезапуск sync_client в процессе">
<span>Restart local sync</span>
</button>
<button type="button" class="ant-btn ant-btn-sm" id="syncActionRefreshRuntime" title="Снимок runtime воркера">
<span>Refresh runtime</span>
</button>
<button type="button" class="ant-btn ant-btn-sm ant-btn-primary" id="syncActionRetryPush" title="Фоновый sync_cycle (клиент)">
<span>Retry report push</span>
</button>
</div>
<p id="syncDiagActionMessage" class="wesp-sync-hint" style="margin-top:10px; min-height: 1.2em;" aria-live="polite"></p>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Роль и сервер синхронизации</div>
</div>
</div>
<div class="ant-card-body">
<div id="syncRuntimeNotices" class="wesp-sync-runtime-notices" style="display: none" aria-live="polite"></div>
<p class="wesp-sync-hint" id="syncConnectionHint"></p>
<div class="wesp-sync-settings-row">
<div class="wesp-sync-field">
<label class="wesp-sync-label" for="syncSettingsRole">Роль узла</label>
<select id="syncSettingsRole" class="ant-input" style="max-width: 280px;">
<option value="server">Сервер</option>
<option value="client">Клиент</option>
<option value="other">Другое</option>
</select>
</div>
<div class="wesp-sync-field wesp-sync-field--grow">
<span class="wesp-sync-label" id="syncSettingsServerUrlLabel">Адрес сервера</span>
<div class="wesp-sync-url-combo" role="group" aria-labelledby="syncSettingsServerUrlLabel">
<select id="syncSettingsServerScheme" class="ant-input wesp-sync-scheme-select" aria-label="Протокол (http или https)">
<option value="https">https://</option>
<option value="http">http://</option>
</select>
<input id="syncSettingsServerHost" class="ant-input wesp-sync-host-input" type="text" placeholder="komton_srv_1.local" autocomplete="off" aria-label="IP или домен сервера" />
</div>
</div>
<div class="wesp-sync-field wesp-sync-field--actions">
<label class="wesp-sync-label" for="syncSettingsSave">&nbsp;</label>
<button type="button" class="ant-btn ant-btn-primary" id="syncSettingsSave"><span>Сохранить</span></button>
</div>
</div>
<p class="wesp-sync-hint" style="margin-top: 14px;">Поля ниже пишутся в <code>data/sync_client_state.json</code> (полевой <code>sync_client.py</code>).</p>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-top: 10px">
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="syncSettingsClientId">client_id (UUID)</label>
<input id="syncSettingsClientId" class="ant-input" type="text" placeholder="08ee387a-0522-4f16-afa7-49dfa578fdb9" autocomplete="off" spellcheck="false" />
</div>
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="syncSettingsClientName">client_name</label>
<input id="syncSettingsClientName" class="ant-input" type="text" placeholder="Sync Client" autocomplete="off" />
</div>
<div class="wesp-sync-field">
<label class="wesp-sync-label" for="syncSettingsFirstBootstrapDone">&nbsp;</label>
<label class="wesp-sync-label" style="display: flex; align-items: center; gap: 8px; margin: 0;">
<input type="checkbox" id="syncSettingsFirstBootstrapDone" />
first_bootstrap_done
</label>
</div>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Очередь синхронизации</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<div class="wesp-adm-list-wrap">
<ul id="syncQueueList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Ошибки синхронизации</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint wesp-sync-errors-hint">Только ошибки валидации/очереди. Transport (TCP, DNS, timeout) — отфильтровано.</p>
<div class="wesp-adm-list-wrap">
<ul id="syncErrorsList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Подключённые клиенты</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<div class="wesp-adm-list-wrap">
<ul id="syncClientsList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" id="wespSectionNetwork" data-section-content="network">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Этот узел</div>
<div class="ant-card-extra">
<button type="button" class="ant-btn ant-btn-sm" id="netSettingsRefreshBtn" title="GET /api/admin/network-settings">
<span>Обновить</span>
</button>
</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint" style="padding: 12px 16px 0; margin: 0;" id="networkSettingsHint">
Роутер настраивать не нужно, если все устройства в одной LAN без изоляции клиентов (guest WiFi).
</p>
<div class="wesp-adm-list-wrap">
<ul id="networkDetectedList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Имя и адрес в LAN</div>
</div>
</div>
<div class="ant-card-body wesp-network-settings-form">
<div class="wesp-network-form-loading" id="networkFormLoading" hidden aria-live="polite">
<span class="wesp-inline-spinner" aria-hidden="true"></span>
<span>Загрузка настроек сети…</span>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap">
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="netLocalHostname">Имя в LAN (mDNS)</label>
<input id="netLocalHostname" class="ant-input" type="text" placeholder="komton_srv_1.local" autocomplete="off" spellcheck="false" />
<p class="wesp-sync-hint" style="margin-top: 6px;">Пример для фермы: <code>komton_srv_1.local</code>, <code>komton_srv_2.local</code></p>
</div>
</div>
<div class="wesp-sync-settings-row" style="flex-wrap: wrap; margin-top: 12px; align-items: flex-end">
<div class="wesp-sync-field wesp-sync-field--grow">
<label class="wesp-sync-label" for="netPublicBaseUrl">Публичный базовый URL</label>
<input id="netPublicBaseUrl" class="ant-input" type="text" placeholder="http://komton_srv_1.local" autocomplete="off" />
<p class="wesp-sync-hint" style="margin-top: 6px;">Start URL киоска и QR; пусто — автоопределение по запросу.</p>
</div>
<div class="wesp-sync-field wesp-sync-field--actions">
<label class="wesp-sync-label" for="netBuildUrlBtn">&nbsp;</label>
<button type="button" class="ant-btn" id="netBuildUrlBtn"><span>Собрать из имени</span></button>
</div>
</div>
<div class="wesp-sync-settings-row" style="margin-top: 16px; align-items: center">
<label class="wesp-hw-sim-switch" for="netMdnsEnabled">
<input type="checkbox" id="netMdnsEnabled" role="switch" aria-describedby="netMdnsStatusLabel" />
<span class="wesp-hw-sim-switch-ui" aria-hidden="true"></span>
<span id="netMdnsStatusLabel" class="wesp-hw-sim-status">mDNS: выкл.</span>
</label>
<p class="wesp-sync-hint" id="netMdnsHint" style="margin: 8px 0 0; flex: 1 1 100%;"></p>
</div>
<div class="wesp-security-actions" style="margin-top: 16px;">
<button type="button" class="ant-btn ant-btn-primary" id="netSettingsSave"><span>Сохранить</span></button>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Журнал сети</div>
<div class="ant-card-extra">
<button type="button" class="ant-btn ant-btn-sm wesp-icon-btn" id="netEventsClearBtn" title="Очистить журнал" aria-label="Очистить журнал сети">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</button>
<button type="button" class="ant-btn ant-btn-sm" id="netEventsRefreshBtn" title="Обновить журнал">
<span>Обновить</span>
</button>
</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint" style="padding: 12px 16px 0;">
Сохранение настроек, запуск/остановка mDNS, ошибки объявления имени в LAN.
</p>
<div class="wesp-adm-list-wrap">
<ul id="networkEventsList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="security">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Безопасность и киоск</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint wesp-security-hint" id="securitySettingsHint"></p>
<div class="wesp-adm-list-wrap">
<ul class="wesp-adm-list wesp-security-list" role="list">
<li class="wesp-adm-list-item wesp-adm-list-section" role="separator"><div class="wesp-adm-list-section-text">MAC-адрес</div></li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<div class="wesp-adm-list-title">Ограничить доступ по списку MAC</div>
<div class="wesp-adm-list-desc">ACL по L2 MAC; только перечисленные интерфейсы.</div>
<p class="wesp-security-field-hint" id="secMacLockHint" hidden></p>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--toggle">
<label class="wesp-security-switch" aria-label="Ограничить по MAC">
<input type="checkbox" id="secMacLock" class="wesp-security-checkbox" />
<span class="wesp-security-switch-ui" aria-hidden="true"></span>
</label>
</div>
</li>
<li class="wesp-adm-list-item wesp-adm-list-item--multiline">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secAllowedMacs">Разрешённые MAC</label>
<div class="wesp-adm-list-desc">Список через запятую; формат EUI-48.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--full">
<textarea id="secAllowedMacs" class="ant-input wesp-security-input" rows="2" placeholder="aa:bb:cc:dd:ee:ff"></textarea>
</div>
</li>
<li class="wesp-adm-list-item wesp-adm-list-section" role="separator"><div class="wesp-adm-list-section-text">Киоск</div></li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<div class="wesp-adm-list-title">Только сопряжённые терминалы</div>
<div class="wesp-adm-list-desc">Доступ к /scales и kiosk API только с cookie привязанного терминала или Start URL. Выключите для отладки без привязки.</div>
<p class="wesp-security-field-hint" id="secKioskEnforceHint" hidden></p>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--toggle">
<label class="wesp-security-switch" aria-label="Только сопряжённые терминалы">
<input type="checkbox" id="secKioskEnforce" class="wesp-security-checkbox" />
<span class="wesp-security-switch-ui" aria-hidden="true"></span>
</label>
</div>
</li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secKioskPairTtl">Срок legacy pair-token</label>
<div class="wesp-adm-list-desc">TTL старого одноразового pair-token (legacy), с [30; 86400]. Start URL — постоянная ссылка.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--field"><input type="number" id="secKioskPairTtl" class="ant-input wesp-security-input" min="30" max="86400" step="1" /></div>
</li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secKioskCookieDays">Срок cookie привязки</label>
<div class="wesp-adm-list-desc">Срок cookie привязки терминала, сут.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--field"><input type="number" id="secKioskCookieDays" class="ant-input wesp-security-input" min="1" max="36500" step="1" /></div>
</li>
<li class="wesp-adm-list-item wesp-adm-list-section" role="separator"><div class="wesp-adm-list-section-text">Калибровка и токен устройства</div></li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<div class="wesp-adm-list-title">Публичная калибровка по токену</div>
<div class="wesp-adm-list-desc">Калибровка без admin-session при валидном DEVICE_TOKEN в заголовке.</div>
<p class="wesp-security-field-hint" id="secCalibrationPublicHint" hidden></p>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--toggle">
<label class="wesp-security-switch" aria-label="Публичная калибровка">
<input type="checkbox" id="secCalibrationPublic" class="wesp-security-checkbox" />
<span class="wesp-security-switch-ui" aria-hidden="true"></span>
</label>
</div>
</li>
<li class="wesp-adm-list-item wesp-security-note-row">
<p class="wesp-security-list-note" id="secDeviceTokenNote">Токен не отображается. Новое значение или флаг сброса ниже.</p>
</li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secDeviceTokenHeader">HTTP-заголовок токена</label>
<div class="wesp-adm-list-desc">HTTP header для секрета устройства; default X-Device-Token.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--field"><input type="text" id="secDeviceTokenHeader" class="ant-input wesp-security-input" placeholder="X-Device-Token" autocomplete="off" /></div>
</li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secDeviceToken">Новый токен устройства</label>
<div class="wesp-adm-list-desc">Пусто = без смены. После commit не читается из API.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--field"><input type="password" id="secDeviceToken" class="ant-input wesp-security-input" placeholder="Не менять — пусто" autocomplete="new-password" /></div>
</li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secDeviceTokenClear">Сбросить токен устройства</label>
<div class="wesp-adm-list-desc">Обнуление секрета; публичная калибровка OFF до нового токена.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--toggle"><input type="checkbox" id="secDeviceTokenClear" class="wesp-security-checkbox" aria-label="Сбросить токен" /></div>
</li>
<li class="wesp-adm-list-item wesp-adm-list-section" role="separator"><div class="wesp-adm-list-section-text">Вход в веб-интерфейс</div></li>
<li class="wesp-adm-list-item">
<div class="wesp-adm-list-main">
<label class="wesp-adm-list-title wesp-adm-list-title--form" for="secSessionRememberDays">Срок «Запомнить меня»</label>
<div class="wesp-adm-list-desc">PERMANENT_SESSION / «Запомнить»: срок, сут.</div>
</div>
<div class="wesp-adm-list-extra wesp-adm-list-extra--field"><input type="number" id="secSessionRememberDays" class="ant-input wesp-security-input" min="1" max="3650" step="1" /></div>
</li>
</ul>
</div>
<div class="wesp-security-actions">
<button type="button" class="ant-btn ant-btn-primary" id="securitySettingsSave"><span>Сохранить</span></button>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="hardware">
<div class="ant-card ant-card-bordered wesp-admin-card-gap" id="wespHardwareHistoryCard">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Нагрузка (история)</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-hardware-chart-hint" id="wespHardwareHistoryHint">
fetching…
</p>
<div class="wesp-hardware-chart-wrap" id="wespHardwareHistoryWrap" hidden>
<svg id="wespHardwareHistorySvg" class="wesp-hardware-chart-svg" viewBox="0 0 640 220" preserveAspectRatio="xMidYMid meet" role="img" aria-label="График нагрузки"></svg>
<div class="wesp-hardware-chart-legend" id="wespHardwareHistoryLegend"></div>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap" id="hwSimulationCard">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Симуляция весов</div>
</div>
</div>
<div class="ant-card-body">
<div class="wesp-hw-sim-head">
<label class="wesp-hw-sim-switch" for="hwSimulationToggle">
<input type="checkbox" id="hwSimulationToggle" role="switch" aria-describedby="hwSimulationStatusLabel" />
<span class="wesp-hw-sim-switch-ui" aria-hidden="true"></span>
<span id="hwSimulationStatusLabel" class="wesp-hw-sim-status">Выключена · чтение HX711</span>
</label>
</div>
<div id="hwSimulationPanel" class="wesp-hw-sim-panel" hidden>
<p class="wesp-sync-hint wesp-hw-sim-hint">
Имитация показаний для киоска и калибровки без датчика. Текущий показ:
<strong id="hwSimWeightLive">0</strong> кг
</p>
<div class="wesp-hw-sim-weight-row">
<button type="button" class="ant-btn" id="hwSimWeightMinus" title="1 кг" aria-label="Уменьшить на 1 кг"><span></span></button>
<input type="number" id="hwSimWeightInput" class="ant-input wesp-hw-sim-input" min="0" max="9999" step="1" inputmode="numeric" aria-label="Вес в килограммах" />
<span class="wesp-hw-sim-unit">кг</span>
<button type="button" class="ant-btn" id="hwSimWeightPlus" title="+1 кг" aria-label="Увеличить на 1 кг"><span>+</span></button>
<button type="button" class="ant-btn ant-btn-primary" id="hwSimWeightApply"><span>Применить</span></button>
</div>
<label class="wesp-sync-label" for="hwSimWeightSlider">Ползунок (0500 кг)</label>
<input type="range" id="hwSimWeightSlider" class="wesp-hw-sim-slider" min="0" max="500" step="1" value="0" aria-valuemin="0" aria-valuemax="500" />
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Железо и весы</div>
<div class="ant-card-extra">
<button type="button" class="ant-btn ant-btn-sm" id="hardwareRefreshBtn" title="GET /api/admin/hardware-status">
<span>Обновить</span>
</button>
</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint" id="hardwareStatusHint" style="margin-top: 0; margin-bottom: 12px;">обновление…</p>
<div class="wesp-diagnostics-actions">
<button type="button" class="ant-btn" id="hwHxSampleBtn"><span>HX711 sample</span></button>
<button type="button" class="ant-btn" id="hwGpioBlinkBtn"><span>GPIO blink ×3</span></button>
</div>
<div class="wesp-adm-list-wrap">
<ul id="hardwareHostList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-adm-list-wrap">
<ul id="hardwareHostAlertsList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-adm-list-wrap">
<ul id="hardwarePeriphList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-adm-list-wrap">
<ul id="hardwareScalesList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Журнал событий оборудования</div>
<div class="ant-card-extra">
<button type="button" class="ant-btn ant-btn-sm wesp-icon-btn" id="hardwareEventsClearBtn" title="Очистить журнал" aria-label="Очистить журнал событий">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18"/><path d="M8 6V4h8v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>
</button>
</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint" style="padding: 12px 16px 0;">
HX711, GPIO, пороги CPU/RAM/диска. При замыкании или неверной обвязке смотри коды <code>hx711_read_error</code>.
</p>
<div class="wesp-adm-list-wrap">
<ul id="hardwareEventsList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="diagnostics">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper wesp-diag-summary-head">
<div>
<div class="ant-card-head-title">Сводка диагностики</div>
<p class="wesp-sync-hint wesp-diag-summary-head-sub">
Топология узла и LAN; эвристический аудит безопасности; периферия (GPIO BCM, АЦП HX711).
</p>
</div>
<button type="button" class="ant-btn" id="diagRefreshReportBtn" title="GET /api/admin/diagnostics/report">
<span>Обновить отчёт</span>
</button>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint wesp-diagnostics-hint" id="diagNodeHint"></p>
<div class="wesp-adm-list-wrap">
<ul id="diagOverviewList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Сеть: сервер и клиенты</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">L3 path асимметричен. Полная картина: прогон на server- и client-узле.</p>
<div class="wesp-diagnostics-actions">
<button type="button" class="ant-btn ant-btn-primary" id="diagRunPrimaryBtn"><span>network-run: primary</span></button>
<button type="button" class="ant-btn" id="diagRunPrimaryTraceBtn"><span>L3 trace (modal)</span></button>
<button type="button" class="ant-btn" id="diagRunInternetBtn"><span>HTTP probe (WAN)</span></button>
</div>
<div class="wesp-adm-list-wrap">
<ul id="diagPrimaryTargetsList" class="wesp-adm-list" role="list"></ul>
</div>
<div class="wesp-adm-list-wrap wesp-diagnostics-results-wrap">
<ul id="diagNetworkResultsList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Дополнительные точки LAN</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint">Персистенция в network diagnostics JSON на данном хосте; до trace — save.</p>
<div id="diagExtraTargetsRows" class="wesp-diagnostics-targets"></div>
<div class="wesp-diagnostics-actions">
<button type="button" class="ant-btn" id="diagAddExtraTargetBtn"><span>+ target row</span></button>
<button type="button" class="ant-btn ant-btn-primary" id="diagSaveExtraTargetsBtn"><span>PATCH extra_targets</span></button>
<button type="button" class="ant-btn" id="diagRunExtraTargetsBtn"><span>network-run: extra</span></button>
</div>
<p class="wesp-sync-hint wesp-diagnostics-path" id="diagSettingsPath"></p>
</div>
</div>
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Безопасность и секреты</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<p class="wesp-sync-hint">
Эвристика: только булевы индикаторы (OK / Ошибка) и текст рекомендаций.
Список ниже заполняется после <strong>GET /api/admin/diagnostics/report</strong> (раздел «Диагностика» в меню слева).
</p>
<div class="wesp-adm-list-wrap">
<ul id="diagSecurityList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Железо и весы</div>
</div>
</div>
<div class="ant-card-body">
<div class="wesp-diagnostics-actions">
<button type="button" class="ant-btn" id="diagHxSampleBtn"><span>HX711 sample</span></button>
<button type="button" class="ant-btn" id="diagGpioBlinkBtn"><span>GPIO blink ×3</span></button>
</div>
<div class="wesp-adm-list-wrap">
<ul id="diagHardwareList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="users">
<div class="ant-card ant-card-bordered wesp-admin-card-gap">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Добавить пользователя</div>
</div>
</div>
<div class="ant-card-body">
<form id="createUserForm" class="ant-form ant-form-inline">
<div class="ant-form-item" style="margin-right: 12px; margin-bottom: 8px;">
<input id="newUserLogin" class="ant-input" type="text" placeholder="Логин" required style="width: 200px;">
</div>
<div class="ant-form-item" style="margin-right: 12px; margin-bottom: 8px;">
<input id="newUserPassword" class="ant-input" type="password" placeholder="Пароль (мин. 8 симв., буквы и цифры)" required style="width: 220px;" minlength="8">
</div>
<div class="ant-form-item" style="margin-right: 12px; margin-bottom: 8px;">
<label class="ant-checkbox-wrapper">
<span class="ant-checkbox">
<input id="newUserIsSuper" type="checkbox" class="ant-checkbox-input">
<span class="ant-checkbox-inner"></span>
</span>
<span>Суперпользователь</span>
</label>
</div>
<div class="ant-form-item" style="margin-bottom: 8px;">
<button type="submit" class="ant-btn ant-btn-primary"><span>Создать</span></button>
</div>
</form>
</div>
</div>
<div class="ant-card ant-card-bordered">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Список пользователей</div>
</div>
</div>
<div class="ant-card-body" style="padding: 0;">
<div class="wesp-adm-list-wrap">
<ul id="usersList" class="wesp-adm-list" role="list"></ul>
</div>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="assistant">
<div class="ant-card ant-card-bordered wesp-admin-card-gap wesp-admin-block">
<div class="ant-card-head">
<div class="ant-card-head-wrapper">
<div class="ant-card-head-title">Локальный ассистент</div>
<div class="ant-card-extra">
<span class="ant-tag" id="wespAssistantStatusBadge"></span>
</div>
</div>
</div>
<div class="ant-card-body">
<p class="wesp-sync-hint" style="margin: 0 0 8px;">
Доступно только суперпользователю. Запросы к модели <strong>не отправляются в облако</strong> — только на ваш
локальный сервер (<code class="wesp-admin-code-inline" id="wespAssistantLlmUrlHint">127.0.0.1</code>,
OpenAI-совместимый API, чаще всего <code class="wesp-admin-code-inline">llama-server</code>).
</p>
<p class="wesp-sync-hint" style="margin: 0 0 10px; max-width: 820px; padding: 10px 12px; border-radius: 8px; background: rgba(128, 128, 128, 0.1);">
<strong>Как узнать, что с системой:</strong> включите ниже <strong>«Чат с контекстом БД»</strong> — в диалог идут среда и PRAGMA по файлам SQLite (<strong>не</strong> строки таблиц раздатчиков/рецептов; полный дамп — <strong>«Снимок диагностики»</strong>).
Отчёты/склад за даты — <strong>«Сводка за период»</strong>.
Ещё: дашборд, журналы, <a href="/db_check">Проверка БД</a>.
</p>
<ul class="wesp-assistant-hint-list">
<li>Это обычная генерация текста, <strong>не режим «размышления»</strong> (как у отдельных облачных моделей): ответ появляется сразу после обработки запроса.</li>
<li><strong>Чат</strong> — по умолчанию лёгкий (без БД в промпте). Галка «Чат с контекстом БД» — среда + PRAGMA по .sqlite, <strong>без строк справочников</strong> (тяжелее по токенам). <strong>Сводка за период</strong> — отчёты/склад. Полный дамп ORM — «Снимок диагностики». При включённых <strong>инструментах</strong> модель может вызывать slim-SQL (только SELECT, сначала preview) и анализ логов; выполнение SELECT — только в режиме «Выполнять SELECT».</li>
<li>Каталог файлов моделей (GGUF): переменная <code class="wesp-admin-code-inline">WESP_ASSISTANT_DIR</code>.</li>
</ul>
<div class="wesp-assistant-settings-block" style="margin-bottom: 16px; padding: 12px 14px; border-radius: 8px; border: 1px solid rgba(128, 128, 128, 0.22);">
<div class="wesp-assistant-subtitle" style="margin-top: 0;">Управление ИИ</div>
<div style="margin-bottom: 10px;">
<label class="wesp-sync-label" style="display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<input type="checkbox" id="wespAssistantAdminLlmEnabled" />
<span><strong>Включить ИИ</strong> (чат, сводки, ping, инструменты)</span>
</label>
<p class="wesp-sync-hint" id="wespAssistantAdminLlmEnabledHint" style="margin: 6px 0 0; max-width: 720px; display: none;"></p>
<p class="wesp-sync-hint" style="margin: 6px 0 0; max-width: 720px;">
Флаг <code class="wesp-admin-code-inline">admin_llm_enabled</code>
<code class="wesp-admin-code-inline">data/wesp_security_settings.json</code>;
env <code class="wesp-admin-code-inline">WESP_ADMIN_LLM_ENABLED</code> имеет приоритет.
При выключении API <code class="wesp-admin-code-inline">/api/admin/llm/*</code> возвращает 503.
</p>
</div>
<div>
<label class="wesp-sync-label" style="display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<input type="checkbox" id="wespAssistantLlmAutostart" />
<span>Автозапуск <code class="wesp-admin-code-inline">llama-server</code> при старте WESP</span>
</label>
<p class="wesp-sync-hint" id="wespAssistantLlmAutostartHint" style="margin: 6px 0 0; max-width: 720px; display: none;"></p>
<p class="wesp-sync-hint" style="margin: 6px 0 0; max-width: 720px;">
Ключ <code class="wesp-admin-code-inline">llm_autostart</code> в том же JSON; env
<code class="wesp-admin-code-inline">WESP_LLM_AUTOSTART</code>. Действует после перезапуска WESP
(если ИИ включён и есть GGUF в <code class="wesp-admin-code-inline">WESP_ASSISTANT_DIR/models</code>).
</p>
</div>
</div>
<div style="margin-bottom: 12px;">
<label class="wesp-sync-label" style="display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<input type="checkbox" id="wespAssistantChatDbContext" />
<span>Чат с контекстом БД (среда + PRAGMA SQLite; без строк таблиц)</span>
</label>
<p class="wesp-sync-hint" id="wespAssistantChatDbContextHint" style="margin: 6px 0 0; max-width: 720px; display: none;"></p>
<p class="wesp-sync-hint" style="margin: 6px 0 0; max-width: 720px;">
Ключ <code class="wesp-admin-code-inline">llm_chat_db_context</code> в
<code class="wesp-admin-code-inline">data/wesp_security_settings.json</code>.
Переменная <code class="wesp-admin-code-inline">WESP_LLM_CHAT_INCLUDE_DB_CONTEXT</code> в окружении имеет приоритет. Полный дамп таблиц по-прежнему только в «Снимок диагностики».
</p>
</div>
<div style="margin-bottom: 12px;">
<label class="wesp-sync-label" style="display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap;">
<input type="checkbox" id="wespAssistantLlmToolsEnabled" />
<span>Инструменты в чате (slim-SQL, логи, сводка лога)</span>
</label>
<p class="wesp-sync-hint" id="wespAssistantLlmToolsEnabledHint" style="margin: 6px 0 0; max-width: 720px; display: none;"></p>
<p class="wesp-sync-hint" style="margin: 6px 0 0; max-width: 720px;">
Ключ <code class="wesp-admin-code-inline">llm_tools_enabled</code> в
<code class="wesp-admin-code-inline">data/wesp_security_settings.json</code>.
Переменная <code class="wesp-admin-code-inline">WESP_LLM_TOOLS_ENABLED</code> в окружении имеет приоритет.
</p>
</div>
<div style="margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 8px; align-items: center;">
<button type="button" class="ant-btn" id="wespAssistantRefreshStatusBtn"><span>Проверить состояние</span></button>
<button type="button" class="ant-btn" id="wespAssistantRefreshLogBtn"><span>Обновить журнал ИИ</span></button>
</div>
<div
id="wespAssistantLlmLogTerminal"
class="wesp-admin-activity-terminal wesp-assistant-llm-log"
aria-label="Журнал вызовов ИИ"
style="margin-bottom: 20px;"
>
<div class="wesp-admin-activity-terminal__chrome">
<span class="wesp-admin-activity-terminal__dots" aria-hidden="true"></span>
<span class="wesp-admin-activity-terminal__title">wesp-llm · journal</span>
<span class="wesp-admin-activity-terminal__badge" id="wespAssistantLlmLogBadge">7d · JSONL</span>
</div>
<div
id="wespAssistantLlmLog"
class="wesp-admin-activity-terminal__body"
role="log"
aria-live="polite"
></div>
</div>
<div class="wesp-assistant-subtitle" id="wespAssistantChatSectionTitle">Диалог с моделью</div>
<p class="wesp-sync-hint" style="margin: 0 0 8px;">Пока идёт запрос, в ленте показывается «Генерируется ответ…». Можно отправить сочетанием <kbd>Enter</kbd> (новая строка — <kbd>Shift+Enter</kbd>).</p>
<div
id="wespAssistantChatLog"
class="wesp-admin-modal-pre wesp-assistant-chat-log"
role="log"
aria-live="polite"
></div>
<textarea
id="wespAssistantChatInput"
class="ant-input"
rows="3"
placeholder="Напишите вопрос…"
style="width: 100%; max-width: 640px; margin-bottom: 8px;"
></textarea>
<div class="wesp-assistant-chat-toolbar" style="margin-bottom: 12px; display: flex; flex-wrap: wrap; gap: 12px; align-items: center; max-width: 720px;">
<label class="wesp-sync-label" style="display: inline-flex; align-items: center; gap: 6px;">
<span>SQL к БД:</span>
<select id="wespAssistantSqlMode" class="ant-input" title="preview — только EXPLAIN; execute — чтение строк SELECT (read-only)">
<option value="preview" selected>только план (preview)</option>
<option value="execute">выполнять SELECT</option>
</select>
</label>
<label class="wesp-sync-label" style="display: inline-flex; align-items: center; gap: 6px;">
<input type="checkbox" id="wespAssistantToolTraceCb" checked />
<span>Показать trace инструментов</span>
</label>
</div>
<div style="margin-bottom: 20px;">
<button type="button" class="ant-btn ant-btn-primary" id="wespAssistantChatSendBtn"><span>Отправить</span></button>
<button type="button" class="ant-btn" id="wespAssistantChatClearBtn"><span>Очистить диалог</span></button>
</div>
<div class="wesp-assistant-subtitle" style="margin-top: 4px;">Trace инструментов (последний ответ)</div>
<pre id="wespAssistantToolTrace" class="wesp-admin-modal-pre wesp-assistant-tool-trace" style="margin-top: 6px; max-height: 220px; overflow: auto;"></pre>
<div class="wesp-assistant-subtitle">Сводка по данным из базы</div>
<p class="wesp-sync-hint" style="margin: 0 0 8px;">Модель получает выгрузку отчётов/склада за выбранный период (тяжелее обычного чата).</p>
<div class="ant-row" style="margin-left: -8px; margin-right: -8px; align-items: flex-end;">
<div class="ant-col ant-col-xs-24 ant-col-sm-8" style="padding: 8px;">
<label class="wesp-admin-field-label" for="wespAssistantDateFrom">Период с</label>
<input type="date" id="wespAssistantDateFrom" class="ant-input" />
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-8" style="padding: 8px;">
<label class="wesp-admin-field-label" for="wespAssistantDateTo">Период по</label>
<input type="date" id="wespAssistantDateTo" class="ant-input" />
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-8" style="padding: 8px;">
<label class="wesp-admin-field-label" for="wespAssistantFocus">Что включить</label>
<select id="wespAssistantFocus" class="ant-input" title="both — отчёты и склад; deviations — только отклонения; stock — склад">
<option value="both" selected>отчёты и склад (both)</option>
<option value="deviations">только отклонения (deviations)</option>
<option value="stock">только склад (stock)</option>
</select>
</div>
</div>
<div style="margin-top: 8px; flex-wrap: wrap; gap: 8px; display: flex; align-items: center;">
<button type="button" class="ant-btn" id="wespAssistantPingBtn" title="Короткий запрос к модели без чтения БД — проверка цепочки WESP → llama-server"><span>Проверка связи (без БД)</span></button>
<button type="button" class="ant-btn ant-btn-primary" id="wespAssistantSummaryBtn"><span>Сводка за период</span></button>
<button type="button" class="ant-btn" id="wespAssistantDiagnosticsBtn" title="GET /api/admin/llm/diagnostics — полный JSON и текст для модели"><span>Снимок диагностики</span></button>
</div>
<div class="wesp-assistant-subtitle" style="margin-top: 16px;">Результат проверки / сводки / диагностики</div>
<pre id="wespAssistantOutput" class="wesp-admin-modal-pre wesp-assistant-output-pre" style="margin-top: 8px;"></pre>
</div>
</div>
</section>
<section class="wesp-admin-section" data-section-content="links">
<div class="ant-row" style="margin-left: -8px; margin-right: -8px;">
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<a class="ant-card ant-card-bordered ant-card-hoverable" href="/db_check" style="display: block; color: inherit;">
<div class="ant-card-body">Проверка БД</div>
</a>
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<a class="ant-card ant-card-bordered ant-card-hoverable" href="/scales" style="display: block; color: inherit;">
<div class="ant-card-body">Весы</div>
</a>
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<a class="ant-card ant-card-bordered ant-card-hoverable" href="/calibration" style="display: block; color: inherit;">
<div class="ant-card-body">Калибровка</div>
</a>
</div>
<div class="ant-col ant-col-xs-24 ant-col-sm-12 ant-col-lg-6" style="padding: 8px;">
<a class="ant-card ant-card-bordered ant-card-hoverable" href="/recipes" style="display: block; color: inherit;">
<div class="ant-card-body">Рецепты</div>
</a>
</div>
</div>
</section>
<div
id="adminActivityTerminal"
class="wesp-admin-activity-terminal"
aria-label="Activity: UI JSONL + wesp.log tail, 72h"
>
<div class="wesp-admin-activity-terminal__chrome">
<span class="wesp-admin-activity-terminal__dots" aria-hidden="true"></span>
<span class="wesp-admin-activity-terminal__title">wesp-admin · activity</span>
<span class="wesp-admin-activity-terminal__badge">72h · ui + llm + wesp.log</span>
</div>
<div
id="adminActivityLog"
class="wesp-admin-activity-terminal__body"
role="log"
aria-live="polite"
aria-relevant="additions"
></div>
</div>
</main>
</div>
</div>
<!-- Ant Design: ant-modal-mask + ant-modal-wrap + ant-modal + ant-modal-content -->
<div id="adminModal" class="wesp-admin-modal" hidden>
<div class="ant-modal-mask" id="adminModalMask"></div>
<div
class="ant-modal-wrap"
role="dialog"
aria-modal="true"
aria-labelledby="adminModalTitle"
tabindex="-1"
>
<div class="ant-modal wesp-admin-log-modal">
<div class="ant-modal-content">
<button type="button" class="ant-modal-close" id="adminModalClose" aria-label="Закрыть">
<span class="ant-modal-close-x">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</span>
</button>
<div class="ant-modal-header">
<div class="ant-modal-title" id="adminModalTitle">Журнал</div>
</div>
<div class="ant-modal-body">
<pre id="adminModalBody" class="wesp-admin-modal-pre"></pre>
</div>
</div>
</div>
</div>
</div>
<div id="userModulesModal" class="wesp-admin-modal" hidden>
<div class="ant-modal-mask" id="userModulesModalMask"></div>
<div
class="ant-modal-wrap"
role="dialog"
aria-modal="true"
aria-labelledby="userModulesModalTitle"
tabindex="-1"
>
<div class="ant-modal wesp-admin-user-modules-modal">
<div class="ant-modal-content">
<button type="button" class="ant-modal-close" id="userModulesModalClose" aria-label="Закрыть">
<span class="ant-modal-close-x">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</span>
</button>
<div class="ant-modal-header">
<div class="ant-modal-title" id="userModulesModalTitle">Доступ к модулям</div>
</div>
<div class="ant-modal-body">
<p class="wesp-sync-hint" style="margin: 0 0 12px;">
Пользователь: <strong id="userModulesModalLogin"></strong>
</p>
<ul class="wesp-adm-list" role="list">
<li class="wesp-adm-list-item wesp-adm-list-item--multiline">
<div class="wesp-adm-list-main">
<div class="wesp-adm-list-title">Lab</div>
<div class="wesp-adm-list-desc">Zootech: рационы, нутриенты, авторацион</div>
</div>
<div class="wesp-adm-list-extra">
<label class="ant-checkbox-wrapper">
<span class="ant-checkbox">
<input id="userModuleLabAccess" type="checkbox" class="ant-checkbox-input">
<span class="ant-checkbox-inner"></span>
</span>
<span>Включён</span>
</label>
</div>
</li>
</ul>
<p id="userModuleLabSuperHint" class="wesp-sync-hint" hidden style="margin: 12px 0 0;">
У суперпользователя модуль Lab доступен всегда.
</p>
</div>
<div class="ant-modal-footer">
<button type="button" class="ant-btn" id="userModulesModalCancel"><span>Отмена</span></button>
<button type="button" class="ant-btn ant-btn-primary" id="userModulesModalSave"><span>Сохранить</span></button>
</div>
</div>
</div>
</div>
</div>
<div id="tracerouteModal" class="wesp-admin-modal" hidden>
<div class="ant-modal-mask" id="tracerouteModalMask"></div>
<div
class="ant-modal-wrap"
role="dialog"
aria-modal="true"
aria-labelledby="tracerouteModalTitle"
tabindex="-1"
>
<div class="ant-modal wesp-admin-traceroute-modal">
<div class="ant-modal-content">
<button type="button" class="ant-modal-close" id="tracerouteModalClose" aria-label="Закрыть">
<span class="ant-modal-close-x">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</span>
</button>
<div class="ant-modal-header">
<div class="ant-modal-title" id="tracerouteModalTitle">L3 path analysis</div>
</div>
<div class="ant-modal-body wesp-admin-traceroute-modal-body">
<p class="wesp-sync-hint" style="margin: 0 0 8px;">
Хосты: <strong>1 строка = 1 FQDN/IP</strong>. Backend: <code class="wesp-admin-code-inline">traceroute</code>/<code class="wesp-admin-code-inline">tracepath</code> (или mtr); парсинг stdout.
</p>
<textarea
id="tracerouteHostsInput"
class="ant-input"
rows="6"
style="width: 100%; font-family: ui-monospace, monospace; font-size: 12px; resize: vertical;"
placeholder="192.168.0.1&#10;8.8.8.8"
></textarea>
<div class="wesp-diagnostics-actions" style="margin-top: 12px;">
<button type="button" class="ant-btn ant-btn-primary" id="tracerouteRunBtn"><span>POST traceroute-by-hosts</span></button>
<button type="button" class="ant-btn" id="tracerouteFillPrimaryBtn"><span>fill primary_targets</span></button>
<button type="button" class="ant-btn" id="tracerouteClearHostsBtn"><span>clear</span></button>
</div>
<div id="tracerouteModalResults" class="wesp-admin-traceroute-results"></div>
</div>
</div>
</div>
</div>
</div>
<div id="adminSqliteModal" class="wesp-admin-modal" hidden>
<div class="ant-modal-mask" id="adminSqliteModalMask"></div>
<div
class="ant-modal-wrap"
role="dialog"
aria-modal="true"
aria-labelledby="adminSqliteModalTitle"
tabindex="-1"
>
<div class="ant-modal wesp-admin-sqlite-modal">
<div class="ant-modal-content">
<button type="button" class="ant-modal-close" id="adminSqliteModalClose" aria-label="Закрыть">
<span class="ant-modal-close-x">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>
</span>
</button>
<div class="ant-modal-header">
<div class="ant-modal-title" id="adminSqliteModalTitle">НЕ НАКОСЯЧ</div>
</div>
<div class="ant-modal-body wesp-admin-sqlite-modal-body wesp-db-browser-body">
<p class="wesp-db-browser__intro">
SQLite по bind: дерево таблиц. Commit → запись в файлы на сервере; операции необратимы без бэкапа.
</p>
<div class="wesp-db-browser__toolbar">
<button type="button" class="ant-btn ant-btn-sm" id="adminDbRefreshTreeBtn">
<span>Обновить дерево</span>
</button>
<button type="button" class="ant-btn ant-btn-sm" id="adminDbRefreshGridBtn">
<span>Обновить таблицу</span>
</button>
</div>
<p id="adminSqliteError" class="wesp-sqlite-editor__error" aria-live="polite"></p>
<div class="wesp-db-browser-layout">
<div class="wesp-db-tree-panel">
<div class="wesp-db-tree-panel-title">Каталог таблиц</div>
<ul id="adminDbTree" class="wesp-db-tree" role="tree"></ul>
</div>
<div class="wesp-db-grid-panel">
<div id="adminDbGridMeta" class="wesp-db-grid-meta"></div>
<div id="adminDbPager" class="wesp-db-pager"></div>
<div id="adminDbGridWrap" class="wesp-db-grid-wrap"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Скелетоны сразу после разметки (модуль грузится позже и не успевает до первого кадра) -->
<script>
(function () {
try {
document.documentElement.classList.add("wesp-admin-loading");
} catch (e1) {}
var app = document.getElementById("app");
if (!app) return;
var seen = new Set();
function add(el) {
if (!el || seen.has(el)) return;
seen.add(el);
el.classList.add("wesp-admin-block");
for (var i = 0; i < el.children.length; i++) {
if (el.children[i].classList.contains("wesp-admin-skel-overlay")) return;
}
var ov = document.createElement("div");
ov.className = "wesp-admin-skel-overlay";
ov.setAttribute("aria-hidden", "true");
el.appendChild(ov);
}
add(app.querySelector(".wesp-admin-sider"));
add(app.querySelector("main.ant-layout-content .wesp-admin-content-topbar"));
var cards = app.querySelectorAll("main.ant-layout-content .ant-card");
for (var j = 0; j < cards.length; j++) add(cards[j]);
window.__WESP_ADMIN_SKEL_ARMED_AT__ = performance.now();
})();
</script>
<script src="/static/js/wesp-user-messages.js?v=1"></script>
<script type="module" src="/static/js/pages/admin-panel.js?v=20260714"></script>
<link href="/static/css/wesp-update-banner.css" rel="stylesheet">
<script src="/static/js/wesp-dialog.js"></script>
<script src="/static/js/wesp-update-notifier.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
if (window.WespUpdateNotifier && window.WespDialog) {
WespUpdateNotifier.init({ dialog: window.WespDialog });
}
});
</script>
<div id="kioskFullSetupOverlay" class="wesp-kiosk-setup-overlay" hidden aria-hidden="true">
<div class="wesp-kiosk-setup-overlay__screen wesp-startup-screen">
<div class="wesp-startup__stage">
<div class="wesp-setup__intro" id="kioskFullSetupIntro"></div>
<p class="wesp-startup__quip" id="kioskFullSetupQuip" aria-live="polite"></p>
</div>
</div>
</div>
</body>
</html>