Show check-type usage and stats on the target groups dashboard page
Each target group's relationship to the check types that reference it was previously invisible without cross-referencing /check-types by hand. handleTargetsPage/renderTargetsTable now resolve that server-side via loadTargetsPage (group -> referencing check types, enabled or not) and render it as a status pill per group, plus a stats row (group count, total targets, check types using them, unused groups) kept live via an out-of-band swap on every create/update/delete. Also auto-sizes the targets textarea as you type (targets.html), and lets the stat-card grid collapse to however many state cards actually exist instead of leaving empty background where a fixed repeat(6, ...) had no card to fill. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
95f8066eed
commit
792b7fbb84
4 files changed
+244
-18
No files matched your search
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
@@ -15,6 +15,49 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var maxLines = 20;
|
||||
function resize(el) {
|
||||
var cs = getComputedStyle(el);
|
||||
var lineHeight = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.5;
|
||||
var paddingV = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
||||
var borderV = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth);
|
||||
var maxHeight = Math.round(lineHeight * maxLines + paddingV + borderV);
|
||||
el.style.height = 'auto';
|
||||
var next = Math.min(el.scrollHeight, maxHeight);
|
||||
el.style.height = next + 'px';
|
||||
el.style.overflowY = el.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
}
|
||||
function bind(el) {
|
||||
if (el.dataset.autosizeBound) return;
|
||||
el.dataset.autosizeBound = '1';
|
||||
el.addEventListener('input', function () { resize(el); });
|
||||
// An element inside a hidden (display:none) edit row reports
|
||||
// scrollHeight 0 — sizing it now would wrongly collapse it to ~0px
|
||||
// and that inline height sticks around after the row is later shown.
|
||||
// Skip the initial measurement here; toggleEditRow re-measures once
|
||||
// the row is actually visible.
|
||||
if (el.offsetParent !== null) resize(el);
|
||||
}
|
||||
function bindAll() {
|
||||
document.querySelectorAll('textarea.autosize').forEach(bind);
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', bindAll);
|
||||
document.body.addEventListener('htmx:afterSwap', bindAll);
|
||||
|
||||
// Shared by every row's "Изменить"/"Отмена" buttons (see targets_table).
|
||||
window.toggleEditRow = function (name) {
|
||||
var row = document.getElementById('edit-' + name);
|
||||
if (!row) return;
|
||||
row.hidden = !row.hidden;
|
||||
if (!row.hidden) {
|
||||
var ta = row.querySelector('textarea.autosize');
|
||||
if (ta) { bind(ta); resize(ta); }
|
||||
}
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -23,18 +66,20 @@
|
||||
<div class="topbar"><h1>Цели проверки</h1></div>
|
||||
<p class="muted" style="margin-bottom:16px">Группа целей — именованный список URL/адресов, на который затем ссылаются типы проверок.</p>
|
||||
|
||||
<div id="targets-stats-wrap">{{template "targets_stats_inner" .}}</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head"><h2>Новая группа целей</h2></div>
|
||||
<div class="panel-body">
|
||||
<form hx-post="/targets" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-on::after-request="this.reset()">
|
||||
<div class="field-row">
|
||||
<div class="field" style="flex: 0 1 200px;">
|
||||
<label>Имя новой группы</label>
|
||||
<input type="text" name="name" placeholder="web" required>
|
||||
<label for="new-group-name">Имя новой группы</label>
|
||||
<input type="text" id="new-group-name" name="name" placeholder="web" required>
|
||||
</div>
|
||||
<div class="field" style="flex: 1 1 380px;">
|
||||
<label>Цели (по одной на строку или через запятую)</label>
|
||||
<textarea name="targets" rows="2" placeholder="https://hub.docker.com https://github.com" required></textarea>
|
||||
<label for="new-group-targets">Цели (по одной на строку или через запятую)</label>
|
||||
<textarea id="new-group-targets" name="targets" rows="2" class="autosize" placeholder="https://hub.docker.com https://github.com" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Создать</button>
|
||||
</div>
|
||||
@@ -47,31 +92,61 @@
|
||||
</div>
|
||||
{{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"}}
|
||||
<div class="stat-grid cols-4">
|
||||
<div class="stat-card accented"><span class="value">{{.Stats.Groups}}</span><span class="label">групп целей</span></div>
|
||||
<div class="stat-card"><span class="value">{{.Stats.TotalTargets}}</span><span class="label">адресов всего</span></div>
|
||||
<div class="stat-card"><span class="value">{{.Stats.CheckTypesUsing}}</span><span class="label">типа проверок ссылаются</span></div>
|
||||
<div class="stat-card{{if gt .Stats.Unused 0}} warn{{end}}"><span class="value">{{.Stats.Unused}}</span><span class="label">без типа проверки</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "targets_stats_oob"}}<div id="targets-stats-wrap" hx-swap-oob="true">{{template "targets_stats_inner" .}}</div>{{end}}
|
||||
|
||||
{{define "targets_table"}}
|
||||
<div class="panel">
|
||||
<div class="table-scroll">
|
||||
<table>
|
||||
<thead><tr><th>Группа</th><th>Цели</th><th></th></tr></thead>
|
||||
<thead><tr><th>Группа</th><th>Цели</th><th>Используется в</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Items}}
|
||||
<tr>
|
||||
<td class="mono" data-label="Группа">{{.Name}}</td>
|
||||
<td data-label="Группа">
|
||||
<span class="group-name"><span class="mono" style="font-weight:600">{{.Name}}</span><span class="count">{{len .Targets}} {{pluralAddr (len .Targets)}}</span></span>
|
||||
</td>
|
||||
<td data-label="Цели">
|
||||
<form class="inline-form" hx-put="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML">
|
||||
<textarea name="targets" rows="2">{{join .Targets "\n"}}</textarea>
|
||||
<button type="submit" class="btn btn-ghost btn-sm">Сохранить</button>
|
||||
</form>
|
||||
<div class="chip-list">{{range .Targets}}<span class="chip">{{.}}</span>{{end}}</div>
|
||||
</td>
|
||||
<td data-label="Используется в">
|
||||
{{if .UsedBy}}<div class="used-by">{{range .UsedBy}}<span class="pill {{if .Enabled}}pill-success{{else}}pill-neutral{{end}}">{{.Name}}{{if not .Enabled}} (выкл.){{end}}</span>{{end}}</div>
|
||||
{{else}}<span class="muted">не используется ни одним типом проверки</span>{{end}}
|
||||
</td>
|
||||
<td data-label="">
|
||||
<div class="actions">
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick="toggleEditRow('{{.Name}}')">Изменить</button>
|
||||
<button class="btn btn-danger-ghost btn-sm" hx-delete="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML" hx-confirm="Удалить группу {{.Name}}?">Удалить</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="edit-row" id="edit-{{.Name}}" hidden>
|
||||
<td colspan="4">
|
||||
<form class="inline-form" hx-put="/targets/{{.Name}}" hx-target="#targets-table-wrap" hx-swap="innerHTML">
|
||||
<textarea name="targets" rows="2" class="autosize">{{join .Targets "\n"}}</textarea>
|
||||
<button type="submit" class="btn btn-primary btn-sm">Сохранить</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" onclick="toggleEditRow('{{.Name}}')">Отмена</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if .Items}}<div class="section-note">Колонка «Используется в» показывает типы проверок (<a href="/check-types" style="color:var(--accent)">/check-types</a>), которые ссылаются на группу; пусто — группа ничем не используется. «Изменить» разворачивает поле для редактирования только для этой группы.</div>{{end}}
|
||||
</div>
|
||||
{{if not .Items}}<p class="muted">Групп целей нет.</p>{{end}}
|
||||
{{end}}
|
||||
Reference in new issue
Block a user