diff --git a/internal/dashboard/handlers_targets.go b/internal/dashboard/handlers_targets.go index 119ef1b..0a7ca6e 100644 --- a/internal/dashboard/handlers_targets.go +++ b/internal/dashboard/handlers_targets.go @@ -5,25 +5,110 @@ import ( "net/http" ) +// usedByTag names one check type that references a target group, and +// whether that check type is currently enabled — rendered as a status pill +// next to the group so the relationship (invisible before this page had +// this column) is visible without cross-referencing /check-types by hand. +type usedByTag struct { + Name string + Enabled bool +} + +// targetGroupView is targetGroupDTO plus the check types that reference it, +// resolved server-side from ListCheckTypes so the template stays a plain +// range with no cross-referencing logic of its own. +type targetGroupView struct { + targetGroupDTO + UsedBy []usedByTag +} + +type targetsStats struct { + Groups int + TotalTargets int + CheckTypesUsing int + Unused int +} + type targetsPageData struct { PageData - Items []targetGroupDTO + Items []targetGroupView + Stats targetsStats +} + +// loadTargetsPage fetches target groups and check types together and +// resolves the group -> referencing-check-types relationship server-side. +// A ListCheckTypes failure degrades to "no usage info" rather than hiding +// the group list — the error still surfaces via the banner. +func (s *Server) loadTargetsPage(r *http.Request) (targetsPageData, error) { + groups, err := s.CA.ListTargetGroups(r.Context()) + if err != nil { + return targetsPageData{}, err + } + checkTypes, ctErr := s.CA.ListCheckTypes(r.Context()) + + usedBy := make(map[string][]usedByTag) + checkTypesUsing := 0 + for _, ct := range checkTypes { + if len(ct.Targets) > 0 { + checkTypesUsing++ + } + for _, g := range ct.Targets { + usedBy[g] = append(usedBy[g], usedByTag{Name: ct.Name, Enabled: ct.Enabled}) + } + } + + items := make([]targetGroupView, len(groups)) + totalTargets := 0 + unused := 0 + for i, g := range groups { + items[i] = targetGroupView{targetGroupDTO: g, UsedBy: usedBy[g.Name]} + totalTargets += len(g.Targets) + if len(usedBy[g.Name]) == 0 { + unused++ + } + } + + return targetsPageData{ + Items: items, + Stats: targetsStats{ + Groups: len(groups), + TotalTargets: totalTargets, + CheckTypesUsing: checkTypesUsing, + Unused: unused, + }, + }, ctErr } func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) { - items, err := s.CA.ListTargetGroups(r.Context()) - data := targetsPageData{Items: items} + data, err := s.loadTargetsPage(r) data.ActiveNav = "targets" data.Banner = bannerFor(err) s.renderPage(w, "targets_page", data) } +// renderTargetsTable renders the #targets-table-wrap swap target plus, +// appended to the same response, an out-of-band update of #targets-stats-wrap +// (see targets_stats_oob in targets.html) — that block sits above the create +// form, outside the swap target, so it needs its own refresh to stay live +// after a create/update/delete. Can't use the shared renderFragment helper +// here because of that second OOB block; otherwise mirrors it exactly, +// including always appending error_banner last. func (s *Server) renderTargetsTable(w http.ResponseWriter, r *http.Request, actionErr error) { - items, listErr := s.CA.ListTargetGroups(r.Context()) + data, listErr := s.loadTargetsPage(r) if actionErr == nil { actionErr = listErr } - s.renderFragment(w, "targets_table", targetsPageData{Items: items}, actionErr) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "targets_table", data); err != nil { + s.Log.Error("render fragment", "template", "targets_table", "err", err) + return + } + if err := s.tmpl.ExecuteTemplate(w, "targets_stats_oob", data); err != nil { + s.Log.Error("render targets stats oob", "err", err) + } + if err := s.tmpl.ExecuteTemplate(w, "error_banner", bannerFor(actionErr)); err != nil { + s.Log.Error("render error banner", "err", err) + } } func (s *Server) handleTargetCreate(w http.ResponseWriter, r *http.Request) { diff --git a/internal/dashboard/render.go b/internal/dashboard/render.go index 39f6b52..120cd4b 100644 --- a/internal/dashboard/render.go +++ b/internal/dashboard/render.go @@ -114,6 +114,24 @@ var funcMap = template.FuncMap{ "deref": derefStr, "join": strings.Join, "joinInts": joinInts, + "pluralAddr": pluralAddr, +} + +// pluralAddr returns the correctly declined Russian word for "address" +// (адрес/адреса/адресов) for the given count — standard count%10/count%100 +// rule, with the usual 11-14 exception. +func pluralAddr(n int) string { + if n%100 >= 11 && n%100 <= 14 { + return "адресов" + } + switch n % 10 { + case 1: + return "адрес" + case 2, 3, 4: + return "адреса" + default: + return "адресов" + } } func parseTemplates() (*template.Template, error) { diff --git a/internal/dashboard/static/dashboard.css b/internal/dashboard/static/dashboard.css index 1b74096..dd3615b 100644 --- a/internal/dashboard/static/dashboard.css +++ b/internal/dashboard/static/dashboard.css @@ -264,7 +264,7 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; } :root[data-theme="dark"] .theme-toggle .icon-sun { display: none; } :root[data-theme="dark"] .theme-toggle .icon-moon { display: block; } -.main { padding: 22px 26px 60px; max-width: 1180px; } +.main { padding: 22px 26px 60px; max-width: 1180px; margin-inline: auto; } .topbar { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 18px; flex-wrap: wrap; gap: 10px; @@ -273,7 +273,13 @@ nav.nav-groups { display: flex; flex-direction: column; gap: 1px; } /* ---------- stat cards ---------- */ .stat-grid { - display: grid; grid-template-columns: repeat(6, 1fr); + /* auto-fit instead of a fixed repeat(6, ...): the state histogram this + drives is a variable-length map (whichever ip_queue states are + actually present), so a fixed column count left visible empty + background showing through the unused grid tracks whenever fewer + than 6 cards exist. auto-fit collapses empty tracks and lets the + real cards stretch to fill the row instead. */ + display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius-sm); @@ -475,7 +481,6 @@ code.inline { font-family: var(--font-mono); background: var(--surface-alt); bor display: grid; place-items: center; cursor: pointer; flex-shrink: 0; } .main { padding: 16px 14px 50px; } - .stat-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 640px) { @@ -496,3 +501,46 @@ code.inline { font-family: var(--font-mono); background: var(--surface-alt); bor } .actions { justify-content: flex-start; margin-top: 6px; } } + +/* ---------- targets page: group summary + usage ---------- */ + +/* 4-card variant of .stat-grid for pages whose summary row isn't the + dynamic ips_by_state histogram (which wants 6 columns). */ +.stat-grid.cols-4 { grid-template-columns: repeat(4, 1fr); } +@media (max-width: 900px) { .stat-grid.cols-4 { grid-template-columns: repeat(2, 1fr); } } + +/* Neutral data token for listing individual targets inline — distinct + from .pill (which always carries a status meaning via its color + dot): + a chip is just a value, not a state. */ +.chip-list { display: flex; flex-wrap: wrap; gap: 5px; max-width: 460px; } +.chip { + font-family: var(--font-mono); + font-size: 11.5px; + padding: 2px 7px; + border-radius: var(--radius-xs); + background: var(--surface-alt); + border: 1px solid var(--border-soft); + color: var(--text-muted); + white-space: nowrap; +} + +/* Row group name + target count, stacked. */ +.group-name { display: flex; flex-direction: column; gap: 2px; } +.group-name .count { font-size: 11px; color: var(--text-faint); font-weight: 400; text-transform: none; letter-spacing: 0; font-family: var(--font-ui); } + +/* "Used by" cell — wraps its pills with a small gap, same idiom as + chip-list, kept separate since these ARE status-colored .pill elements. */ +.used-by { display: flex; flex-wrap: wrap; gap: 4px; } + +/* Inline edit row, toggled open by the "Изменить" button — collapses by + default so the table stays scannable instead of showing N always-open + textareas at once. */ +tr.edit-row td { background: var(--surface-alt); padding: 14px 16px; } +tr.edit-row textarea { flex: 1 1 auto; } + +/* Auto-growing textarea (new-group form + per-row edit forms above) — + stretches with content instead of showing an internal scrollbar + immediately; only caps out (and starts scrolling) past ~20 lines. + Height is set by JS (see templates/targets.html), computed from the + field's own line-height, so it stays correct if font-size ever changes. */ +textarea.autosize { overflow-y: hidden; resize: none; } diff --git a/internal/dashboard/templates/targets.html b/internal/dashboard/templates/targets.html index 32d4dcf..25cdef1 100644 --- a/internal/dashboard/templates/targets.html +++ b/internal/dashboard/templates/targets.html @@ -15,6 +15,49 @@ + {{end}} @@ -23,18 +66,20 @@

Цели проверки

Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.

+
{{template "targets_stats_inner" .}}
+

Новая группа целей

- - + +
- - + +
@@ -47,31 +92,61 @@
{{end}} +{{/* Stat-grid content only, shared by the initial page render (plain, + inside #targets-stats-wrap above) and the OOB update below (targets + mutations only swap #targets-table-wrap, so the stats need their own + out-of-band refresh to stay live — same pattern as layout.html's + banner_inner/error_banner split). */}} +{{define "targets_stats_inner"}} +
+
{{.Stats.Groups}}групп целей
+
{{.Stats.TotalTargets}}адресов всего
+
{{.Stats.CheckTypesUsing}}типа проверок ссылаются
+
{{.Stats.Unused}}без типа проверки
+
+{{end}} + +{{define "targets_stats_oob"}}
{{template "targets_stats_inner" .}}
{{end}} + {{define "targets_table"}}
- + {{range .Items}} - + + + + + {{end}}
ГруппаЦели
ГруппаЦелиИспользуется в
{{.Name}} +{{.Name}}{{len .Targets}} {{pluralAddr (len .Targets)}} + - - - - +
{{range .Targets}}{{.}}{{end}}
+
+{{if .UsedBy}}
{{range .UsedBy}}{{.Name}}{{if not .Enabled}} (выкл.){{end}}{{end}}
+{{else}}не используется ни одним типом проверки{{end}}
+
+{{if .Items}}
Колонка «Используется в» показывает типы проверок (/check-types), которые ссылаются на группу; пусто — группа ничем не используется. «Изменить» разворачивает поле для редактирования только для этой группы.
{{end}}
{{if not .Items}}

Групп целей нет.

{{end}} {{end}}