UI fix
This commit is contained in:
1 parent
ee2a29cb36
commit
4f597505cc
30 files changed
+829
-304
No files matched your search
+1
-1
@@ -1,4 +1,4 @@
|
||||
992ec763b2dc26f87d5391e072904e5897863a0c4c5b27c3c4980aa03f037c5e control-api
|
||||
48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent
|
||||
43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober
|
||||
ef29de3e818c4e81f8606b9dffbbc9b5a0e62c886321094e5feb1f03e3df5958 admin-dashboard
|
||||
3aa24ef8e862e467071fa238e89884127f240e4b33f131fb63e95439307ec639 admin-dashboard
|
||||
Binary file not shown.
+14
-12
@@ -11,18 +11,20 @@
|
||||
|
||||
- Рендеринг полностью на сервере: `html/template` + [htmx](https://htmx.org)
|
||||
(частичные обновления без перезагрузки страницы) +
|
||||
[Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность) +
|
||||
[Bootstrap 5](https://getbootstrap.com) (стабильная 5.3.x) для оформления
|
||||
— компактные таблицы/формы/бейджи через `table-sm`/`*-sm`-утилиты и
|
||||
плотную сетку, без кастомной вёрстки с нуля. Только CSS-часть Bootstrap:
|
||||
JS-бандл (навигация-collapse/модалки/тултипы) не подключён — ничего из
|
||||
этого дашборд не использует, подтверждения — через нативный
|
||||
`hx-confirm`. Никакой сборки фронтенда нет — все библиотеки вендорены
|
||||
как статические файлы (`internal/dashboard/static/vendor/`, см.
|
||||
`VENDOR.md` там же) и встроены в бинарник через `//go:embed`. Дашборд
|
||||
работает полностью офлайн — при открытии страницы браузер не делает ни
|
||||
одного запроса за пределы самого дашборда (можно проверить через
|
||||
DevTools → Network).
|
||||
[Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность).
|
||||
Оформление — собственная небольшая дизайн-система
|
||||
(`internal/dashboard/static/dashboard.css`), не построена на готовом
|
||||
CSS-фреймворке: боковая навигация, карточки-панели со скруглением и
|
||||
мягкой тенью, статусы — пастельные «пилюли» (не Bootstrap-бейджи).
|
||||
Шрифты — Manrope (интерфейс/заголовки) и IBM Plex Mono (IP-адреса,
|
||||
таймстампы, числа — моноширинные для табличного выравнивания), с
|
||||
кириллицей. Адаптивна: боковая панель на узких экранах уходит в
|
||||
выдвижное меню, таблицы складываются в карточки. Никакой сборки
|
||||
фронтенда нет — шрифты и JS-библиотеки вендорены как статические файлы
|
||||
(`internal/dashboard/static/vendor/`, см. `VENDOR.md` там же) и встроены
|
||||
в бинарник через `//go:embed`. Дашборд работает полностью офлайн — при
|
||||
открытии страницы браузер не делает ни одного запроса за пределы самого
|
||||
дашборда (можно проверить через DevTools → Network).
|
||||
- Браузер никогда не видит JSON `control-api` напрямую: каждая страница и
|
||||
каждый htmx-фрагмент — это HTML, отрендеренный Go-хендлером дашборда
|
||||
после вызова `control-api`. CORS и reverse-proxy не нужны.
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestOverviewFragment(t *testing.T) {
|
||||
if !strings.Contains(body, "2.2.2.2") || !strings.Contains(body, "3.3.3.3") {
|
||||
t.Fatalf("expected both completed ips in last-completed section, got:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "pass: 1") || !strings.Contains(body, "cancelled: 1") {
|
||||
if !strings.Contains(body, "pass <b>1</b>") || !strings.Contains(body, "cancelled <b>1</b>") {
|
||||
t.Fatalf("expected breakdown pass:1 cancelled:1, got:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Badge is the {class, label} pair rendered via Bootstrap's .badge
|
||||
// component (see static/dashboard.css for the one addition Bootstrap
|
||||
// doesn't ship — .bg-cancelled).
|
||||
// Badge is the {class, label} pair rendered via the dashboard's own .pill
|
||||
// component (see static/dashboard.css) — Class is a modifier to combine
|
||||
// with "pill" in the template (e.g. `class="pill {{.Class}}"`).
|
||||
type Badge struct{ Class, Label string }
|
||||
|
||||
func ipBadge(state, result string) Badge {
|
||||
@@ -18,31 +18,31 @@ func ipBadge(state, result string) Badge {
|
||||
case "done", "failed":
|
||||
switch result {
|
||||
case "pass":
|
||||
return Badge{"badge bg-success", "pass"}
|
||||
return Badge{"pill-success", "pass"}
|
||||
case "partial":
|
||||
return Badge{"badge bg-warning text-dark", "partial"}
|
||||
return Badge{"pill-warning", "partial"}
|
||||
case "cancelled":
|
||||
return Badge{"badge bg-cancelled", "cancelled"}
|
||||
return Badge{"pill-cancel", "cancelled"}
|
||||
default:
|
||||
return Badge{"badge bg-danger", "fail"}
|
||||
return Badge{"pill-danger", "fail"}
|
||||
}
|
||||
case "queued":
|
||||
return Badge{"badge bg-secondary", "queued"}
|
||||
return Badge{"pill-neutral", "queued"}
|
||||
default:
|
||||
return Badge{"badge bg-info text-dark", state}
|
||||
return Badge{"pill-info", state}
|
||||
}
|
||||
}
|
||||
|
||||
func validatorBadge(state string) Badge {
|
||||
switch state {
|
||||
case "idle":
|
||||
return Badge{"badge bg-success", "idle"}
|
||||
return Badge{"pill-success", "idle"}
|
||||
case "unreachable":
|
||||
return Badge{"badge bg-danger", "unreachable"}
|
||||
return Badge{"pill-danger", "unreachable"}
|
||||
case "unregistered":
|
||||
return Badge{"badge bg-secondary", "unregistered"}
|
||||
return Badge{"pill-neutral", "unregistered"}
|
||||
default:
|
||||
return Badge{"badge bg-info text-dark", state} // assigned / checking
|
||||
return Badge{"pill-info", state} // assigned / checking
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +1,318 @@
|
||||
/* Compact-density overrides on top of Bootstrap 5 (vendor/bootstrap.min.css)
|
||||
— Bootstrap's own *-sm utilities (table-sm, btn-sm, form-control-sm) do
|
||||
most of the work in the templates themselves; this file only adds what
|
||||
Bootstrap doesn't ship: a "cancelled" badge color, and a few global
|
||||
density knobs (smaller base font, tighter heading/section spacing) so
|
||||
more of the queue/config tables fit on screen without scrolling. */
|
||||
/* Cloud IP Validator — admin dashboard design system.
|
||||
Self-contained, no framework: tokens + a small set of components
|
||||
(sidebar shell, panels, tables, pills, forms, buttons, alerts).
|
||||
Fonts are vendored separately — see vendor/fonts.css. */
|
||||
|
||||
:root {
|
||||
--bg: #F4F7F6;
|
||||
--surface: #FFFFFF;
|
||||
--surface-alt: #ECF2F1;
|
||||
--border: #E1E8E7;
|
||||
--border-soft: #EAF0EF;
|
||||
--text: #12201F;
|
||||
--text-muted: #5E6F6D;
|
||||
--text-faint: #8B9997;
|
||||
|
||||
--accent: #146C74;
|
||||
--accent-strong: #0F565C;
|
||||
--accent-soft: #E1F0EF;
|
||||
--accent-contrast: #FFFFFF;
|
||||
|
||||
--success: #1D8A5C; --success-soft: #E3F6EC;
|
||||
--warning: #B4790F; --warning-soft: #FBF0DD;
|
||||
--danger: #C13A2E; --danger-soft: #FCE8E6;
|
||||
--info: #3A6EA5; --info-soft: #E7EFF8;
|
||||
--neutral: #5B6B6C; --neutral-soft: #EDF1F0;
|
||||
--cancel: #7A5AC0; --cancel-soft: #F1ECFB;
|
||||
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 16px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(18, 32, 31, .05), 0 1px 1px rgba(18, 32, 31, .04);
|
||||
--shadow-md: 0 8px 24px rgba(18, 32, 31, .08);
|
||||
|
||||
--font-ui: "Manrope", -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-ui);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; }
|
||||
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
h2 {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
h1, h2, h3 { text-wrap: balance; }
|
||||
h1 { font-size: 21px; font-weight: 800; letter-spacing: -0.015em; margin: 0 0 4px; }
|
||||
h2.section-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
color: var(--text-muted);
|
||||
margin: 26px 0 10px;
|
||||
}
|
||||
section:first-of-type h2.section-title,
|
||||
main > h2.section-title:first-child { margin-top: 0; }
|
||||
|
||||
.navbar {
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
/* ---------- shell layout ---------- */
|
||||
.shell { display: grid; grid-template-columns: 232px 1fr; min-height: 100vh; }
|
||||
.sidebar {
|
||||
background: var(--surface-alt);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
padding: 20px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
.navbar-brand {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
.brand { display: flex; align-items: center; gap: 10px; padding: 0 8px; }
|
||||
.brand-mark {
|
||||
width: 30px; height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(155deg, var(--accent), var(--accent-strong));
|
||||
display: grid; place-items: center; flex-shrink: 0;
|
||||
}
|
||||
.brand-mark svg { width: 16px; height: 16px; }
|
||||
.brand-name { font-weight: 800; font-size: 14.5px; letter-spacing: -0.01em; line-height: 1.2; }
|
||||
.brand-name small { display: block; font-weight: 500; font-size: 11px; color: var(--text-muted); }
|
||||
|
||||
nav.nav-groups { display: flex; flex-direction: column; gap: 2px; }
|
||||
.nav-link {
|
||||
padding-top: 0.25rem !important;
|
||||
padding-bottom: 0.25rem !important;
|
||||
}
|
||||
|
||||
/* Bootstrap ships pass/fail-adjacent colors (success/warning/danger) but
|
||||
nothing for "administrator explicitly stopped this" — give cancelled
|
||||
its own recognizable hue rather than overloading danger/secondary. */
|
||||
.bg-cancelled {
|
||||
background-color: #6d28d9 !important;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
text-decoration: none;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 0.75em;
|
||||
font-size: 13.5px;
|
||||
transition: background .12s ease, color .12s ease;
|
||||
}
|
||||
.nav-link svg { width: 17px; height: 17px; flex-shrink: 0; opacity: .85; }
|
||||
.nav-link:hover { background: var(--border-soft); color: var(--text); }
|
||||
.nav-link.active { background: var(--accent-soft); color: var(--accent-strong); }
|
||||
.nav-link.active svg { opacity: 1; }
|
||||
|
||||
.sidebar-foot {
|
||||
margin-top: auto;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.pulse-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 0 3px var(--success-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.main { padding: 22px 28px 60px; max-width: 1180px; }
|
||||
.topbar {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
margin-bottom: 20px; flex-wrap: wrap; gap: 10px;
|
||||
}
|
||||
.topbar .sub { color: var(--text-muted); font-size: 12.5px; display: flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* ---------- stat cards ---------- */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 4px; }
|
||||
.stat-card {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 12px 14px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.stat-card .value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px; font-weight: 500;
|
||||
display: block;
|
||||
font-size: 1.35rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-card .label { font-size: 11.5px; color: var(--text-muted); font-weight: 600; }
|
||||
.stat-card.accented .value { color: var(--accent-strong); }
|
||||
|
||||
/* ---------- panels ---------- */
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel + .panel { margin-top: 14px; }
|
||||
.panel-body { padding: 16px 18px; }
|
||||
|
||||
/* ---------- forms ---------- */
|
||||
.field-row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||||
.field { display: flex; flex-direction: column; gap: 5px; flex: 1 1 220px; }
|
||||
.field label { font-size: 12px; font-weight: 700; color: var(--text-muted); }
|
||||
.form-check { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
|
||||
input[type="text"], input[type="search"], textarea, select {
|
||||
font-family: var(--font-ui);
|
||||
font-size: 13.5px;
|
||||
padding: 8px 11px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
::placeholder { color: var(--text-faint); }
|
||||
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-family: var(--font-ui);
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
font-size: 13px;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: transform .08s ease, background .12s ease, border-color .12s ease;
|
||||
white-space: nowrap;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.stat-card .label {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary { background: var(--accent); color: var(--accent-contrast); }
|
||||
.btn-primary:hover { background: var(--accent-strong); }
|
||||
.btn-ghost { background: transparent; border-color: var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--text-muted); }
|
||||
.btn-danger-ghost { background: transparent; border-color: var(--border); color: var(--danger); }
|
||||
.btn-danger-ghost:hover { background: var(--danger-soft); border-color: var(--danger); }
|
||||
.btn-sm { padding: 5px 10px; font-size: 12px; }
|
||||
.btn-block { width: 100%; justify-content: center; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
text-align: left;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
opacity: 0.65;
|
||||
letter-spacing: .04em;
|
||||
color: var(--text-faint);
|
||||
font-weight: 700;
|
||||
padding: 10px 18px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
tbody td { padding: 10px 18px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--bg); }
|
||||
td.addr { font-family: var(--font-mono); font-size: 13px; }
|
||||
td.num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; color: var(--text-muted); }
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
|
||||
.table > :not(caption) > * > * {
|
||||
padding-top: 0.35rem;
|
||||
padding-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
/* ---------- pills (state/result badges) ---------- */
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 3px 10px 3px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.actions-cell .btn {
|
||||
margin-right: 0.25rem;
|
||||
.pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.pill-neutral { background: var(--neutral-soft); color: var(--neutral); }
|
||||
.pill-info { background: var(--info-soft); color: var(--info); }
|
||||
.pill-success { background: var(--success-soft); color: var(--success); }
|
||||
.pill-warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.pill-danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.pill-cancel { background: var(--cancel-soft); color: var(--cancel); }
|
||||
|
||||
/* ---------- alerts ---------- */
|
||||
.alert {
|
||||
display: flex; gap: 10px; align-items: flex-start;
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.actions-cell .btn:last-child {
|
||||
margin-right: 0;
|
||||
.alert-warning { background: var(--warning-soft); color: var(--warning); border-color: #f0d9a8; }
|
||||
.alert-danger { background: var(--danger-soft); color: var(--danger); border-color: #f0c2bc; }
|
||||
.alert strong { color: var(--text); font-weight: 700; }
|
||||
#error-banner:empty { display: none; }
|
||||
#error-banner .alert { margin-bottom: 16px; }
|
||||
|
||||
.breakdown { display: flex; gap: 16px; flex-wrap: wrap; margin: 2px 0 14px; font-size: 12.5px; color: var(--text-muted); }
|
||||
.breakdown b { color: var(--text); font-family: var(--font-mono); font-weight: 500; }
|
||||
|
||||
.muted { color: var(--text-faint); font-size: 12px; }
|
||||
code.inline { font-family: var(--font-mono); background: var(--bg); border: 1px solid var(--border-soft); padding: 1px 6px; border-radius: 5px; font-size: 12px; }
|
||||
|
||||
.inline-form { display: flex; gap: 6px; align-items: center; }
|
||||
.inline-form input, .inline-form textarea { margin: 0; }
|
||||
|
||||
/* ---------- responsive ---------- */
|
||||
.nav-toggle { display: none; }
|
||||
.topbar-mobile { display: none; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.shell { grid-template-columns: 1fr; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 30% 0 0;
|
||||
z-index: 40;
|
||||
transform: translateX(-100%);
|
||||
transition: transform .18s ease;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
#nav-toggle:checked ~ .shell .sidebar { transform: translateX(0); }
|
||||
#nav-toggle:checked ~ .shell::after {
|
||||
content: "";
|
||||
position: fixed; inset: 0; z-index: 39;
|
||||
background: rgba(18, 32, 31, .28);
|
||||
}
|
||||
.topbar-mobile {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
background: var(--surface);
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
}
|
||||
label.burger {
|
||||
width: 34px; height: 34px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
display: grid; place-items: center; cursor: pointer; flex-shrink: 0;
|
||||
}
|
||||
.main { padding: 18px 16px 50px; }
|
||||
.stat-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.82rem;
|
||||
@media (max-width: 640px) {
|
||||
.field-row { flex-direction: column; align-items: stretch; }
|
||||
thead { display: none; }
|
||||
table, tbody, tr, td { display: block; width: 100%; }
|
||||
tbody tr { border-bottom: 1px solid var(--border-soft); padding: 10px 18px; }
|
||||
tbody td { border: none; padding: 3px 0; }
|
||||
tbody td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.inline-form {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
align-items: center;
|
||||
}
|
||||
.inline-form .form-control,
|
||||
.inline-form textarea.form-control {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#error-banner:empty {
|
||||
display: none;
|
||||
}
|
||||
#error-banner .alert {
|
||||
padding: 0.5rem 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
main {
|
||||
padding-bottom: 3rem;
|
||||
.actions { justify-content: flex-start; margin-top: 6px; }
|
||||
}
|
||||
+27
-12
@@ -9,18 +9,33 @@ commit, if a newer version is actually needed.
|
||||
|---|---|---|---|
|
||||
| `htmx.min.js` | https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js | 2.0.10 | 2026-08-23 |
|
||||
| `alpine.min.js` | https://unpkg.com/alpinejs@3.16.2/dist/cdn.min.js | 3.16.2 | 2026-08-23 |
|
||||
| `bootstrap.min.css` | https://unpkg.com/bootstrap@5.3.8/dist/css/bootstrap.min.css | 5.3.8 | 2026-08-23 |
|
||||
| `fonts.css` + `fonts/*.woff2` | Google Fonts CSS2 API (`family=Manrope:wght@500;600;700;800\|family=IBM+Plex+Mono:wght@400;500`) | Manrope v20, IBM Plex Mono v20 | 2026-08-23 |
|
||||
|
||||
Only Bootstrap's CSS is vendored — no `bootstrap.bundle.min.js`/Popper: the
|
||||
dashboard doesn't use any Bootstrap component that requires JS (navbar
|
||||
collapse, dropdowns, modals, tooltips). Confirmations use the browser's
|
||||
native dialog via htmx's `hx-confirm`, not a Bootstrap modal. If a future
|
||||
page genuinely needs Bootstrap JS, vendor `bootstrap.bundle.min.js`
|
||||
alongside this file rather than pulling it from a CDN.
|
||||
## Fonts
|
||||
|
||||
Previously used Pico CSS (classless build) — replaced by Bootstrap 5 for a
|
||||
more compact, conventional admin-dashboard look (denser tables/forms via
|
||||
`table-sm`/`*-sm` utilities) per explicit request.
|
||||
The dashboard's design system (`../dashboard.css`) uses **Manrope**
|
||||
(UI chrome/headings — a variable font, so one file per subset covers all
|
||||
four vendored weights) and **IBM Plex Mono** (tabular data: IP addresses,
|
||||
timestamps, ports — one file per weight per subset). Only the `latin`,
|
||||
`latin-ext`, `cyrillic`, and `cyrillic-ext` subsets are vendored (the UI
|
||||
is Russian/English only) — `greek` and `vietnamese` from the upstream CSS
|
||||
were dropped. `fonts.css` was hand-rewritten from the fetched Google Fonts
|
||||
CSS to point at the local `fonts/*.woff2` files instead of
|
||||
`fonts.gstatic.com`, keeping the original `unicode-range` subsetting so
|
||||
browsers still only fetch the subset(s) actually used.
|
||||
|
||||
Licenses: htmx (BSD 2-Clause), Alpine.js (MIT), Bootstrap (MIT) — all
|
||||
permit vendoring/redistribution unmodified.
|
||||
No CSS framework is vendored: the design system in `dashboard.css` is
|
||||
hand-written (sidebar shell, panels, tables, pills, forms, buttons,
|
||||
alerts) rather than built on Bootstrap/Pico/etc — see
|
||||
`docs/PLAN_ADMIN_DASHBOARD.md` for why (a framework's default look kept
|
||||
fighting the "modern, compact, rounded" brief; a small hand-rolled system
|
||||
gave full control for less code than fighting one).
|
||||
|
||||
Only htmx's and Alpine's CSS-adjacent-nothing JS is vendored — no
|
||||
`bootstrap.bundle.min.js`/Popper equivalent is needed since the dashboard
|
||||
doesn't use any component that requires JS beyond htmx/Alpine themselves
|
||||
(the mobile sidebar toggle is a pure-CSS checkbox trick, not JS;
|
||||
confirmations use the browser's native dialog via htmx's `hx-confirm`).
|
||||
|
||||
Licenses: htmx (BSD 2-Clause), Alpine.js (MIT), Manrope (OFL-1.1), IBM Plex
|
||||
Mono (OFL-1.1) — all permit vendoring/redistribution unmodified.
|
||||
@@ -1,6 +0,0 @@
|
||||
@charset "UTF-8";/*!
|
||||
* Bootstrap v5.3.8 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2025 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherLine truncated
|
||||
/*# sourceMappingURL=bootstrap.min.css.map */
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-cyrillic-ext.woff2") format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-cyrillic.woff2") format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-cyrillic-ext-2.woff2") format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-cyrillic-2.woff2") format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-latin-ext-2.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'IBM Plex Mono';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/ibm-plex-mono-latin-2.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic-ext.woff2") format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic.woff2") format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic-ext.woff2") format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic.woff2") format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic-ext.woff2") format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic.woff2") format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic-ext.woff2") format("woff2");
|
||||
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-cyrillic.woff2") format("woff2");
|
||||
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Manrope';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("fonts/manrope-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3,51 +3,54 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "checktypes_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "checktypes_content"}}
|
||||
<h1>Типы проверок</h1>
|
||||
<p class="muted">Тип проверки (https/icmp/ssh/...) ссылается на одну или несколько групп целей —
|
||||
<div class="topbar"><h1>Типы проверок</h1></div>
|
||||
<p class="muted" style="margin-bottom:16px">Тип проверки (https/icmp/ssh/...) ссылается на одну или несколько групп целей —
|
||||
именно развёрнутый список валидатор получает в задании.</p>
|
||||
|
||||
{{if not .Groups}}
|
||||
<p class="muted">Сначала создайте хотя бы одну <a href="/targets">группу целей</a>.</p>
|
||||
{{else}}
|
||||
<form class="card card-body mb-3 py-2" hx-post="/check-types" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label mb-1">Имя типа проверки</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="https" required>
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<form hx-post="/check-types" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="field-row">
|
||||
<div class="field" style="flex: 0 1 200px;">
|
||||
<label>Имя типа проверки</label>
|
||||
<input type="text" name="name" placeholder="https" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<div class="form-check mt-4">
|
||||
<input type="checkbox" class="form-check-input" name="enabled" value="1" id="ct-enabled" checked>
|
||||
<label class="form-check-label" for="ct-enabled">включён</label>
|
||||
<div class="field" style="flex: 0 1 auto;">
|
||||
<label> </label>
|
||||
<span class="form-check"><input type="checkbox" name="enabled" value="1" id="ct-enabled" checked> <label for="ct-enabled" style="font-weight:500">включён</label></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label mb-1">Группы целей</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div class="field" style="flex: 1 1 300px;">
|
||||
<label>Группы целей</label>
|
||||
<div style="display:flex; flex-wrap:wrap; gap:10px;">
|
||||
{{range .Groups}}
|
||||
<div class="form-check form-check-inline m-0">
|
||||
<input type="checkbox" class="form-check-input" name="targets" value="{{.}}" id="ct-group-{{.}}">
|
||||
<label class="form-check-label" for="ct-group-{{.}}">{{.}}</label>
|
||||
</div>
|
||||
<span class="form-check"><input type="checkbox" name="targets" value="{{.}}" id="ct-group-{{.}}"> <label for="ct-group-{{.}}" style="font-weight:500">{{.}}</label></span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Создать</button>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div id="checktypes-table-wrap">
|
||||
@@ -56,28 +59,32 @@
|
||||
{{end}}
|
||||
|
||||
{{define "checktypes_table"}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead><tr><th>Имя</th><th>Включён</th><th>Группы целей</th><th>Действия</th></tr></thead>
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Имя</th><th>Включён</th><th>Группы целей</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{if .Enabled}}<span class="badge bg-success">да</span>{{else}}<span class="badge bg-secondary">нет</span>{{end}}</td>
|
||||
<td>{{join .Targets ", "}}</td>
|
||||
<td class="actions-cell">
|
||||
<form class="d-inline" hx-put="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML">
|
||||
<td class="mono" data-label="Имя">{{.Name}}</td>
|
||||
<td data-label="Включён">{{if .Enabled}}<span class="pill pill-success">да</span>{{else}}<span class="pill pill-neutral">нет</span>{{end}}</td>
|
||||
<td data-label="Группы целей">{{join .Targets ", "}}</td>
|
||||
<td data-label="">
|
||||
<div class="actions">
|
||||
<form style="display:inline" hx-put="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML">
|
||||
<input type="hidden" name="targets" value="{{join .Targets ","}}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
|
||||
<button type="submit" class="btn btn-ghost btn-sm" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
|
||||
{{if .Enabled}}Выключить{{else}}Включить{{end}}
|
||||
</button>
|
||||
</form>
|
||||
<button class="btn btn-sm btn-outline-danger" hx-delete="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить тип проверки {{.Name}}?">Удалить</button>
|
||||
<button class="btn btn-danger-ghost btn-sm" hx-delete="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить тип проверки {{.Name}}?">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Items}}<p class="muted">Типов проверок нет.</p>{{end}}
|
||||
{{end}}
|
||||
@@ -3,23 +3,28 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "ip_detail_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "ip_detail_content"}}
|
||||
<p><a href="/ips">← К очереди</a></p>
|
||||
<h1 class="d-flex align-items-center gap-2">
|
||||
{{.Detail.IP.IPAddress}}
|
||||
{{$b := ipBadge .Detail.IP.State .Detail.IP.OverallResult}}
|
||||
<span class="{{$b.Class}} fs-6">{{$b.Label}}</span>
|
||||
</h1>
|
||||
<p class="muted mb-1">
|
||||
<div class="topbar">
|
||||
<h1 class="mono" style="display:flex;align-items:center;gap:10px">{{.Detail.IP.IPAddress}} <span class="pill {{$b.Class}}">{{$b.Label}}</span></h1>
|
||||
</div>
|
||||
<p class="muted">
|
||||
попытка {{.Detail.IP.AttemptNumber}} (повторов: {{.Detail.IP.RetryCount}})
|
||||
· валидатор {{deref .Detail.IP.OwnerValidatorID}}
|
||||
</p>
|
||||
@@ -28,35 +33,39 @@
|
||||
агрегирован: {{fmtTime .Detail.IP.AggregatedAt}} · FIP освобождён: {{fmtTime .Detail.IP.FIPReleasedAt}}
|
||||
</p>
|
||||
|
||||
<h2>Проверки (попытка {{.Detail.IP.AttemptNumber}})</h2>
|
||||
<h2 class="section-title">Проверки (попытка {{.Detail.IP.AttemptNumber}})</h2>
|
||||
{{if .Detail.Checks}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Источник</th><th>Тип</th><th>Цель</th><th>Успех</th><th>Задержка, мс</th><th>Время</th><th>Детали</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Detail.Checks}}
|
||||
<tr>
|
||||
<td>{{.Source}}</td><td>{{.CheckType}}</td><td>{{.Target}}</td>
|
||||
<td>{{if .Success}}<span class="badge bg-success">ok</span>{{else}}<span class="badge bg-danger">fail</span>{{end}}</td>
|
||||
<td>{{.LatencyMS}}</td><td>{{fmtTime .CheckedAt}}</td><td>{{.Detail}}</td>
|
||||
<td data-label="Источник">{{.Source}}</td><td data-label="Тип">{{.CheckType}}</td><td class="addr" data-label="Цель">{{.Target}}</td>
|
||||
<td data-label="Успех">{{if .Success}}<span class="pill pill-success">ok</span>{{else}}<span class="pill pill-danger">fail</span>{{end}}</td>
|
||||
<td class="num" data-label="Задержка, мс">{{.LatencyMS}}</td><td class="num" data-label="Время">{{fmtTime .CheckedAt}}</td><td data-label="Детали">{{.Detail}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}<p class="muted">Проверок пока нет.</p>{{end}}
|
||||
|
||||
<h2>События</h2>
|
||||
<h2 class="section-title">События</h2>
|
||||
{{if .Detail.Events}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Время</th><th>Тип</th><th>Источник</th><th>Данные</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Detail.Events}}
|
||||
<tr><td>{{fmtTime .OccurredAt}}</td><td>{{.EventType}}</td><td>{{.SourceType}} {{.SourceID}}</td><td>{{.Payload}}</td></tr>
|
||||
<tr><td class="num" data-label="Время">{{fmtTime .OccurredAt}}</td><td data-label="Тип">{{.EventType}}</td><td data-label="Источник">{{.SourceType}} {{.SourceID}}</td><td data-label="Данные">{{.Payload}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}<p class="muted">Событий нет.</p>{{end}}
|
||||
{{end}}
|
||||
@@ -3,31 +3,39 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "ips_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "ips_content"}}
|
||||
<h1>Очередь IP</h1>
|
||||
<div class="topbar"><h1>Очередь IP</h1></div>
|
||||
|
||||
<form class="card card-body mb-3 py-2" hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-9">
|
||||
<label for="addresses" class="form-label mb-1">Адреса (по одному на строку или через запятую)</label>
|
||||
<textarea id="addresses" name="addresses" class="form-control form-control-sm" rows="2" placeholder="203.0.113.10 203.0.113.11"></textarea>
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="field-row">
|
||||
<div class="field" style="flex: 1 1 420px;">
|
||||
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
|
||||
<textarea id="addresses" name="addresses" rows="2" placeholder="203.0.113.10 203.0.113.11"></textarea>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Добавить / перепроверить</button>
|
||||
<button type="submit" class="btn btn-primary">Добавить / перепроверить</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted mt-2 mb-0">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
|
||||
(тот же вызов); тот, что сейчас проверяется, не трогается.</p>
|
||||
</form>
|
||||
<p class="muted" style="margin-top:8px">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
|
||||
(тот же вызов); тот, что сейчас проверяется, не трогается.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="ips-table-wrap">
|
||||
{{template "ips_table" .}}
|
||||
@@ -35,30 +43,34 @@
|
||||
{{end}}
|
||||
|
||||
{{define "ips_table"}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped table-hover align-middle">
|
||||
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th>Действия</th></tr></thead>
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
{{$b := ipBadge .State .OverallResult}}
|
||||
{{$terminal := or (eq .State "done") (eq .State "failed")}}
|
||||
<tr>
|
||||
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
|
||||
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td>{{deref .OwnerValidatorID}}</td>
|
||||
<td>{{.AttemptNumber}}</td>
|
||||
<td>{{fmtTime .UpdatedAt}}</td>
|
||||
<td class="actions-cell">
|
||||
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
|
||||
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>
|
||||
<td class="num" data-label="Попытка">{{.AttemptNumber}}</td>
|
||||
<td class="num" data-label="Обновлено">{{fmtTime .UpdatedAt}}</td>
|
||||
<td data-label="">
|
||||
<div class="actions">
|
||||
{{if $terminal}}
|
||||
<button class="btn btn-sm btn-outline-secondary" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML">Перепроверить</button>
|
||||
<button class="btn btn-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML">Перепроверить</button>
|
||||
{{else}}
|
||||
<button class="btn btn-sm btn-outline-danger" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
|
||||
<button class="btn btn-danger-ghost btn-sm" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
|
||||
{{end}}
|
||||
@@ -2,38 +2,74 @@
|
||||
literals, not dynamic pipelines — so a single "layout" wrapper that
|
||||
dispatches to a per-page content block by name isn't possible within
|
||||
one shared template set. Instead, layout.html provides small named
|
||||
partials (html_head, page_header, banner_inner, error_banner) that
|
||||
each page's *_page template (see e.g. overview.html) assembles
|
||||
around its own *_content block explicitly. A few lines of
|
||||
boilerplate are duplicated per page as a result — see any *_page
|
||||
define for the pattern. */}}
|
||||
partials (html_head, sidebar_nav, topbar_mobile, banner_inner,
|
||||
error_banner) that each page's *_page template (see e.g.
|
||||
overview.html) assembles around its own *_content block explicitly.
|
||||
A few lines of boilerplate are duplicated per page as a result — see
|
||||
any *_page define for the pattern. */}}
|
||||
|
||||
{{define "html_head"}}
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Cloud IP Validator — Admin</title>
|
||||
<link rel="stylesheet" href="/static/vendor/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="/static/vendor/fonts.css">
|
||||
<link rel="stylesheet" href="/static/dashboard.css">
|
||||
<script src="/static/vendor/htmx.min.js"></script>
|
||||
<script src="/static/vendor/alpine.min.js" defer></script>
|
||||
{{end}}
|
||||
|
||||
{{define "page_header"}}
|
||||
<nav class="navbar navbar-expand navbar-dark bg-dark">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0">Cloud IP Validator</span>
|
||||
<ul class="navbar-nav flex-row flex-wrap gap-1">
|
||||
<li class="nav-item"><a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview">Обзор</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .ActiveNav "ips"}} active{{end}}" {{if eq .ActiveNav "ips"}}aria-current="page"{{end}} href="/ips">Очередь IP</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators">Валидаторы</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .ActiveNav "sites"}} active{{end}}" {{if eq .ActiveNav "sites"}}aria-current="page"{{end}} href="/sites">Площадки</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .ActiveNav "targets"}} active{{end}}" {{if eq .ActiveNav "targets"}}aria-current="page"{{end}} href="/targets">Цели</a></li>
|
||||
<li class="nav-item"><a class="nav-link{{if eq .ActiveNav "check-types"}} active{{end}}" {{if eq .ActiveNav "check-types"}}aria-current="page"{{end}} href="/check-types">Типы проверок</a></li>
|
||||
</ul>
|
||||
{{define "sidebar_nav"}}
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v6M12 16v6M2 12h6M16 12h6"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
</span>
|
||||
<span class="brand-name">Cloud IP Validator<small>admin</small></span>
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups">
|
||||
<a class="nav-link{{if eq .ActiveNav "overview"}} active{{end}}" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}} href="/overview">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>
|
||||
Обзор
|
||||
</a>
|
||||
<a class="nav-link{{if eq .ActiveNav "ips"}} active{{end}}" {{if eq .ActiveNav "ips"}}aria-current="page"{{end}} href="/ips">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 10h18M8 4v16"/></svg>
|
||||
Очередь IP
|
||||
</a>
|
||||
<a class="nav-link{{if eq .ActiveNav "validators"}} active{{end}}" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}} href="/validators">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="16" height="7" rx="1.5"/><rect x="4" y="14" width="16" height="7" rx="1.5"/><circle cx="8" cy="6.5" r="1"/><circle cx="8" cy="17.5" r="1"/></svg>
|
||||
Валидаторы
|
||||
</a>
|
||||
<a class="nav-link{{if eq .ActiveNav "sites"}} active{{end}}" {{if eq .ActiveNav "sites"}}aria-current="page"{{end}} href="/sites">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 3 7l9 5 9-5-9-5Z"/><path d="m3 12 9 5 9-5M3 17l9 5 9-5"/></svg>
|
||||
Площадки
|
||||
</a>
|
||||
<a class="nav-link{{if eq .ActiveNav "targets"}} active{{end}}" {{if eq .ActiveNav "targets"}}aria-current="page"{{end}} href="/targets">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4.5"/><circle cx="12" cy="12" r=".6" fill="currentColor"/></svg>
|
||||
Цели
|
||||
</a>
|
||||
<a class="nav-link{{if eq .ActiveNav "check-types"}} active{{end}}" {{if eq .ActiveNav "check-types"}}aria-current="page"{{end}} href="/check-types">
|
||||
<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>
|
||||
Типы проверок
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-foot">
|
||||
<span class="pulse-dot"></span>
|
||||
control-api
|
||||
</div>
|
||||
</aside>
|
||||
{{end}}
|
||||
|
||||
{{define "banner_inner"}}{{if .Message}}<div class="alert {{if .Client}}alert-warning{{else}}alert-danger{{end}} py-2 mb-0" role="alert">{{.Message}}</div>{{end}}{{end}}
|
||||
{{define "topbar_mobile"}}
|
||||
<div class="topbar-mobile">
|
||||
<label class="burger" for="nav-toggle">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 7h16M4 12h16M4 17h16"/></svg>
|
||||
</label>
|
||||
<strong>Cloud IP Validator</strong>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "banner_inner"}}{{if .Message}}<div class="alert {{if .Client}}alert-warning{{else}}alert-danger{{end}}" role="alert">{{.Message}}</div>{{end}}{{end}}
|
||||
|
||||
{{define "error_banner"}}<div id="error-banner" hx-swap-oob="true">{{template "banner_inner" .}}</div>{{end}}
|
||||
@@ -3,17 +3,25 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "overview_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "overview_content"}}
|
||||
<div class="topbar">
|
||||
<h1>Обзор</h1>
|
||||
</div>
|
||||
<div id="overview-live" hx-get="/overview/fragment" hx-trigger="every {{.PollSeconds}}s" hx-swap="innerHTML">
|
||||
{{template "overview_fragment" .}}
|
||||
</div>
|
||||
|
||||
@@ -1,58 +1,62 @@
|
||||
{{define "overview_fragment"}}
|
||||
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-6 g-2 mb-2">
|
||||
<div class="col"><div class="card stat-card text-center"><span class="value">{{.Status.TotalIPs}}</span><span class="label">всего IP</span></div></div>
|
||||
<div class="col"><div class="card stat-card text-center"><span class="value">{{.Status.TotalValidators}}</span><span class="label">валидаторов</span></div></div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card accented"><span class="value">{{.Status.TotalIPs}}</span><span class="label">всего IP</span></div>
|
||||
<div class="stat-card"><span class="value">{{.Status.TotalValidators}}</span><span class="label">валидаторов</span></div>
|
||||
{{range $state, $count := .Status.IPsByState}}
|
||||
<div class="col"><div class="card stat-card text-center"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div></div>
|
||||
<div class="stat-card"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<h2>Текущая проверка</h2>
|
||||
<h2 class="section-title">Текущая проверка</h2>
|
||||
{{if .CurrentItems}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped table-hover align-middle mb-3">
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .CurrentItems}}
|
||||
{{$b := ipBadge .State .OverallResult}}
|
||||
<tr>
|
||||
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
|
||||
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td>{{deref .OwnerValidatorID}}</td>
|
||||
<td>{{.AttemptNumber}}</td>
|
||||
<td>{{fmtTime .AssignedAt}}</td>
|
||||
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
|
||||
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>
|
||||
<td class="num" data-label="Попытка">{{.AttemptNumber}}</td>
|
||||
<td class="num" data-label="Назначено">{{fmtTime .AssignedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted">Сейчас нет адресов в обработке.</p>
|
||||
{{end}}
|
||||
|
||||
<h2>Последние {{.LastN}} завершённых</h2>
|
||||
<p class="muted mb-2">
|
||||
pass: {{index .Breakdown "pass"}} ·
|
||||
partial: {{index .Breakdown "partial"}} ·
|
||||
fail: {{index .Breakdown "fail"}} ·
|
||||
cancelled: {{index .Breakdown "cancelled"}}
|
||||
</p>
|
||||
<h2 class="section-title">Последние {{.LastN}} завершённых</h2>
|
||||
<div class="breakdown">
|
||||
<span>pass <b>{{index .Breakdown "pass"}}</b></span>
|
||||
<span>partial <b>{{index .Breakdown "partial"}}</b></span>
|
||||
<span>fail <b>{{index .Breakdown "fail"}}</b></span>
|
||||
<span>cancelled <b>{{index .Breakdown "cancelled"}}</b></span>
|
||||
</div>
|
||||
{{if .LastCompleted}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped table-hover align-middle">
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Адрес</th><th>Итог</th><th>Завершено</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .LastCompleted}}
|
||||
{{$b := ipBadge .State .OverallResult}}
|
||||
<tr>
|
||||
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
|
||||
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td>{{fmtTime .AggregatedAt}}</td>
|
||||
<td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
|
||||
<td data-label="Итог"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td class="num" data-label="Завершено">{{fmtTime .AggregatedAt}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="muted">Пока ничего не завершено.</p>
|
||||
{{end}}
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "sites_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "sites_content"}}
|
||||
<h1>Площадки</h1>
|
||||
<p class="muted">Ровно три слота (1, 2, 3) — жёсткое ограничение схемы БД control-api.
|
||||
<div class="topbar"><h1>Площадки</h1></div>
|
||||
<p class="muted" style="margin-bottom:16px">Ровно три слота (1, 2, 3) — жёсткое ограничение схемы БД control-api.
|
||||
Пустой слот — площадка не используется, входящие проверки для неё не ожидаются.</p>
|
||||
|
||||
<div id="sites-table-wrap">
|
||||
@@ -23,27 +29,31 @@
|
||||
{{end}}
|
||||
|
||||
{{define "sites_table"}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead><tr><th>Слот</th><th>site_id</th><th>Действия</th></tr></thead>
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Слот</th><th>site_id</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td>{{.Index}}</td>
|
||||
<td>
|
||||
<td class="num" data-label="Слот">{{.Index}}</td>
|
||||
<td data-label="site_id">
|
||||
<form class="inline-form" hx-put="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML">
|
||||
<input type="text" name="site_id" value="{{.SiteID}}" class="form-control form-control-sm" required>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Сохранить</button>
|
||||
<input type="text" name="site_id" value="{{.SiteID}}" required>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<td data-label="">
|
||||
<div class="actions">
|
||||
{{if .SiteID}}
|
||||
<button class="btn btn-sm btn-outline-danger" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Освободить слот {{.Index}}?">Очистить</button>
|
||||
<button class="btn btn-danger-ghost btn-sm" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Освободить слот {{.Index}}?">Очистить</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -3,34 +3,42 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "targets_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "targets_content"}}
|
||||
<h1>Цели проверки</h1>
|
||||
<p class="muted">Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.</p>
|
||||
<div class="topbar"><h1>Цели проверки</h1></div>
|
||||
<p class="muted" style="margin-bottom:16px">Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.</p>
|
||||
|
||||
<form class="card card-body mb-3 py-2" hx-post="/targets" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label mb-1">Имя новой группы</label>
|
||||
<input type="text" name="name" class="form-control form-control-sm" placeholder="web" required>
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<form hx-post="/targets" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="field-row">
|
||||
<div class="field" style="flex: 0 1 200px;">
|
||||
<label>Имя новой группы</label>
|
||||
<input type="text" name="name" placeholder="web" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-7">
|
||||
<label class="form-label mb-1">Цели (по одной на строку или через запятую)</label>
|
||||
<textarea name="targets" class="form-control form-control-sm" rows="2" placeholder="https://hub.docker.com https://github.com" required></textarea>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Создать</button>
|
||||
<div class="field" style="flex: 1 1 380px;">
|
||||
<label>Цели (по одной на строку или через запятую)</label>
|
||||
<textarea name="targets" rows="2" placeholder="https://hub.docker.com https://github.com" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="targets-table-wrap">
|
||||
{{template "targets_table" .}}
|
||||
@@ -38,26 +46,30 @@
|
||||
{{end}}
|
||||
|
||||
{{define "targets_table"}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead><tr><th>Группа</th><th>Цели</th><th>Действия</th></tr></thead>
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Группа</th><th>Цели</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td class="text-nowrap">{{.Name}}</td>
|
||||
<td>
|
||||
<td class="mono" data-label="Группа">{{.Name}}</td>
|
||||
<td data-label="Цели">
|
||||
<form class="inline-form" hx-put="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML">
|
||||
<textarea name="targets" class="form-control form-control-sm" rows="2">{{join .Targets "\n"}}</textarea>
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary text-nowrap">Сохранить</button>
|
||||
<textarea name="targets" rows="2">{{join .Targets "\n"}}</textarea>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-sm btn-outline-danger" hx-delete="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить группу {{.Name}}?">Удалить</button>
|
||||
<td data-label="">
|
||||
<div class="actions">
|
||||
<button class="btn btn-danger-ghost btn-sm" hx-delete="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить группу {{.Name}}?">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Items}}<p class="muted">Групп целей нет.</p>{{end}}
|
||||
{{end}}
|
||||
@@ -3,33 +3,41 @@
|
||||
<html lang="ru">
|
||||
<head>{{template "html_head" .}}</head>
|
||||
<body>
|
||||
{{template "page_header" .}}
|
||||
<main class="container-fluid py-3">
|
||||
<input type="checkbox" id="nav-toggle" class="nav-toggle">
|
||||
<div class="shell">
|
||||
{{template "sidebar_nav" .}}
|
||||
<div>
|
||||
{{template "topbar_mobile" .}}
|
||||
<main class="main">
|
||||
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
|
||||
{{template "validators_content" .}}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "validators_content"}}
|
||||
<h1>Валидаторы</h1>
|
||||
<div class="topbar"><h1>Валидаторы</h1></div>
|
||||
|
||||
<form class="card card-body mb-3 py-2" hx-post="/validators" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label mb-1">validator_id</label>
|
||||
<input type="text" name="validator_id" class="form-control form-control-sm" required>
|
||||
<div class="panel">
|
||||
<div class="panel-body">
|
||||
<form hx-post="/validators" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>validator_id</label>
|
||||
<input type="text" name="validator_id" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label mb-1">os_port_id</label>
|
||||
<input type="text" name="os_port_id" class="form-control form-control-sm" placeholder="Neutron port ID">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">Добавить</button>
|
||||
<div class="field">
|
||||
<label>os_port_id</label>
|
||||
<input type="text" name="os_port_id" placeholder="Neutron port ID">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="validators-table-wrap">
|
||||
{{template "validators_table" .}}
|
||||
@@ -37,28 +45,32 @@
|
||||
{{end}}
|
||||
|
||||
{{define "validators_table"}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead><tr><th>ID</th><th>OS Port ID</th><th>Состояние</th><th>Действия</th></tr></thead>
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>ID</th><th>OS Port ID</th><th>Состояние</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
{{$b := validatorBadge .State}}
|
||||
<tr>
|
||||
<td>{{.ValidatorID}}</td>
|
||||
<td>
|
||||
<td class="mono" data-label="ID">{{.ValidatorID}}</td>
|
||||
<td data-label="OS Port ID">
|
||||
<form class="inline-form" hx-put="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML">
|
||||
<input type="text" name="os_port_id" value="{{.OSPortID}}" class="form-control form-control-sm">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Сохранить</button>
|
||||
<input type="text" name="os_port_id" value="{{.OSPortID}}">
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
|
||||
</form>
|
||||
</td>
|
||||
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-sm btn-outline-danger" hx-delete="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить валидатор {{.ValidatorID}}?">Удалить</button>
|
||||
<td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
|
||||
<td data-label="">
|
||||
<div class="actions">
|
||||
<button class="btn btn-danger-ghost btn-sm" hx-delete="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить валидатор {{.ValidatorID}}?">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{{if not .Items}}<p class="muted">Валидаторов нет.</p>{{end}}
|
||||
{{end}}
|
||||
Reference in new issue
Block a user