This commit is contained in:
ayurishchev committed 2026-08-23 21:58:19 +03:00
1 parent ee2a29cb36
commit 4f597505cc
30 files changed
+830 -305

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
992ec763b2dc26f87d5391e072904e5897863a0c4c5b27c3c4980aa03f037c5e control-api 992ec763b2dc26f87d5391e072904e5897863a0c4c5b27c3c4980aa03f037c5e control-api
48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent 48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent
43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober 43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober
ef29de3e818c4e81f8606b9dffbbc9b5a0e62c886321094e5feb1f03e3df5958 admin-dashboard 3aa24ef8e862e467071fa238e89884127f240e4b33f131fb63e95439307ec639 admin-dashboard
Binary file not shown.
+14 -12
View File
@@ -11,18 +11,20 @@
- Рендеринг полностью на сервере: `html/template` + [htmx](https://htmx.org) - Рендеринг полностью на сервере: `html/template` + [htmx](https://htmx.org)
(частичные обновления без перезагрузки страницы) + (частичные обновления без перезагрузки страницы) +
[Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность) + [Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность).
[Bootstrap 5](https://getbootstrap.com) (стабильная 5.3.x) для оформления Оформление — собственная небольшая дизайн-система
— компактные таблицы/формы/бейджи через `table-sm`/`*-sm`-утилиты и (`internal/dashboard/static/dashboard.css`), не построена на готовом
плотную сетку, без кастомной вёрстки с нуля. Только CSS-часть Bootstrap: CSS-фреймворке: боковая навигация, карточки-панели со скруглением и
JS-бандл (навигация-collapse/модалки/тултипы) не подключён — ничего из мягкой тенью, статусы — пастельные «пилюли» (не Bootstrap-бейджи).
этого дашборд не использует, подтверждения — через нативный Шрифты — Manrope (интерфейс/заголовки) и IBM Plex Mono (IP-адреса,
`hx-confirm`. Никакой сборки фронтенда нет — все библиотеки вендорены таймстампы, числа — моноширинные для табличного выравнивания), с
как статические файлы (`internal/dashboard/static/vendor/`, см. кириллицей. Адаптивна: боковая панель на узких экранах уходит в
`VENDOR.md` там же) и встроены в бинарник через `//go:embed`. Дашборд выдвижное меню, таблицы складываются в карточки. Никакой сборки
работает полностью офлайн — при открытии страницы браузер не делает ни фронтенда нет — шрифты и JS-библиотеки вендорены как статические файлы
одного запроса за пределы самого дашборда (можно проверить через (`internal/dashboard/static/vendor/`, см. `VENDOR.md` там же) и встроены
DevTools → Network). в бинарник через `//go:embed`. Дашборд работает полностью офлайн — при
открытии страницы браузер не делает ни одного запроса за пределы самого
дашборда (можно проверить через DevTools → Network).
- Браузер никогда не видит JSON `control-api` напрямую: каждая страница и - Браузер никогда не видит JSON `control-api` напрямую: каждая страница и
каждый htmx-фрагмент — это HTML, отрендеренный Go-хендлером дашборда каждый htmx-фрагмент — это HTML, отрендеренный Go-хендлером дашборда
после вызова `control-api`. CORS и reverse-proxy не нужны. после вызова `control-api`. CORS и reverse-proxy не нужны.
+1 -1
View File
@@ -30,7 +30,7 @@ func TestOverviewFragment(t *testing.T) {
if !strings.Contains(body, "2.2.2.2") || !strings.Contains(body, "3.3.3.3") { 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) 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) t.Fatalf("expected breakdown pass:1 cancelled:1, got:\n%s", body)
} }
} }
+13 -13
View File
@@ -8,9 +8,9 @@ import (
"time" "time"
) )
// Badge is the {class, label} pair rendered via Bootstrap's .badge // Badge is the {class, label} pair rendered via the dashboard's own .pill
// component (see static/dashboard.css for the one addition Bootstrap // component (see static/dashboard.css) — Class is a modifier to combine
// doesn't ship — .bg-cancelled). // with "pill" in the template (e.g. `class="pill {{.Class}}"`).
type Badge struct{ Class, Label string } type Badge struct{ Class, Label string }
func ipBadge(state, result string) Badge { func ipBadge(state, result string) Badge {
@@ -18,31 +18,31 @@ func ipBadge(state, result string) Badge {
case "done", "failed": case "done", "failed":
switch result { switch result {
case "pass": case "pass":
return Badge{"badge bg-success", "pass"} return Badge{"pill-success", "pass"}
case "partial": case "partial":
return Badge{"badge bg-warning text-dark", "partial"} return Badge{"pill-warning", "partial"}
case "cancelled": case "cancelled":
return Badge{"badge bg-cancelled", "cancelled"} return Badge{"pill-cancel", "cancelled"}
default: default:
return Badge{"badge bg-danger", "fail"} return Badge{"pill-danger", "fail"}
} }
case "queued": case "queued":
return Badge{"badge bg-secondary", "queued"} return Badge{"pill-neutral", "queued"}
default: default:
return Badge{"badge bg-info text-dark", state} return Badge{"pill-info", state}
} }
} }
func validatorBadge(state string) Badge { func validatorBadge(state string) Badge {
switch state { switch state {
case "idle": case "idle":
return Badge{"badge bg-success", "idle"} return Badge{"pill-success", "idle"}
case "unreachable": case "unreachable":
return Badge{"badge bg-danger", "unreachable"} return Badge{"pill-danger", "unreachable"}
case "unregistered": case "unregistered":
return Badge{"badge bg-secondary", "unregistered"} return Badge{"pill-neutral", "unregistered"}
default: default:
return Badge{"badge bg-info text-dark", state} // assigned / checking return Badge{"pill-info", state} // assigned / checking
} }
} }
+291 -79
View File
@@ -1,106 +1,318 @@
/* Compact-density overrides on top of Bootstrap 5 (vendor/bootstrap.min.css) /* Cloud IP Validator — admin dashboard design system.
— Bootstrap's own *-sm utilities (table-sm, btn-sm, form-control-sm) do Self-contained, no framework: tokens + a small set of components
most of the work in the templates themselves; this file only adds what (sidebar shell, panels, tables, pills, forms, buttons, alerts).
Bootstrap doesn't ship: a "cancelled" badge color, and a few global Fonts are vendored separately — see vendor/fonts.css. */
density knobs (smaller base font, tighter heading/section spacing) so
more of the queue/config tables fit on screen without scrolling. */
: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 { 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 { h1, h2, h3 { text-wrap: balance; }
font-size: 1.5rem; h1 { font-size: 21px; font-weight: 800; letter-spacing: -0.015em; margin: 0 0 4px; }
margin-bottom: 0.75rem; h2.section-title {
} font-size: 12.5px;
h2 { font-weight: 700;
font-size: 1.05rem; text-transform: uppercase;
margin-top: 1.25rem; letter-spacing: .05em;
margin-bottom: 0.5rem; 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 { /* ---------- shell layout ---------- */
padding-top: 0.35rem; .shell { display: grid; grid-template-columns: 232px 1fr; min-height: 100vh; }
padding-bottom: 0.35rem; .sidebar {
font-size: 0.9rem; background: var(--surface-alt);
border-right: 1px solid var(--border-soft);
padding: 20px 14px;
display: flex;
flex-direction: column;
gap: 22px;
} }
.navbar-brand { .brand { display: flex; align-items: center; gap: 10px; padding: 0 8px; }
font-size: 1rem; .brand-mark {
font-weight: 600; 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 { .nav-link {
padding-top: 0.25rem !important; display: flex; align-items: center; gap: 10px;
padding-bottom: 0.25rem !important; padding: 8px 10px;
} border-radius: var(--radius-sm);
text-decoration: none;
/* Bootstrap ships pass/fail-adjacent colors (success/warning/danger) but color: var(--text-muted);
nothing for "administrator explicitly stopped this" — give cancelled
its own recognizable hue rather than overloading danger/secondary. */
.bg-cancelled {
background-color: #6d28d9 !important;
}
.badge {
font-weight: 600; 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 { .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 { .stat-card .value {
font-family: var(--font-mono);
font-size: 22px; font-weight: 500;
display: block; 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; 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 { .btn:active { transform: translateY(1px); }
display: block; .btn-primary { background: var(--accent); color: var(--accent-contrast); }
font-size: 0.72rem; .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; text-transform: uppercase;
letter-spacing: 0.03em; letter-spacing: .04em;
opacity: 0.65; 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) > * > * { /* ---------- pills (state/result badges) ---------- */
padding-top: 0.35rem; .pill {
padding-bottom: 0.35rem; display: inline-flex; align-items: center; gap: 5px;
} padding: 3px 10px 3px 8px;
border-radius: var(--radius-pill);
.actions-cell { font-size: 11.5px;
font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
.actions-cell .btn { .pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
margin-right: 0.25rem; .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 { .alert-warning { background: var(--warning-soft); color: var(--warning); border-color: #f0d9a8; }
margin-right: 0; .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 { @media (max-width: 640px) {
color: var(--bs-secondary-color); .field-row { flex-direction: column; align-items: stretch; }
font-size: 0.82rem; thead { display: none; }
} table, tbody, tr, td { display: block; width: 100%; }
tbody tr { border-bottom: 1px solid var(--border-soft); padding: 10px 18px; }
.inline-form { tbody td { border: none; padding: 3px 0; }
display: flex; tbody td[data-label]::before {
gap: 0.4rem; content: attr(data-label);
align-items: center; display: block;
} font-size: 10.5px;
.inline-form .form-control, font-weight: 700;
.inline-form textarea.form-control { text-transform: uppercase;
margin: 0; letter-spacing: .04em;
} color: var(--text-faint);
}
#error-banner:empty { .actions { justify-content: flex-start; margin-top: 6px; }
display: none;
}
#error-banner .alert {
padding: 0.5rem 0.9rem;
margin-bottom: 1rem;
font-size: 0.85rem;
}
main {
padding-bottom: 3rem;
} }
+27 -12
View File
@@ -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 | | `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 | | `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 ## Fonts
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.
Previously used Pico CSS (classless build) — replaced by Bootstrap 5 for a The dashboard's design system (`../dashboard.css`) uses **Manrope**
more compact, conventional admin-dashboard look (denser tables/forms via (UI chrome/headings — a variable font, so one file per subset covers all
`table-sm`/`*-sm` utilities) per explicit request. 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 No CSS framework is vendored: the design system in `dashboard.css` is
permit vendoring/redistribution unmodified. 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.
-6
View File
@@ -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
View File
@@ -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.
+41 -34
View File
@@ -3,51 +3,54 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "checktypes_content" .}} {{template "checktypes_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "checktypes_content"}} {{define "checktypes_content"}}
<h1>Типы проверок</h1> <div class="topbar"><h1>Типы проверок</h1></div>
<p class="muted">Тип проверки (https/icmp/ssh/...) ссылается на одну или несколько групп целей — <p class="muted" style="margin-bottom:16px">Тип проверки (https/icmp/ssh/...) ссылается на одну или несколько групп целей —
именно развёрнутый список валидатор получает в задании.</p> именно развёрнутый список валидатор получает в задании.</p>
{{if not .Groups}} {{if not .Groups}}
<p class="muted">Сначала создайте хотя бы одну <a href="/targets">группу целей</a>.</p> <p class="muted">Сначала создайте хотя бы одну <a href="/targets">группу целей</a>.</p>
{{else}} {{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="panel">
<div class="row g-2 align-items-end"> <div class="panel-body">
<div class="col-12 col-md-3"> <form hx-post="/check-types" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label class="form-label mb-1">Имя типа проверки</label> <div class="field-row">
<input type="text" name="name" class="form-control form-control-sm" placeholder="https" required> <div class="field" style="flex: 0 1 200px;">
<label>Имя типа проверки</label>
<input type="text" name="name" placeholder="https" required>
</div> </div>
<div class="col-12 col-md-2"> <div class="field" style="flex: 0 1 auto;">
<div class="form-check mt-4"> <label>&nbsp;</label>
<input type="checkbox" class="form-check-input" name="enabled" value="1" id="ct-enabled" checked> <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>
<label class="form-check-label" for="ct-enabled">включён</label>
</div> </div>
</div> <div class="field" style="flex: 1 1 300px;">
<div class="col-12 col-md-5"> <label>Группы целей</label>
<label class="form-label mb-1">Группы целей</label> <div style="display:flex; flex-wrap:wrap; gap:10px;">
<div class="d-flex flex-wrap gap-2">
{{range .Groups}} {{range .Groups}}
<div class="form-check form-check-inline m-0"> <span class="form-check"><input type="checkbox" name="targets" value="{{.}}" id="ct-group-{{.}}"> <label for="ct-group-{{.}}" style="font-weight:500">{{.}}</label></span>
<input type="checkbox" class="form-check-input" name="targets" value="{{.}}" id="ct-group-{{.}}">
<label class="form-check-label" for="ct-group-{{.}}">{{.}}</label>
</div>
{{end}} {{end}}
</div> </div>
</div> </div>
<div class="col-12 col-md-2"> <button type="submit" class="btn btn-primary">Создать</button>
<button type="submit" class="btn btn-sm btn-primary w-100">Создать</button>
</div>
</div> </div>
</form> </form>
</div>
</div>
{{end}} {{end}}
<div id="checktypes-table-wrap"> <div id="checktypes-table-wrap">
@@ -56,28 +59,32 @@
{{end}} {{end}}
{{define "checktypes_table"}} {{define "checktypes_table"}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped align-middle"> <div class="table-scroll">
<thead><tr><th>Имя</th><th>Включён</th><th>Группы целей</th><th>Действия</th></tr></thead> <table>
<thead><tr><th>Имя</th><th>Включён</th><th>Группы целей</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
<tr> <tr>
<td>{{.Name}}</td> <td class="mono" data-label="Имя">{{.Name}}</td>
<td>{{if .Enabled}}<span class="badge bg-success">да</span>{{else}}<span class="badge bg-secondary">нет</span>{{end}}</td> <td data-label="Включён">{{if .Enabled}}<span class="pill pill-success">да</span>{{else}}<span class="pill pill-neutral">нет</span>{{end}}</td>
<td>{{join .Targets ", "}}</td> <td data-label="Группы целей">{{join .Targets ", "}}</td>
<td class="actions-cell"> <td data-label="">
<form class="d-inline" hx-put="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML"> <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 ","}}"> <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}} {{if .Enabled}}Выключить{{else}}Включить{{end}}
</button> </button>
</form> </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> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{if not .Items}}<p class="muted">Типов проверок нет.</p>{{end}} {{if not .Items}}<p class="muted">Типов проверок нет.</p>{{end}}
{{end}} {{end}}
+26 -17
View File
@@ -3,23 +3,28 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ip_detail_content" .}} {{template "ip_detail_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "ip_detail_content"}} {{define "ip_detail_content"}}
<p><a href="/ips">&larr; К очереди</a></p> <p><a href="/ips">&larr; К очереди</a></p>
<h1 class="d-flex align-items-center gap-2">
{{.Detail.IP.IPAddress}}
{{$b := ipBadge .Detail.IP.State .Detail.IP.OverallResult}} {{$b := ipBadge .Detail.IP.State .Detail.IP.OverallResult}}
<span class="{{$b.Class}} fs-6">{{$b.Label}}</span> <div class="topbar">
</h1> <h1 class="mono" style="display:flex;align-items:center;gap:10px">{{.Detail.IP.IPAddress}} <span class="pill {{$b.Class}}">{{$b.Label}}</span></h1>
<p class="muted mb-1"> </div>
<p class="muted">
попытка {{.Detail.IP.AttemptNumber}} (повторов: {{.Detail.IP.RetryCount}}) попытка {{.Detail.IP.AttemptNumber}} (повторов: {{.Detail.IP.RetryCount}})
· валидатор {{deref .Detail.IP.OwnerValidatorID}} · валидатор {{deref .Detail.IP.OwnerValidatorID}}
</p> </p>
@@ -28,35 +33,39 @@
агрегирован: {{fmtTime .Detail.IP.AggregatedAt}} · FIP освобождён: {{fmtTime .Detail.IP.FIPReleasedAt}} агрегирован: {{fmtTime .Detail.IP.AggregatedAt}} · FIP освобождён: {{fmtTime .Detail.IP.FIPReleasedAt}}
</p> </p>
<h2>Проверки (попытка {{.Detail.IP.AttemptNumber}})</h2> <h2 class="section-title">Проверки (попытка {{.Detail.IP.AttemptNumber}})</h2>
{{if .Detail.Checks}} {{if .Detail.Checks}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped align-middle"> <div class="table-scroll">
<table>
<thead><tr><th>Источник</th><th>Тип</th><th>Цель</th><th>Успех</th><th>Задержка, мс</th><th>Время</th><th>Детали</th></tr></thead> <thead><tr><th>Источник</th><th>Тип</th><th>Цель</th><th>Успех</th><th>Задержка, мс</th><th>Время</th><th>Детали</th></tr></thead>
<tbody> <tbody>
{{range .Detail.Checks}} {{range .Detail.Checks}}
<tr> <tr>
<td>{{.Source}}</td><td>{{.CheckType}}</td><td>{{.Target}}</td> <td data-label="Источник">{{.Source}}</td><td data-label="Тип">{{.CheckType}}</td><td class="addr" data-label="Цель">{{.Target}}</td>
<td>{{if .Success}}<span class="badge bg-success">ok</span>{{else}}<span class="badge bg-danger">fail</span>{{end}}</td> <td data-label="Успех">{{if .Success}}<span class="pill pill-success">ok</span>{{else}}<span class="pill pill-danger">fail</span>{{end}}</td>
<td>{{.LatencyMS}}</td><td>{{fmtTime .CheckedAt}}</td><td>{{.Detail}}</td> <td class="num" data-label="Задержка, мс">{{.LatencyMS}}</td><td class="num" data-label="Время">{{fmtTime .CheckedAt}}</td><td data-label="Детали">{{.Detail}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{else}}<p class="muted">Проверок пока нет.</p>{{end}} {{else}}<p class="muted">Проверок пока нет.</p>{{end}}
<h2>События</h2> <h2 class="section-title">События</h2>
{{if .Detail.Events}} {{if .Detail.Events}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped align-middle"> <div class="table-scroll">
<table>
<thead><tr><th>Время</th><th>Тип</th><th>Источник</th><th>Данные</th></tr></thead> <thead><tr><th>Время</th><th>Тип</th><th>Источник</th><th>Данные</th></tr></thead>
<tbody> <tbody>
{{range .Detail.Events}} {{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}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{else}}<p class="muted">Событий нет.</p>{{end}} {{else}}<p class="muted">Событий нет.</p>{{end}}
{{end}} {{end}}
+36 -24
View File
@@ -3,31 +3,39 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ips_content" .}} {{template "ips_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "ips_content"}} {{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="panel">
<div class="row g-2 align-items-end"> <div class="panel-body">
<div class="col-12 col-md-9"> <form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="addresses" class="form-label mb-1">Адреса (по одному на строку или через запятую)</label> <div class="field-row">
<textarea id="addresses" name="addresses" class="form-control form-control-sm" rows="2" placeholder="203.0.113.10&#10;203.0.113.11"></textarea> <div class="field" style="flex: 1 1 420px;">
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
<textarea id="addresses" name="addresses" rows="2" placeholder="203.0.113.10&#10;203.0.113.11"></textarea>
</div> </div>
<div class="col-12 col-md-3"> <button type="submit" class="btn btn-primary">Добавить / перепроверить</button>
<button type="submit" class="btn btn-sm btn-primary w-100">Добавить / перепроверить</button>
</div> </div>
</div>
<p class="muted mt-2 mb-0">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается.</p>
</form> </form>
<p class="muted" style="margin-top:8px">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается.</p>
</div>
</div>
<div id="ips-table-wrap"> <div id="ips-table-wrap">
{{template "ips_table" .}} {{template "ips_table" .}}
@@ -35,30 +43,34 @@
{{end}} {{end}}
{{define "ips_table"}} {{define "ips_table"}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped table-hover align-middle"> <div class="table-scroll">
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th>Действия</th></tr></thead> <table>
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Обновлено</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
{{$b := ipBadge .State .OverallResult}} {{$b := ipBadge .State .OverallResult}}
{{$terminal := or (eq .State "done") (eq .State "failed")}} {{$terminal := or (eq .State "done") (eq .State "failed")}}
<tr> <tr>
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td> <td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td> <td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td>{{deref .OwnerValidatorID}}</td> <td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>
<td>{{.AttemptNumber}}</td> <td class="num" data-label="Попытка">{{.AttemptNumber}}</td>
<td>{{fmtTime .UpdatedAt}}</td> <td class="num" data-label="Обновлено">{{fmtTime .UpdatedAt}}</td>
<td class="actions-cell"> <td data-label="">
<div class="actions">
{{if $terminal}} {{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}} {{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}} {{end}}
</div>
</td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}} {{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
{{end}} {{end}}
+55 -19
View File
@@ -2,38 +2,74 @@
literals, not dynamic pipelines — so a single "layout" wrapper that literals, not dynamic pipelines — so a single "layout" wrapper that
dispatches to a per-page content block by name isn't possible within dispatches to a per-page content block by name isn't possible within
one shared template set. Instead, layout.html provides small named one shared template set. Instead, layout.html provides small named
partials (html_head, page_header, banner_inner, error_banner) that partials (html_head, sidebar_nav, topbar_mobile, banner_inner,
each page's *_page template (see e.g. overview.html) assembles error_banner) that each page's *_page template (see e.g.
around its own *_content block explicitly. A few lines of overview.html) assembles around its own *_content block explicitly.
boilerplate are duplicated per page as a result — see any *_page A few lines of boilerplate are duplicated per page as a result — see
define for the pattern. */}} any *_page define for the pattern. */}}
{{define "html_head"}} {{define "html_head"}}
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud IP Validator — Admin</title> <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"> <link rel="stylesheet" href="/static/dashboard.css">
<script src="/static/vendor/htmx.min.js"></script> <script src="/static/vendor/htmx.min.js"></script>
<script src="/static/vendor/alpine.min.js" defer></script> <script src="/static/vendor/alpine.min.js" defer></script>
{{end}} {{end}}
{{define "page_header"}} {{define "sidebar_nav"}}
<nav class="navbar navbar-expand navbar-dark bg-dark"> <aside class="sidebar">
<div class="container-fluid"> <div class="brand">
<span class="navbar-brand mb-0">Cloud IP Validator</span> <span class="brand-mark">
<ul class="navbar-nav flex-row flex-wrap gap-1"> <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>
<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> </span>
<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> <span class="brand-name">Cloud IP Validator<small>admin</small></span>
<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>
</div> </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> </nav>
<div class="sidebar-foot">
<span class="pulse-dot"></span>
control-api
</div>
</aside>
{{end}} {{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}} {{define "error_banner"}}<div id="error-banner" hx-swap-oob="true">{{template "banner_inner" .}}</div>{{end}}
+10 -2
View File
@@ -3,17 +3,25 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "overview_content" .}} {{template "overview_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "overview_content"}} {{define "overview_content"}}
<div class="topbar">
<h1>Обзор</h1> <h1>Обзор</h1>
</div>
<div id="overview-live" hx-get="/overview/fragment" hx-trigger="every {{.PollSeconds}}s" hx-swap="innerHTML"> <div id="overview-live" hx-get="/overview/fragment" hx-trigger="every {{.PollSeconds}}s" hx-swap="innerHTML">
{{template "overview_fragment" .}} {{template "overview_fragment" .}}
</div> </div>
@@ -1,58 +1,62 @@
{{define "overview_fragment"}} {{define "overview_fragment"}}
<div class="row row-cols-2 row-cols-md-4 row-cols-lg-6 g-2 mb-2"> <div class="stat-grid">
<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="stat-card accented"><span class="value">{{.Status.TotalIPs}}</span><span class="label">всего IP</span></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-card"><span class="value">{{.Status.TotalValidators}}</span><span class="label">валидаторов</span></div>
{{range $state, $count := .Status.IPsByState}} {{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}} {{end}}
</div> </div>
<h2>Текущая проверка</h2> <h2 class="section-title">Текущая проверка</h2>
{{if .CurrentItems}} {{if .CurrentItems}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped table-hover align-middle mb-3"> <div class="table-scroll">
<table>
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead> <thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead>
<tbody> <tbody>
{{range .CurrentItems}} {{range .CurrentItems}}
{{$b := ipBadge .State .OverallResult}} {{$b := ipBadge .State .OverallResult}}
<tr> <tr>
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td> <td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td> <td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td>{{deref .OwnerValidatorID}}</td> <td data-label="Валидатор">{{deref .OwnerValidatorID}}</td>
<td>{{.AttemptNumber}}</td> <td class="num" data-label="Попытка">{{.AttemptNumber}}</td>
<td>{{fmtTime .AssignedAt}}</td> <td class="num" data-label="Назначено">{{fmtTime .AssignedAt}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{else}} {{else}}
<p class="muted">Сейчас нет адресов в обработке.</p> <p class="muted">Сейчас нет адресов в обработке.</p>
{{end}} {{end}}
<h2>Последние {{.LastN}} завершённых</h2> <h2 class="section-title">Последние {{.LastN}} завершённых</h2>
<p class="muted mb-2"> <div class="breakdown">
pass: {{index .Breakdown "pass"}} · <span>pass <b>{{index .Breakdown "pass"}}</b></span>
partial: {{index .Breakdown "partial"}} · <span>partial <b>{{index .Breakdown "partial"}}</b></span>
fail: {{index .Breakdown "fail"}} · <span>fail <b>{{index .Breakdown "fail"}}</b></span>
cancelled: {{index .Breakdown "cancelled"}} <span>cancelled <b>{{index .Breakdown "cancelled"}}</b></span>
</p> </div>
{{if .LastCompleted}} {{if .LastCompleted}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped table-hover align-middle"> <div class="table-scroll">
<table>
<thead><tr><th>Адрес</th><th>Итог</th><th>Завершено</th></tr></thead> <thead><tr><th>Адрес</th><th>Итог</th><th>Завершено</th></tr></thead>
<tbody> <tbody>
{{range .LastCompleted}} {{range .LastCompleted}}
{{$b := ipBadge .State .OverallResult}} {{$b := ipBadge .State .OverallResult}}
<tr> <tr>
<td><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td> <td class="addr" data-label="Адрес"><a href="/ips/{{.IPAddress}}">{{.IPAddress}}</a></td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td> <td data-label="Итог"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td>{{fmtTime .AggregatedAt}}</td> <td class="num" data-label="Завершено">{{fmtTime .AggregatedAt}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{else}} {{else}}
<p class="muted">Пока ничего не завершено.</p> <p class="muted">Пока ничего не завершено.</p>
{{end}} {{end}}
+23 -13
View File
@@ -3,18 +3,24 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "sites_content" .}} {{template "sites_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "sites_content"}} {{define "sites_content"}}
<h1>Площадки</h1> <div class="topbar"><h1>Площадки</h1></div>
<p class="muted">Ровно три слота (1, 2, 3) — жёсткое ограничение схемы БД control-api. <p class="muted" style="margin-bottom:16px">Ровно три слота (1, 2, 3) — жёсткое ограничение схемы БД control-api.
Пустой слот — площадка не используется, входящие проверки для неё не ожидаются.</p> Пустой слот — площадка не используется, входящие проверки для неё не ожидаются.</p>
<div id="sites-table-wrap"> <div id="sites-table-wrap">
@@ -23,27 +29,31 @@
{{end}} {{end}}
{{define "sites_table"}} {{define "sites_table"}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped align-middle"> <div class="table-scroll">
<thead><tr><th>Слот</th><th>site_id</th><th>Действия</th></tr></thead> <table>
<thead><tr><th>Слот</th><th>site_id</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
<tr> <tr>
<td>{{.Index}}</td> <td class="num" data-label="Слот">{{.Index}}</td>
<td> <td data-label="site_id">
<form class="inline-form" hx-put="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML"> <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> <input type="text" name="site_id" value="{{.SiteID}}" required>
<button type="submit" class="btn btn-sm btn-outline-secondary">Сохранить</button> <button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
</form> </form>
</td> </td>
<td class="actions-cell"> <td data-label="">
<div class="actions">
{{if .SiteID}} {{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}} {{end}}
</div>
</td> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{end}} {{end}}
+36 -24
View File
@@ -3,34 +3,42 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "targets_content" .}} {{template "targets_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "targets_content"}} {{define "targets_content"}}
<h1>Цели проверки</h1> <div class="topbar"><h1>Цели проверки</h1></div>
<p class="muted">Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.</p> <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="panel">
<div class="row g-2 align-items-end"> <div class="panel-body">
<div class="col-12 col-md-3"> <form hx-post="/targets" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label class="form-label mb-1">Имя новой группы</label> <div class="field-row">
<input type="text" name="name" class="form-control form-control-sm" placeholder="web" required> <div class="field" style="flex: 0 1 200px;">
<label>Имя новой группы</label>
<input type="text" name="name" placeholder="web" required>
</div> </div>
<div class="col-12 col-md-7"> <div class="field" style="flex: 1 1 380px;">
<label class="form-label mb-1">Цели (по одной на строку или через запятую)</label> <label>Цели (по одной на строку или через запятую)</label>
<textarea name="targets" class="form-control form-control-sm" rows="2" placeholder="https://hub.docker.com&#10;https://github.com" required></textarea> <textarea name="targets" rows="2" placeholder="https://hub.docker.com&#10;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> </div>
<button type="submit" class="btn btn-primary">Создать</button>
</div> </div>
</form> </form>
</div>
</div>
<div id="targets-table-wrap"> <div id="targets-table-wrap">
{{template "targets_table" .}} {{template "targets_table" .}}
@@ -38,26 +46,30 @@
{{end}} {{end}}
{{define "targets_table"}} {{define "targets_table"}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped align-middle"> <div class="table-scroll">
<thead><tr><th>Группа</th><th>Цели</th><th>Действия</th></tr></thead> <table>
<thead><tr><th>Группа</th><th>Цели</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
<tr> <tr>
<td class="text-nowrap">{{.Name}}</td> <td class="mono" data-label="Группа">{{.Name}}</td>
<td> <td data-label="Цели">
<form class="inline-form" hx-put="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML"> <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> <textarea name="targets" rows="2">{{join .Targets "\n"}}</textarea>
<button type="submit" class="btn btn-sm btn-outline-secondary text-nowrap">Сохранить</button> <button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
</form> </form>
</td> </td>
<td class="actions-cell"> <td data-label="">
<button class="btn btn-sm btn-outline-danger" hx-delete="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить группу {{.Name}}?">Удалить</button> <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> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{if not .Items}}<p class="muted">Групп целей нет.</p>{{end}} {{if not .Items}}<p class="muted">Групп целей нет.</p>{{end}}
{{end}} {{end}}
+36 -24
View File
@@ -3,33 +3,41 @@
<html lang="ru"> <html lang="ru">
<head>{{template "html_head" .}}</head> <head>{{template "html_head" .}}</head>
<body> <body>
{{template "page_header" .}} <input type="checkbox" id="nav-toggle" class="nav-toggle">
<main class="container-fluid py-3"> <div class="shell">
{{template "sidebar_nav" .}}
<div>
{{template "topbar_mobile" .}}
<main class="main">
<div id="error-banner">{{template "banner_inner" .Banner}}</div> <div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "validators_content" .}} {{template "validators_content" .}}
</main> </main>
</div>
</div>
</body> </body>
</html> </html>
{{end}} {{end}}
{{define "validators_content"}} {{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="panel">
<div class="row g-2 align-items-end"> <div class="panel-body">
<div class="col-12 col-md-4"> <form hx-post="/validators" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label class="form-label mb-1">validator_id</label> <div class="field-row">
<input type="text" name="validator_id" class="form-control form-control-sm" required> <div class="field">
<label>validator_id</label>
<input type="text" name="validator_id" required>
</div> </div>
<div class="col-12 col-md-4"> <div class="field">
<label class="form-label mb-1">os_port_id</label> <label>os_port_id</label>
<input type="text" name="os_port_id" class="form-control form-control-sm" placeholder="Neutron port ID"> <input type="text" name="os_port_id" 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> </div>
<button type="submit" class="btn btn-primary">Добавить</button>
</div> </div>
</form> </form>
</div>
</div>
<div id="validators-table-wrap"> <div id="validators-table-wrap">
{{template "validators_table" .}} {{template "validators_table" .}}
@@ -37,28 +45,32 @@
{{end}} {{end}}
{{define "validators_table"}} {{define "validators_table"}}
<div class="table-responsive"> <div class="panel">
<table class="table table-sm table-striped align-middle"> <div class="table-scroll">
<thead><tr><th>ID</th><th>OS Port ID</th><th>Состояние</th><th>Действия</th></tr></thead> <table>
<thead><tr><th>ID</th><th>OS Port ID</th><th>Состояние</th><th></th></tr></thead>
<tbody> <tbody>
{{range .Items}} {{range .Items}}
{{$b := validatorBadge .State}} {{$b := validatorBadge .State}}
<tr> <tr>
<td>{{.ValidatorID}}</td> <td class="mono" data-label="ID">{{.ValidatorID}}</td>
<td> <td data-label="OS Port ID">
<form class="inline-form" hx-put="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML"> <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"> <input type="text" name="os_port_id" value="{{.OSPortID}}">
<button type="submit" class="btn btn-sm btn-outline-secondary">Сохранить</button> <button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
</form> </form>
</td> </td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td> <td data-label="Состояние"><span class="pill {{$b.Class}}">{{$b.Label}}</span></td>
<td class="actions-cell"> <td data-label="">
<button class="btn btn-sm btn-outline-danger" hx-delete="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить валидатор {{.ValidatorID}}?">Удалить</button> <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> </td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
{{if not .Items}}<p class="muted">Валидаторов нет.</p>{{end}} {{if not .Items}}<p class="muted">Валидаторов нет.</p>{{end}}
{{end}} {{end}}