From 83832fc7349fe3b8b3a32e81907c2449e4e95e30 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:42:32 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=B5=D0=B1-=D0=BC=D0=BE=D1=80=D0=B4?= =?UTF-8?q?=D0=B0:=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA,=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D1=8B,=20=D1=81=D0=BE=D1=80=D1=82=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B8=20=D0=BF=D0=B0=D0=B3?= =?UTF-8?q?=D0=B8=D0=BD=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BB=D0=B8=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2;=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - список клиентов: поиск по домену и названию, фильтр по статусу (чипы со счётчиками), сортировка по любой колонке, пагинация 10/25/50/100; состояние в URL — отфильтрованный вид можно сохранить ссылкой - размеры последних бэкапов одним запросом вместо запроса на клиента - шапка выровнена по колонке контента; настройки на всю ширину: ntfy и Telegram двумя карточками, на узком экране — друг под другом Co-Authored-By: Claude Opus 5.5 --- internal/server/clients_list.go | 280 +++++++++++++++++++++++ internal/server/clients_list_test.go | 87 +++++++ internal/server/static/style.css | 288 +++++++++++++----------- internal/server/store.go | 19 ++ internal/server/templates/clients.html | 45 +++- internal/server/templates/layout.html | 20 +- internal/server/templates/settings.html | 48 ++-- internal/server/web.go | 18 -- 8 files changed, 626 insertions(+), 179 deletions(-) create mode 100644 internal/server/clients_list.go create mode 100644 internal/server/clients_list_test.go diff --git a/internal/server/clients_list.go b/internal/server/clients_list.go new file mode 100644 index 0000000..26c7245 --- /dev/null +++ b/internal/server/clients_list.go @@ -0,0 +1,280 @@ +package server + +import ( + "cmp" + "net/http" + "net/url" + "slices" + "strconv" + "strings" +) + +// Параметры списка клиентов живут в URL: ссылку на отфильтрованный вид можно сохранить. +type clientsQuery struct { + Q string + Status string // "" — все + Sort string + Desc bool + Page int + Per int +} + +var ( + sortColumns = []string{"domain", "name", "status", "last_backup", "size", "last_seen"} + perOptions = []int{10, 25, 50, 100} + statusOrder = []struct{ Key, Title string }{ + {"fail", "ошибка"}, {"stale", "просрочен"}, {"never", "нет бэкапов"}, {"ok", "ok"}, {"off", "отключён"}, + } +) + +// defaultDesc — направление по умолчанию при выборе колонки: даты и размеры — сначала большие. +func defaultDesc(col string) bool { + switch col { + case "last_backup", "size", "last_seen": + return true + } + return false +} + +func parseClientsQuery(r *http.Request) clientsQuery { + v := r.URL.Query() + q := clientsQuery{ + Q: strings.TrimSpace(v.Get("q")), + Status: v.Get("status"), + Sort: v.Get("sort"), + Page: 1, + Per: 25, + } + if !slices.ContainsFunc(statusOrder, func(s struct{ Key, Title string }) bool { return s.Key == q.Status }) { + q.Status = "" + } + if !slices.Contains(sortColumns, q.Sort) { + q.Sort = "domain" + } + switch v.Get("dir") { + case "asc": + q.Desc = false + case "desc": + q.Desc = true + default: + q.Desc = defaultDesc(q.Sort) + } + if p, err := strconv.Atoi(v.Get("page")); err == nil && p > 0 { + q.Page = p + } + if p, err := strconv.Atoi(v.Get("per")); err == nil && slices.Contains(perOptions, p) { + q.Per = p + } + return q +} + +func (q clientsQuery) values() url.Values { + v := url.Values{} + if q.Q != "" { + v.Set("q", q.Q) + } + if q.Status != "" { + v.Set("status", q.Status) + } + if q.Sort != "domain" || q.Desc { + v.Set("sort", q.Sort) + if q.Desc != defaultDesc(q.Sort) { + if q.Desc { + v.Set("dir", "desc") + } else { + v.Set("dir", "asc") + } + } + } + if q.Page > 1 { + v.Set("page", strconv.Itoa(q.Page)) + } + if q.Per != 25 { + v.Set("per", strconv.Itoa(q.Per)) + } + return v +} + +func (q clientsQuery) url() string { + if enc := q.values().Encode(); enc != "" { + return "/?" + enc + } + return "/" +} + +// StatusURL — ссылка на фильтр по статусу (с первой страницы). +func (q clientsQuery) StatusURL(status string) string { + q.Status, q.Page = status, 1 + return q.url() +} + +// SortURL — ссылка для заголовка колонки: повторный клик меняет направление. +func (q clientsQuery) SortURL(col string) string { + if q.Sort == col { + q.Desc = !q.Desc + } else { + q.Sort, q.Desc = col, defaultDesc(col) + } + q.Page = 1 + return q.url() +} + +func (q clientsQuery) SortMark(col string) string { + if q.Sort != col { + return "" + } + if q.Desc { + return "↓" + } + return "↑" +} + +// DirParam — значение dir для формы поиска, если направление отличается от умолчания колонки. +func (q clientsQuery) DirParam() string { + return q.values().Get("dir") +} + +func (q clientsQuery) PageURL(p int) string { + q.Page = p + return q.url() +} + +type statusChip struct { + Key, Title string + Count int + URL string + Active bool +} + +type pageLink struct { + N int + URL string + Current bool + Gap bool +} + +type clientsView struct { + Query clientsQuery + Rows []clientRow + Chips []statusChip + Total int // всего клиентов + Found int // после поиска и фильтра + From, To int + Pages []pageLink + Prev string + Next string + PerOpts []int +} + +func (s *Server) webClients(w http.ResponseWriter, r *http.Request) { + q := parseClientsQuery(r) + clients, err := s.st.ListClients() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sizes, err := s.st.LastSizes() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + v := buildClientsView(q, clients, sizes) + s.render(w, r, "clients", map[string]any{"V": v}) +} + +func buildClientsView(q clientsQuery, clients []*Client, sizes map[int64]int64) *clientsView { + v := &clientsView{Query: q, Total: len(clients), PerOpts: perOptions} + + // поиск + needle := strings.ToLower(q.Q) + var matched []clientRow + for _, c := range clients { + if needle != "" && !strings.Contains(strings.ToLower(c.Domain), needle) && !strings.Contains(strings.ToLower(c.Name), needle) { + continue + } + row := clientRow{Client: c, LastSize: sizes[c.ID]} + row.StatusClass, row.StatusText = clientStatus(c) + matched = append(matched, row) + } + + // чипы статусов считаются по результату поиска, до фильтра по статусу + counts := map[string]int{} + for _, r := range matched { + counts[r.StatusClass]++ + } + v.Chips = append(v.Chips, statusChip{Title: "все", Count: len(matched), URL: q.StatusURL(""), Active: q.Status == ""}) + for _, st := range statusOrder { + if counts[st.Key] == 0 && q.Status != st.Key { + continue + } + v.Chips = append(v.Chips, statusChip{Key: st.Key, Title: st.Title, Count: counts[st.Key], URL: q.StatusURL(st.Key), Active: q.Status == st.Key}) + } + + rows := matched[:0:0] + for _, r := range matched { + if q.Status == "" || r.StatusClass == q.Status { + rows = append(rows, r) + } + } + + rank := map[string]int{} + for i, st := range statusOrder { + rank[st.Key] = i + } + slices.SortStableFunc(rows, func(a, b clientRow) int { + var c int + switch q.Sort { + case "name": + c = cmp.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) + case "status": + c = cmp.Compare(rank[a.StatusClass], rank[b.StatusClass]) + case "last_backup": + c = cmp.Compare(a.LastBackupAt, b.LastBackupAt) + case "size": + c = cmp.Compare(a.LastSize, b.LastSize) + case "last_seen": + c = cmp.Compare(a.LastSeenAt, b.LastSeenAt) + } + if q.Desc { + c = -c + } + if c == 0 { // стабильный вторичный порядок — по домену + c = cmp.Compare(a.Domain, b.Domain) + } + return c + }) + + // пагинация + v.Found = len(rows) + pages := max(1, (v.Found+q.Per-1)/q.Per) + if q.Page > pages { + q.Page = pages + v.Query = q + } + start := (q.Page - 1) * q.Per + end := min(start+q.Per, v.Found) + v.Rows = rows[start:end] + if v.Found > 0 { + v.From, v.To = start+1, end + } + if pages > 1 { + if q.Page > 1 { + v.Prev = q.PageURL(q.Page - 1) + } + if q.Page < pages { + v.Next = q.PageURL(q.Page + 1) + } + last := 0 + for p := 1; p <= pages; p++ { + if p != 1 && p != pages && (p < q.Page-2 || p > q.Page+2) { + continue + } + if last != 0 && p > last+1 { + v.Pages = append(v.Pages, pageLink{Gap: true}) + } + v.Pages = append(v.Pages, pageLink{N: p, URL: q.PageURL(p), Current: p == q.Page}) + last = p + } + } + return v +} diff --git a/internal/server/clients_list_test.go b/internal/server/clients_list_test.go new file mode 100644 index 0000000..463d4a8 --- /dev/null +++ b/internal/server/clients_list_test.go @@ -0,0 +1,87 @@ +package server + +import ( + "fmt" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func testClients() ([]*Client, map[int64]int64) { + now := time.Now().Unix() + var cs []*Client + sizes := map[int64]int64{} + for i := 1; i <= 30; i++ { + c := &Client{ID: int64(i), Domain: fmt.Sprintf("srv%02d.example.com", i), Name: fmt.Sprintf("srv%02d", i), + StaleHours: 26, LastBackupAt: now - int64(i)*60, CreatedAt: now} + sizes[c.ID] = int64(i) * 1000 + cs = append(cs, c) + } + cs[0].Name = "Нидерланды" // srv01 + cs[1].LastBackupAt = 0 // srv02 — never + cs[2].LastErrorAt = now // srv03 — fail + cs[3].Disabled = true // srv04 — off + cs[4].LastBackupAt = now - 100*3600 // srv05 — stale + return cs, sizes +} + +func view(t *testing.T, rawQuery string) *clientsView { + t.Helper() + cs, sizes := testClients() + r := httptest.NewRequest("GET", "/?"+rawQuery, nil) + return buildClientsView(parseClientsQuery(r), cs, sizes) +} + +func TestClientsSearchFilter(t *testing.T) { + v := view(t, "q=нидер") + if v.Found != 1 || v.Rows[0].Domain != "srv01.example.com" { + t.Fatalf("поиск по названию: %d", v.Found) + } + if v := view(t, "q=SRV1"); v.Found != 10 { // srv10..srv19 + t.Fatalf("поиск без учёта регистра: %d", v.Found) + } + v = view(t, "status=fail") + if v.Found != 1 || v.Rows[0].Domain != "srv03.example.com" { + t.Fatalf("фильтр fail: %+v", v.Found) + } + chips := map[string]int{} + for _, c := range v.Chips { + chips[c.Key] = c.Count + } + if chips[""] != 30 || chips["never"] != 1 || chips["off"] != 1 || chips["stale"] != 1 || chips["ok"] != 26 { + t.Fatalf("счётчики: %v", chips) + } +} + +func TestClientsSortAndPaging(t *testing.T) { + v := view(t, "") + if v.Found != 30 || len(v.Rows) != 25 || v.Rows[0].Domain != "srv01.example.com" || v.Next == "" { + t.Fatalf("первая страница: found=%d rows=%d", v.Found, len(v.Rows)) + } + v = view(t, "page=2") + if len(v.Rows) != 5 || v.From != 26 || v.To != 30 || v.Prev == "" || v.Next != "" { + t.Fatalf("вторая страница: %+v", v) + } + if v := view(t, "page=99"); v.Query.Page != 2 { + t.Fatalf("страница за пределами: %d", v.Query.Page) + } + v = view(t, "sort=size&per=10") + if v.Rows[0].Domain != "srv30.example.com" || len(v.Rows) != 10 { + t.Fatalf("размер по убыванию по умолчанию: %s", v.Rows[0].Domain) + } + if v := view(t, "sort=size&dir=asc"); v.Rows[0].Domain != "srv01.example.com" { + t.Fatalf("размер по возрастанию: %s", v.Rows[0].Domain) + } + if v := view(t, "sort=status"); v.Rows[0].StatusClass != "fail" { + t.Fatalf("сортировка по статусу: %s", v.Rows[0].StatusClass) + } + // повторный клик по текущей колонке меняет направление + if u := view(t, "sort=size").Query.SortURL("size"); !strings.Contains(u, "dir=asc") { + t.Fatalf("SortURL: %s", u) + } + // фильтр сохраняется в ссылках пагинации + if u := view(t, "q=srv&status=ok").Query.PageURL(2); !strings.Contains(u, "q=srv") || !strings.Contains(u, "status=ok") { + t.Fatalf("PageURL: %s", u) + } +} diff --git a/internal/server/static/style.css b/internal/server/static/style.css index 7875d7f..8323822 100644 --- a/internal/server/static/style.css +++ b/internal/server/static/style.css @@ -1,129 +1,159 @@ -:root { - --bg: #f6f7f9; - --panel: #ffffff; - --text: #1c2128; - --muted: #6b7280; - --border: #dfe3e8; - --accent: #2f6fde; - --accent-text: #ffffff; - --ok: #1f8a4c; - --ok-bg: #e3f4ea; - --warn: #9a6400; - --warn-bg: #fdf1d8; - --fail: #c0362c; - --fail-bg: #fbe5e3; - --code-bg: #eef1f5; -} -@media (prefers-color-scheme: dark) { - :root { - --bg: #14171c; - --panel: #1c2027; - --text: #e4e7eb; - --muted: #9aa3ae; - --border: #2e343d; - --accent: #5b8ff0; - --ok: #4cc27f; - --ok-bg: #18342a; - --warn: #e0ad4a; - --warn-bg: #3a2f17; - --fail: #f07167; - --fail-bg: #3d1f1d; - --code-bg: #232831; - } -} -* { box-sizing: border-box; } -body { - margin: 0; - background: var(--bg); - color: var(--text); - font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -} -a { color: var(--accent); text-decoration: none; } -a:hover { text-decoration: underline; } -code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; } -code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; } - -.top { - display: flex; align-items: center; justify-content: space-between; gap: 16px; - padding: 10px 24px; background: var(--panel); border-bottom: 1px solid var(--border); -} -.brand { font-weight: 600; color: var(--text); } -.top nav { display: flex; align-items: center; gap: 18px; } -.top nav a { color: var(--muted); } -.top nav a.active { color: var(--text); font-weight: 500; } - -main { max-width: 1200px; margin: 0 auto; padding: 24px; } -h1 { font-size: 24px; margin: 0 0 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } -h2 { font-size: 18px; margin: 32px 0 12px; } -.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } -.actions { display: flex; gap: 8px; flex-wrap: wrap; } -.muted { color: var(--muted); } -.small { font-size: 13px; } -.right { text-align: right; } -.empty { color: var(--muted); padding: 24px 0; } -.hint { color: var(--muted); font-size: 13px; margin: 4px 0 12px; } - -.table-wrap { overflow-x: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; } -table { width: 100%; border-collapse: collapse; } -th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } -th { font-size: 13px; font-weight: 500; color: var(--muted); } -tr:last-child td { border-bottom: none; } -td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } - -.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; white-space: nowrap; } -.badge.ok { background: var(--ok-bg); color: var(--ok); } -.badge.stale, .badge.never { background: var(--warn-bg); color: var(--warn); } -.badge.fail { background: var(--fail-bg); color: var(--fail); } -.badge.off { background: var(--code-bg); color: var(--muted); } -.ok-text { color: var(--ok); } -.fail-text { color: var(--fail); } -ul.results { list-style: none; padding: 0; margin: 0 0 16px; } - -.facts { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 16px 0; } -.facts dt { font-size: 12px; color: var(--muted); } -.facts dd { margin: 0; } - -.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } -.card.narrow, .login { max-width: 520px; } -.card h2:first-of-type { margin-top: 0; } -label { display: block; margin-bottom: 12px; font-size: 14px; } -label.check { display: flex; align-items: center; gap: 8px; } -input, textarea { - display: block; width: 100%; margin-top: 4px; padding: 7px 10px; - background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: inherit; -} -input[type=checkbox] { display: inline; width: auto; margin: 0; } -textarea { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; } -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0 16px; } - -button, .button { - display: inline-block; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border); - background: var(--panel); color: var(--text); font: inherit; cursor: pointer; -} -button:hover, .button:hover { text-decoration: none; filter: brightness(0.97); } -button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } -button:disabled { opacity: 0.5; cursor: not-allowed; } -button.link { background: none; border: none; padding: 0; color: var(--muted); } -button.danger { background: var(--fail-bg); border-color: var(--fail-bg); color: var(--fail); } -button.link.danger { background: none; } -form.inline { display: inline; } - -.error { background: var(--fail-bg); color: var(--fail); padding: 10px 14px; border-radius: 6px; } -.warnings { background: var(--warn-bg); color: var(--warn); padding: 10px 14px; border-radius: 6px; margin: 12px 0; } -.warnings ul { margin: 8px 0 0; padding-left: 20px; font-size: 13px; } -.danger-zone { margin-top: 32px; color: var(--muted); } -.danger-zone form { margin-top: 12px; max-width: 420px; } -.danger-zone input { margin-bottom: 8px; } - -.cmd { display: flex; gap: 8px; align-items: flex-start; margin: 16px 0; } -.cmd pre { flex: 1; margin: 0; padding: 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; word-break: break-all; } -pre.json { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-x: auto; } - -.login { margin: 10vh auto; } -.login form { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } - -@media (max-width: 640px) { - main { padding: 16px; } - .top { padding: 10px 16px; } - .cmd { flex-direction: column; } -} +:root { + --bg: #f6f7f9; + --panel: #ffffff; + --text: #1c2128; + --muted: #6b7280; + --border: #dfe3e8; + --accent: #2f6fde; + --accent-text: #ffffff; + --ok: #1f8a4c; + --ok-bg: #e3f4ea; + --warn: #9a6400; + --warn-bg: #fdf1d8; + --fail: #c0362c; + --fail-bg: #fbe5e3; + --code-bg: #eef1f5; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #14171c; + --panel: #1c2027; + --text: #e4e7eb; + --muted: #9aa3ae; + --border: #2e343d; + --accent: #5b8ff0; + --ok: #4cc27f; + --ok-bg: #18342a; + --warn: #e0ad4a; + --warn-bg: #3a2f17; + --fail: #f07167; + --fail-bg: #3d1f1d; + --code-bg: #232831; + } +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +code, pre { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; } +code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; } + +.top { background: var(--panel); border-bottom: 1px solid var(--border); } +/* шапка выровнена по той же колонке, что и контент */ +.top .wrap { + max-width: 1200px; margin: 0 auto; padding: 10px 24px; + display: flex; align-items: center; justify-content: space-between; gap: 16px; +} +.brand { font-weight: 600; color: var(--text); } +.top nav { display: flex; align-items: center; gap: 18px; } +.top nav a { color: var(--muted); } +.top nav a.active { color: var(--text); font-weight: 500; } + +main { max-width: 1200px; margin: 0 auto; padding: 24px; } +h1 { font-size: 24px; margin: 0 0 8px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } +h2 { font-size: 18px; margin: 32px 0 12px; } +.head { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; } +.actions { display: flex; gap: 8px; flex-wrap: wrap; } +.muted { color: var(--muted); } +.small { font-size: 13px; } +.right { text-align: right; } +.empty { color: var(--muted); padding: 24px 0; } +.hint { color: var(--muted); font-size: 13px; margin: 4px 0 12px; } + +.table-wrap { overflow-x: auto; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; } +table { width: 100%; border-collapse: collapse; } +th, td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; } +th { font-size: 13px; font-weight: 500; color: var(--muted); } +tr:last-child td { border-bottom: none; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } + +.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; font-weight: 500; white-space: nowrap; } +.badge.ok { background: var(--ok-bg); color: var(--ok); } +.badge.stale, .badge.never { background: var(--warn-bg); color: var(--warn); } +.badge.fail { background: var(--fail-bg); color: var(--fail); } +.badge.off { background: var(--code-bg); color: var(--muted); } +.ok-text { color: var(--ok); } +.fail-text { color: var(--fail); } +ul.results { list-style: none; padding: 0; margin: 0 0 16px; } + +.facts { display: flex; flex-wrap: wrap; gap: 12px 32px; margin: 16px 0; } +.facts dt { font-size: 12px; color: var(--muted); } +.facts dd { margin: 0; } + +.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } +.card.narrow, .login { max-width: 520px; } +.card h2:first-of-type { margin-top: 0; } +label { display: block; margin-bottom: 12px; font-size: 14px; } +label.check { display: flex; align-items: center; gap: 8px; } +input, textarea { + display: block; width: 100%; margin-top: 4px; padding: 7px 10px; + background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; font: inherit; +} +input[type=checkbox] { display: inline; width: auto; margin: 0; } +textarea { font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; font-size: 13px; } +.settings { display: flex; flex-direction: column; gap: 16px; } +.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 16px; align-items: start; } +.cards .card { height: 100%; } + +.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 12px; } +.toolbar input[type=search] { flex: 1 1 260px; width: auto; margin: 0; } +.toolbar select { margin: 0; } +select { + padding: 7px 10px; background: var(--bg); color: var(--text); + border: 1px solid var(--border); border-radius: 6px; font: inherit; +} +.chips { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { + display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 14px; + border: 1px solid var(--border); color: var(--muted); font-size: 13px; white-space: nowrap; +} +.chip:hover { text-decoration: none; color: var(--text); } +.chip.active { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.chip .count { opacity: 0.75; font-variant-numeric: tabular-nums; } +th a { color: inherit; } +th a.sorted { color: var(--text); } +.pager { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-top: 12px; color: var(--muted); font-size: 13px; } +.pager .pages { display: flex; gap: 4px; flex-wrap: wrap; } +.pager .pages a, .pager .pages span { min-width: 32px; text-align: center; padding: 4px 8px; border-radius: 6px; border: 1px solid var(--border); } +.pager .pages span.current { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +.pager .pages span.gap { border: none; } + +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 0 16px; } + +button, .button { + display: inline-block; padding: 7px 14px; border-radius: 6px; border: 1px solid var(--border); + background: var(--panel); color: var(--text); font: inherit; cursor: pointer; +} +button:hover, .button:hover { text-decoration: none; filter: brightness(0.97); } +button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +button.link { background: none; border: none; padding: 0; color: var(--muted); } +button.danger { background: var(--fail-bg); border-color: var(--fail-bg); color: var(--fail); } +button.link.danger { background: none; } +form.inline { display: inline; } + +.error { background: var(--fail-bg); color: var(--fail); padding: 10px 14px; border-radius: 6px; } +.warnings { background: var(--warn-bg); color: var(--warn); padding: 10px 14px; border-radius: 6px; margin: 12px 0; } +.warnings ul { margin: 8px 0 0; padding-left: 20px; font-size: 13px; } +.danger-zone { margin-top: 32px; color: var(--muted); } +.danger-zone form { margin-top: 12px; max-width: 420px; } +.danger-zone input { margin-bottom: 8px; } + +.cmd { display: flex; gap: 8px; align-items: flex-start; margin: 16px 0; } +.cmd pre { flex: 1; margin: 0; padding: 12px; background: var(--code-bg); border-radius: 6px; white-space: pre-wrap; word-break: break-all; } +pre.json { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; overflow-x: auto; } + +.login { margin: 10vh auto; } +.login form { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 20px; } + +@media (max-width: 640px) { + main { padding: 16px; } + .top .wrap { padding: 10px 16px; } + .cards { grid-template-columns: 1fr; } + .cmd { flex-direction: column; } +} diff --git a/internal/server/store.go b/internal/server/store.go index 673bd90..6e78e50 100644 --- a/internal/server/store.go +++ b/internal/server/store.go @@ -325,6 +325,25 @@ func (s *Store) ListBackups(clientID int64, status string) ([]*Backup, error) { return out, nil } +// LastSizes возвращает размер последнего успешного бэкапа по каждому клиенту одним запросом. +func (s *Store) LastSizes() (map[int64]int64, error) { + rows, err := s.db.Query(`SELECT client_id, total_size FROM backups b WHERE status = ?1 AND id = ( + SELECT id FROM backups WHERE client_id = b.client_id AND status = ?1 ORDER BY created_at DESC, id DESC LIMIT 1)`, StatusComplete) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[int64]int64{} + for rows.Next() { + var id, size int64 + if err := rows.Scan(&id, &size); err != nil { + return nil, err + } + out[id] = size + } + return out, rows.Err() +} + // LatestComplete возвращает последний успешный бэкап клиента. func (s *Store) LatestComplete(clientID int64) (*Backup, error) { var id int64 diff --git a/internal/server/templates/clients.html b/internal/server/templates/clients.html index 738847e..2ad9a33 100644 --- a/internal/server/templates/clients.html +++ b/internal/server/templates/clients.html @@ -1,15 +1,39 @@ {{define "title"}}Клиенты — backup-master{{end}} {{define "content"}} +{{$v := .V}}{{$q := .V.Query}}
-

Клиенты

+

Клиенты {{$v.Total}}

Добавить клиента
-{{if .Clients}} +{{if $v.Total}} +
+ + {{if $q.Status}}{{end}} + {{if ne $q.Sort "domain"}}{{end}} + {{with $q.DirParam}}{{end}} + + + {{if or $q.Q $q.Status}}Сбросить{{end}} +
+
+ {{range $v.Chips}}{{.Title}} {{.Count}}{{end}} +
+ +{{if $v.Rows}}
- + + + + + + + + - {{range .Clients}} + {{range $v.Rows}} @@ -22,6 +46,19 @@
ДоменНазваниеСтатусПоследний бэкапРазмерАгент на связи
Домен {{$q.SortMark "domain"}}Название {{$q.SortMark "name"}}Статус {{$q.SortMark "status"}}Последний бэкап {{$q.SortMark "last_backup"}}Размер {{$q.SortMark "size"}}Агент на связи {{$q.SortMark "last_seen"}}
{{.Domain}} {{if ne .Name .Domain}}{{.Name}}{{end}}
+
+ {{$v.From}}–{{$v.To}} из {{$v.Found}}{{if ne $v.Found $v.Total}} (всего {{$v.Total}}){{end}} + {{if $v.Pages}} +
+ {{if $v.Prev}}←{{end}} + {{range $v.Pages}}{{if .Gap}}…{{else if .Current}}{{.N}}{{else}}{{.N}}{{end}}{{end}} + {{if $v.Next}}→{{end}} +
+ {{end}} +
+{{else}} +

Ничего не найдено. Сбросить фильтры

+{{end}} {{else}}

Клиентов пока нет. Добавьте первый — получите команду установки агента.

{{end}} diff --git a/internal/server/templates/layout.html b/internal/server/templates/layout.html index 90c19a8..9c144b5 100644 --- a/internal/server/templates/layout.html +++ b/internal/server/templates/layout.html @@ -9,15 +9,17 @@ {{if ne .Page "login"}}
- backup-master - +
+ backup-master + +
{{end}}
diff --git a/internal/server/templates/settings.html b/internal/server/templates/settings.html index 330e3e4..a34a99f 100644 --- a/internal/server/templates/settings.html +++ b/internal/server/templates/settings.html @@ -13,29 +13,39 @@

Ни один канал уведомлений не настроен.

{{end}} {{end}} -
+ -

ntfy

- - - {{if .NtfyAuthSet}}{{end}} -

Пустой URL — ntfy выключен.

+
+
+

ntfy

+ + + {{if .NtfyAuthSet}}{{end}} +

Пустой URL — ntfy выключен.

+
-

Telegram

- - {{if .TGTokenSet}}{{end}} - -

Можно несколько через запятую. Личный чат — ваш числовой id (напишите боту и откройте https://api.telegram.org/bot<токен>/getUpdates), группа — id вида -100…, бот должен быть её участником.

- - -

Свой Bot API сервер или прокси, если api.telegram.org недоступен с сервера бэкапов.

+
+

Telegram

+ + {{if .TGTokenSet}}{{end}} + +

Можно несколько через запятую. Личный чат — ваш числовой id (напишите боту и откройте https://api.telegram.org/bot<токен>/getUpdates), группа — id вида -100…, бот должен быть её участником.

+
+ + +
+

Тема — для группы с темами. Свой Bot API сервер или прокси — если api.telegram.org недоступен с сервера бэкапов.

+
+
-

Что отправлять

- -

Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда, во все настроенные каналы.

- - +
+

Что отправлять

+ +

Ошибки бэкапа и отсутствие бэкапа дольше заданного у клиента срока приходят всегда, во все настроенные каналы.

+ + +

Логин и пароль администратора меняются на сервере: backupctl admin-passwd <логин>

{{end}} diff --git a/internal/server/web.go b/internal/server/web.go index 76f2422..0c18d17 100644 --- a/internal/server/web.go +++ b/internal/server/web.go @@ -122,24 +122,6 @@ type clientRow struct { LastSize int64 } -func (s *Server) webClients(w http.ResponseWriter, r *http.Request) { - clients, err := s.st.ListClients() - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - var rows []clientRow - for _, c := range clients { - row := clientRow{Client: c} - row.StatusClass, row.StatusText = clientStatus(c) - if b, err := s.st.LatestComplete(c.ID); err == nil { - row.LastSize = b.TotalSize - } - rows = append(rows, row) - } - s.render(w, r, "clients", map[string]any{"Clients": rows}) -} - func (s *Server) webClientNew(w http.ResponseWriter, r *http.Request) { s.render(w, r, "client_new", nil) }