use bootstrap 5 for UI, compact UI

This commit is contained in:
ayurishchev committed 2026-08-23 21:15:31 +03:00
1 parent 37910e410b
commit ee2a29cb36
18 files changed
+284 -161

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
992ec763b2dc26f87d5391e072904e5897863a0c4c5b27c3c4980aa03f037c5e control-api
48c9b99fa88be751d9badfba8b7d80f894e326a743a90e85478f1d2251ce4ab6 validator-agent
43fb660b78179b204b57388241a508345881aa16f3531d77b8fd75af60e7f2d8 prober
fcf80759c37b8b7ea572d313e6689e7c7d00c1369f0889ed24b9eea16c0a17b0 admin-dashboard
ef29de3e818c4e81f8606b9dffbbc9b5a0e62c886321094e5feb1f03e3df5958 admin-dashboard
Binary file not shown.
+11 -7
View File
@@ -12,13 +12,17 @@
- Рендеринг полностью на сервере: `html/template` + [htmx](https://htmx.org)
(частичные обновления без перезагрузки страницы) +
[Alpine.js](https://alpinejs.dev) (точечная клиентская интерактивность) +
[Pico CSS](https://picocss.com) в classless-сборке (минималистичный вид
на голой семантической разметке). Никакой сборки фронтенда нет — все три
библиотеки вендорены как статические файлы
(`internal/dashboard/static/vendor/`, см. `VENDOR.md` там же) и встроены
в бинарник через `//go:embed`. Дашборд работает полностью офлайн — при
открытии страницы браузер не делает ни одного запроса за пределы самого
дашборда (можно проверить через DevTools → Network).
[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).
- Браузер никогда не видит JSON `control-api` напрямую: каждая страница и
каждый htmx-фрагмент — это HTML, отрендеренный Go-хендлером дашборда
после вызова `control-api`. CORS и reverse-proxy не нужны.
+5 -5
View File
@@ -60,7 +60,7 @@ func TestIPsSubmitAddAndForceRecheck(t *testing.T) {
// Empty submission is a client error, surfaced via the banner, not a 500.
body = postForm(t, ts, "POST", "/ips", map[string][]string{"addresses": {""}})
if !strings.Contains(body, "error-banner-client") {
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner for empty submission, got:\n%s", body)
}
}
@@ -116,7 +116,7 @@ func TestValidatorsCRUD(t *testing.T) {
// Conflict: creating the same validator_id again.
body = postForm(t, ts, "POST", "/validators", map[string][]string{"validator_id": {"val-1"}, "os_port_id": {"port-x"}})
if !strings.Contains(body, "error-banner-client") {
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected client error banner on duplicate create, got:\n%s", body)
}
@@ -148,7 +148,7 @@ func TestSitesPutAndConflict(t *testing.T) {
}
body = postForm(t, ts, "PUT", "/sites/2", map[string][]string{"site_id": {"site-1"}})
if !strings.Contains(body, "error-banner-client") {
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected conflict banner assigning a taken site_id to another slot, got:\n%s", body)
}
}
@@ -169,7 +169,7 @@ func TestTargetsAndCheckTypesRoundTrip(t *testing.T) {
// Can't delete a target group still referenced by a check type.
body = postForm(t, ts, "DELETE", "/targets/web", nil)
if !strings.Contains(body, "error-banner-client") {
if !strings.Contains(body, "alert-warning") {
t.Fatalf("expected conflict banner deleting in-use target group, got:\n%s", body)
}
@@ -190,7 +190,7 @@ func TestControlAPIUnreachable(t *testing.T) {
// transport failure.
ts := newTestServer(t, "http://127.0.0.1:1")
body := get(t, ts, "/overview")
if !strings.Contains(body, "error-banner-server") {
if !strings.Contains(body, "alert-danger") {
t.Fatalf("expected server/transport error banner when control-api is unreachable, got:\n%s", body)
}
}
+13 -12
View File
@@ -8,8 +8,9 @@ import (
"time"
)
// Badge is the {class, label} pair rendered as a colored pill — see
// static/dashboard.css for the .badge-* classes.
// 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).
type Badge struct{ Class, Label string }
func ipBadge(state, result string) Badge {
@@ -17,31 +18,31 @@ func ipBadge(state, result string) Badge {
case "done", "failed":
switch result {
case "pass":
return Badge{"badge badge-pass", "pass"}
return Badge{"badge bg-success", "pass"}
case "partial":
return Badge{"badge badge-partial", "partial"}
return Badge{"badge bg-warning text-dark", "partial"}
case "cancelled":
return Badge{"badge badge-cancelled", "cancelled"}
return Badge{"badge bg-cancelled", "cancelled"}
default:
return Badge{"badge badge-fail", "fail"}
return Badge{"badge bg-danger", "fail"}
}
case "queued":
return Badge{"badge badge-queued", "queued"}
return Badge{"badge bg-secondary", "queued"}
default:
return Badge{"badge badge-inprogress", state}
return Badge{"badge bg-info text-dark", state}
}
}
func validatorBadge(state string) Badge {
switch state {
case "idle":
return Badge{"badge badge-idle", "idle"}
return Badge{"badge bg-success", "idle"}
case "unreachable":
return Badge{"badge badge-unreachable", "unreachable"}
return Badge{"badge bg-danger", "unreachable"}
case "unregistered":
return Badge{"badge badge-unregistered", "unregistered"}
return Badge{"badge bg-secondary", "unregistered"}
default:
return Badge{"badge badge-inprogress", state} // assigned / checking
return Badge{"badge bg-info text-dark", state} // assigned / checking
}
}
+94 -47
View File
@@ -1,59 +1,106 @@
/* Small additions Pico's classless build doesn't cover: color-coded state
badges, the error banner, and a compact stat-tile grid for the overview
page. Everything else relies on Pico's classless defaults. */
/* 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. */
.badge {
display: inline-block;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.4;
white-space: nowrap;
body {
font-size: 0.875rem;
}
.badge-queued { background: #e2e8f0; color: #334155; }
.badge-inprogress { background: #dbeafe; color: #1d4ed8; }
.badge-pass { background: #dcfce7; color: #15803d; }
.badge-partial { background: #ffedd5; color: #c2410c; }
.badge-fail { background: #fee2e2; color: #b91c1c; }
.badge-cancelled { background: #ede9fe; color: #6d28d9; }
.badge-idle { background: #dcfce7; color: #15803d; }
.badge-unreachable { background: #fee2e2; color: #b91c1c; }
.badge-unregistered { background: #e2e8f0; color: #334155; }
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.stat-tile {
border: 1px solid var(--pico-muted-border-color, #ddd);
border-radius: 0.5rem;
padding: 0.75rem 1rem;
text-align: center;
h2 {
font-size: 1.05rem;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.stat-tile .value { display: block; font-size: 1.6rem; font-weight: 700; }
.stat-tile .label { display: block; font-size: 0.8rem; opacity: 0.75; }
#error-banner:empty { display: none; }
.error-banner {
padding: 0.6rem 1rem;
margin-bottom: 1rem;
border-radius: 0.4rem;
.navbar {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
font-size: 0.9rem;
}
.error-banner-client { background: #fef9c3; color: #854d0e; border: 1px solid #fde68a; }
.error-banner-server { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
.navbar-brand {
font-size: 1rem;
font-weight: 600;
}
.nav-link {
padding-top: 0.25rem !important;
padding-bottom: 0.25rem !important;
}
nav.dashboard-nav ul { display: flex; gap: 1.25rem; list-style: none; padding: 0; margin: 0 0 1.5rem 0; }
nav.dashboard-nav a[aria-current="page"] { font-weight: 700; text-decoration: underline; }
/* 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;
}
.actions-cell { white-space: nowrap; }
.actions-cell button { margin-right: 0.35rem; font-size: 0.85rem; padding: 0.25rem 0.6rem; }
.actions-cell button.secondary { --pico-primary-background: #64748b; }
.actions-cell button.contrast { --pico-primary-background: #b91c1c; }
.badge {
font-weight: 600;
font-size: 0.75em;
}
table.compact th, table.compact td { padding: 0.4rem 0.6rem; }
.stat-card {
padding: 0.5rem 0.75rem;
}
.stat-card .value {
display: block;
font-size: 1.35rem;
font-weight: 700;
line-height: 1.2;
}
.stat-card .label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.65;
}
.muted { opacity: 0.65; font-size: 0.85rem; }
.table > :not(caption) > * > * {
padding-top: 0.35rem;
padding-bottom: 0.35rem;
}
.actions-cell {
white-space: nowrap;
}
.actions-cell .btn {
margin-right: 0.25rem;
}
.actions-cell .btn:last-child {
margin-right: 0;
}
.muted {
color: var(--bs-secondary-color);
font-size: 0.82rem;
}
.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;
}
+13 -2
View File
@@ -9,7 +9,18 @@ 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 |
| `pico.classless.min.css` | https://unpkg.com/@picocss/pico@2.1.1/css/pico.classless.min.css | 2.1.1 | 2026-08-23 |
| `bootstrap.min.css` | https://unpkg.com/bootstrap@5.3.8/dist/css/bootstrap.min.css | 5.3.8 | 2026-08-23 |
Licenses: htmx (BSD 2-Clause), Alpine.js (MIT), Pico CSS (MIT) — all
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.
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.
Licenses: htmx (BSD 2-Clause), Alpine.js (MIT), Bootstrap (MIT) — all
permit vendoring/redistribution unmodified.
+6
View File
@@ -0,0 +1,6 @@
@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 */
@@ -1,4 +0,0 @@
@charset "UTF-8";/*!
* Pico CSS ✨ v2.1.1 (https://picocss.com)
* Copyright 2019-2025 - Licensed under MIT
*/:host,:root{--pico-font-family-emoji:"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--pico-font-family-sans-serif:system-ui,"Segoe UI",Roboto,Oxygen,Ubuntu,Cantarell,Helvetica,Arial,"Helvetica Neue",sans-serif,var(--pico-font-family-emoji);--pico-font-family-monospace:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace,var(--pico-font-family-emoji);--pico-font-family:var(--pico-font-family-sans-serif);--pico-line-height:1.5;--pico-font-weight:400;--pico-font-size:100%;--pico-text-underline-offset:0.1rem;--pico-border-radius:0.25rem;--pico-border-width:0.0625rem;--pico-outline-width:0.125rem;--pico-transition:0.2s ease-in-out;--pico-spacing:1rem;--pico-typography-spacing-vertical:1rem;--pico-block-spacing-vertical:var(--pico-spacing);--pico-block-spacing-horizontal:var(--pico-spacing);--pico-form-element-spacing-vertical:0.75rem;--pico-form-element-spacing-horizontal:1rem;--pico-group-box-shadow:0 0 0 rgba(0, 0, 0, 0);--pico-group-box-shadow-focus-with-button:0 0 0 var(--pico-outline-width) var(--pico-primary-focus);--pico-group-box-shadow-focus-with-input:0 0 0 0.0625rem var(--pico-form-element-border-color);--pico-modal-overlay-backdrop-filter:blur(0.375rem);--pico-nav-element-spacing-vertical:1rem;--pico-nav-element-spacing-horizontal:0.5rem;--pico-nav-link-spacing-vertical:0.5rem;--pico-nav-link-spacing-horizontal:0.5rem;--pico-nav-breadcrumb-divider:">";--pico-icon-checkbox:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-minus:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(255, 255, 255)' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='5' y1='12' x2='19' y2='12'%3E%3C/line%3E%3C/svg%3E");--pico-icon-chevron:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-date:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2' ry='2'%3E%3C/rect%3E%3Cline x1='16' y1='2' x2='16' y2='6'%3E%3C/line%3E%3Cline x1='8' y1='2' x2='8' y2='6'%3E%3C/line%3E%3Cline x1='3' y1='10' x2='21' y2='10'%3E%3C/line%3E%3C/svg%3E");--pico-icon-time:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cpolyline points='12 6 12 12 16 14'%3E%3C/polyline%3E%3C/svg%3E");--pico-icon-search:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='11' cy='11' r='8'%3E%3C/circle%3E%3Cline x1='21' y1='21' x2='16.65' y2='16.65'%3E%3C/line%3E%3C/svg%3E");--pico-icon-close:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='rgb(136, 145, 164)' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='18' y1='6' x2='6' y2='18'%3E%3C/line%3E%3Cline x1='6' y1='6' x2='18' y2='18'%3E%3C/line%3E%3C/svg%3E");--pico-icon-loading:url("data:image/svg+xml,%3Csvg fill='none' height='24' width='24' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg' %3E%3Cstyle%3E g %7B animation: rotate 2s linear infinite; transform-origin: center center; %7D circle %7B stroke-dasharray: 75,100; stroke-dashoffset: -5; animation: dash 1.5s ease-in-out infinite; stroke-linecap: round; %7D @keyframes rotate %7B 0%25 %7B transform: rotate(0deg); %7D 100%25 %7B transform: rotate(360deg); %7D %7D @keyframes dash %7B 0%25 %7B stroke-dasharray: 1,100; stroke-dashoffset: 0; %7D 50%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -17.5; %7D 100%25 %7B stroke-dasharray: 44.5,100; stroke-dashoffset: -62; %7D %7D %3C/style%3E%3Cg%3E%3Ccircle cx='12' cy='12' r='10' fill='none' stroke='rgb(136, 145, 164)' stroke-width='4' /%3E%3C/g%3E%3C/svg%3E")}@media (min-width:576px){:host,:root{--pico-font-size:106.25%}}@media (min-width:768px){:host,:root{--pico-font-size:112.5%}}@media (min-width:1024px){:host,:root{--pico-font-size:118.75%}}@media (min-width:1280px){:host,:root{--pico-font-size:125%}}@media (min-width:1536px){:host,:root{--pico-font-size:131.25%}}aLine truncated
+33 -15
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "checktypes_content" .}}
</main>
@@ -20,17 +20,33 @@
{{if not .Groups}}
<p class="muted">Сначала создайте хотя бы одну <a href="/targets">группу целей</a>.</p>
{{else}}
<form hx-post="/check-types" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="ct-name">Имя типа проверки</label>
<input id="ct-name" type="text" name="name" placeholder="https" required>
<label><input type="checkbox" name="enabled" value="1" checked> включён</label>
<fieldset>
<legend>Группы целей</legend>
<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>
<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>
</div>
<div class="col-12 col-md-5">
<label class="form-label mb-1">Группы целей</label>
<div class="d-flex flex-wrap gap-2">
{{range .Groups}}
<label><input type="checkbox" name="targets" value="{{.}}"> {{.}}</label>
<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>
{{end}}
</fieldset>
<button type="submit">Создать тип проверки</button>
</div>
</div>
<div class="col-12 col-md-2">
<button type="submit" class="btn btn-sm btn-primary w-100">Создать</button>
</div>
</div>
</form>
{{end}}
@@ -40,26 +56,28 @@
{{end}}
{{define "checktypes_table"}}
<table role="grid">
<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>
<tbody>
{{range .Items}}
<tr>
<td>{{.Name}}</td>
<td>{{if .Enabled}}✅{{else}}—{{end}}</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 hx-put="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" style="display:inline">
<form class="d-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="secondary" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
<button type="submit" class="btn btn-sm btn-outline-secondary" name="enabled" value="{{if .Enabled}}0{{else}}1{{end}}">
{{if .Enabled}}Выключить{{else}}Включить{{end}}
</button>
</form>
<button class="contrast" hx-delete="/check-types/{{.Name}}" hx-target="#checktypes-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить тип проверки {{.Name}}?">Удалить</button>
<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>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if not .Items}}<p class="muted">Типов проверок нет.</p>{{end}}
{{end}}
+14 -8
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ip_detail_content" .}}
</main>
@@ -14,11 +14,13 @@
{{define "ip_detail_content"}}
<p><a href="/ips">&larr; К очереди</a></p>
<h1>{{.Detail.IP.IPAddress}}</h1>
<h1 class="d-flex align-items-center gap-2">
{{.Detail.IP.IPAddress}}
{{$b := ipBadge .Detail.IP.State .Detail.IP.OverallResult}}
<p>
<span class="{{$b.Class}}">{{$b.Label}}</span>
· попытка {{.Detail.IP.AttemptNumber}} (повторов: {{.Detail.IP.RetryCount}})
<span class="{{$b.Class}} fs-6">{{$b.Label}}</span>
</h1>
<p class="muted mb-1">
попытка {{.Detail.IP.AttemptNumber}} (повторов: {{.Detail.IP.RetryCount}})
· валидатор {{deref .Detail.IP.OwnerValidatorID}}
</p>
<p class="muted">
@@ -28,23 +30,26 @@
<h2>Проверки (попытка {{.Detail.IP.AttemptNumber}})</h2>
{{if .Detail.Checks}}
<table class="compact" role="grid">
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<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}}✅{{else}}❌{{end}}</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>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="muted">Проверок пока нет.</p>{{end}}
<h2>События</h2>
{{if .Detail.Events}}
<table class="compact" role="grid">
<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>
<tbody>
{{range .Detail.Events}}
@@ -52,5 +57,6 @@
{{end}}
</tbody>
</table>
</div>
{{else}}<p class="muted">Событий нет.</p>{{end}}
{{end}}
+17 -9
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "ips_content" .}}
</main>
@@ -15,11 +15,17 @@
{{define "ips_content"}}
<h1>Очередь IP</h1>
<form hx-post="/ips" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="addresses">Адреса (по одному на строку или через запятую)</label>
<textarea id="addresses" name="addresses" rows="3" placeholder="203.0.113.10&#10;203.0.113.11"></textarea>
<button type="submit">Добавить / принудительно перепроверить</button>
<p class="muted">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
<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&#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>
</div>
</div>
<p class="muted mt-2 mb-0">Новый адрес встаёт в очередь; уже завершённый (done/failed) запускается заново
(тот же вызов); тот, что сейчас проверяется, не трогается.</p>
</form>
@@ -29,7 +35,8 @@
{{end}}
{{define "ips_table"}}
<table role="grid">
<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>
<tbody>
{{range .Items}}
@@ -43,14 +50,15 @@
<td>{{fmtTime .UpdatedAt}}</td>
<td class="actions-cell">
{{if $terminal}}
<button class="secondary" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML">Перепроверить</button>
<button class="btn btn-sm btn-outline-secondary" hx-post="/ips/{{.IPAddress}}/recheck" hx-target="#ips-table-wrap" hx-swap="innerHTML">Перепроверить</button>
{{else}}
<button class="contrast" hx-post="/ips/{{.IPAddress}}/cancel" hx-target="#ips-table-wrap" hx-swap="innerHTML" hx-confirm="Остановить проверку {{.IPAddress}}?">Отменить</button>
<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>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if not .Items}}<p class="muted">Очередь пуста.</p>{{end}}
{{end}}
+13 -13
View File
@@ -12,28 +12,28 @@
<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/pico.classless.min.css">
<link rel="stylesheet" href="/static/vendor/bootstrap.min.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"}}
<header>
<nav class="dashboard-nav">
<strong>Cloud IP Validator</strong>
<ul>
<li><a href="/overview" {{if eq .ActiveNav "overview"}}aria-current="page"{{end}}>Обзор</a></li>
<li><a href="/ips" {{if eq .ActiveNav "ips"}}aria-current="page"{{end}}>Очередь IP</a></li>
<li><a href="/validators" {{if eq .ActiveNav "validators"}}aria-current="page"{{end}}>Валидаторы</a></li>
<li><a href="/sites" {{if eq .ActiveNav "sites"}}aria-current="page"{{end}}>Площадки</a></li>
<li><a href="/targets" {{if eq .ActiveNav "targets"}}aria-current="page"{{end}}>Цели</a></li>
<li><a href="/check-types" {{if eq .ActiveNav "check-types"}}aria-current="page"{{end}}>Типы проверок</a></li>
<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>
</div>
</nav>
</header>
{{end}}
{{define "banner_inner"}}{{if .Message}}<div class="error-banner {{if .Client}}error-banner-client{{else}}error-banner-server{{end}}">{{.Message}}</div>{{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 "error_banner"}}<div id="error-banner" hx-swap-oob="true">{{template "banner_inner" .}}</div>{{end}}
+1 -1
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "overview_content" .}}
</main>
@@ -1,15 +1,16 @@
{{define "overview_fragment"}}
<div class="stat-grid">
<div class="stat-tile"><span class="value">{{.Status.TotalIPs}}</span><span class="label">всего IP</span></div>
<div class="stat-tile"><span class="value">{{.Status.TotalValidators}}</span><span class="label">валидаторов</span></div>
<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>
{{range $state, $count := .Status.IPsByState}}
<div class="stat-tile"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div>
<div class="col"><div class="card stat-card text-center"><span class="value">{{$count}}</span><span class="label">{{$state}}</span></div></div>
{{end}}
</div>
<h2>Текущая проверка</h2>
{{if .CurrentItems}}
<table class="compact" role="grid">
<div class="table-responsive">
<table class="table table-sm table-striped table-hover align-middle mb-3">
<thead><tr><th>Адрес</th><th>Состояние</th><th>Валидатор</th><th>Попытка</th><th>Назначено</th></tr></thead>
<tbody>
{{range .CurrentItems}}
@@ -24,19 +25,21 @@
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="muted">Сейчас нет адресов в обработке.</p>
{{end}}
<h2>Последние {{.LastN}} завершённых</h2>
<p class="muted">
<p class="muted mb-2">
pass: {{index .Breakdown "pass"}} ·
partial: {{index .Breakdown "partial"}} ·
fail: {{index .Breakdown "fail"}} ·
cancelled: {{index .Breakdown "cancelled"}}
</p>
{{if .LastCompleted}}
<table class="compact" role="grid">
<div class="table-responsive">
<table class="table table-sm table-striped table-hover align-middle">
<thead><tr><th>Адрес</th><th>Итог</th><th>Завершено</th></tr></thead>
<tbody>
{{range .LastCompleted}}
@@ -49,6 +52,7 @@ cancelled: {{index .Breakdown "cancelled"}}
{{end}}
</tbody>
</table>
</div>
{{else}}
<p class="muted">Пока ничего не завершено.</p>
{{end}}
+8 -6
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "sites_content" .}}
</main>
@@ -23,25 +23,27 @@
{{end}}
{{define "sites_table"}}
<table role="grid">
<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>
<tbody>
{{range .Items}}
<tr>
<td>{{.Index}}</td>
<td>
<form hx-put="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" style="display:flex;gap:.5rem;margin:0">
<input type="text" name="site_id" value="{{.SiteID}}" style="margin:0" required>
<button type="submit" class="secondary" style="margin:0">Сохранить</button>
<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>
</form>
</td>
<td class="actions-cell">
{{if .SiteID}}
<button class="contrast" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Освободить слот {{.Index}}?">Очистить</button>
<button class="btn btn-sm btn-outline-danger" hx-delete="/sites/{{.Index}}" hx-target="#sites-table-wrap" hx-swap="innerHTML" hx-confirm="Освободить слот {{.Index}}?">Очистить</button>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
+23 -13
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "targets_content" .}}
</main>
@@ -16,12 +16,20 @@
<h1>Цели проверки</h1>
<p class="muted">Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.</p>
<form hx-post="/targets" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<label for="target-name">Имя новой группы</label>
<input id="target-name" type="text" name="name" placeholder="web" required>
<label for="target-list">Цели (по одной на строку или через запятую)</label>
<textarea id="target-list" name="targets" rows="2" placeholder="https://hub.docker.com&#10;https://github.com" required></textarea>
<button type="submit">Создать группу</button>
<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>
<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&#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>
</form>
<div id="targets-table-wrap">
@@ -30,24 +38,26 @@
{{end}}
{{define "targets_table"}}
<table role="grid">
<div class="table-responsive">
<table class="table table-sm table-striped align-middle">
<thead><tr><th>Группа</th><th>Цели</th><th>Действия</th></tr></thead>
<tbody>
{{range .Items}}
<tr>
<td>{{.Name}}</td>
<td class="text-nowrap">{{.Name}}</td>
<td>
<form hx-put="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" style="display:flex;gap:.5rem;margin:0">
<textarea name="targets" rows="2" style="margin:0">{{join .Targets "\n"}}</textarea>
<button type="submit" class="secondary" style="margin:0">Сохранить</button>
<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>
</form>
</td>
<td class="actions-cell">
<button class="contrast" hx-delete="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить группу {{.Name}}?">Удалить</button>
<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>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if not .Items}}<p class="muted">Групп целей нет.</p>{{end}}
{{end}}
+21 -11
View File
@@ -4,7 +4,7 @@
<head>{{template "html_head" .}}</head>
<body>
{{template "page_header" .}}
<main>
<main class="container-fluid py-3">
<div id="error-banner">{{template "banner_inner" .Banner}}</div>
{{template "validators_content" .}}
</main>
@@ -15,11 +15,19 @@
{{define "validators_content"}}
<h1>Валидаторы</h1>
<form hx-post="/validators" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
<div class="grid">
<input type="text" name="validator_id" placeholder="validator_id" required>
<input type="text" name="os_port_id" placeholder="os_port_id (Neutron port ID)" required>
<button type="submit">Добавить</button>
<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>
<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>
</div>
</form>
@@ -29,7 +37,8 @@
{{end}}
{{define "validators_table"}}
<table role="grid">
<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>
<tbody>
{{range .Items}}
@@ -37,18 +46,19 @@
<tr>
<td>{{.ValidatorID}}</td>
<td>
<form hx-put="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" style="display:flex;gap:.5rem;margin:0">
<input type="text" name="os_port_id" value="{{.OSPortID}}" style="margin:0">
<button type="submit" class="secondary" style="margin:0">Сохранить</button>
<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>
</form>
</td>
<td><span class="{{$b.Class}}">{{$b.Label}}</span></td>
<td class="actions-cell">
<button class="contrast" hx-delete="/validators/{{.ValidatorID}}" hx-target="#validators-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить валидатор {{.ValidatorID}}?">Удалить</button>
<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>
</tr>
{{end}}
</tbody>
</table>
</div>
{{if not .Items}}<p class="muted">Валидаторов нет.</p>{{end}}
{{end}}