From 711c4c7eb1297aa427a9bab4333aead340b803c4 Mon Sep 17 00:00:00 2001 From: Debian Date: Mon, 25 May 2026 16:47:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(fwd-proxy):=20Squid=20cache=20stats=20card?= =?UTF-8?q?=20=E2=80=94=20GET=20/forward-proxy/stats=20+=20squidclient=20m?= =?UTF-8?q?gr:counters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- VERSION | 2 +- cmd/edgeguard-api/main.go | 2 +- cmd/edgeguard-ctl/main.go | 2 +- cmd/edgeguard-scheduler/main.go | 2 +- internal/handlers/forwardproxy.go | 69 +++++++++++++++- management-ui/src/i18n/locales/de/common.json | 12 +++ management-ui/src/i18n/locales/en/common.json | 12 +++ .../src/pages/ForwardProxy/index.tsx | 82 ++++++++++++++++++- 8 files changed, 176 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index 872d3b2..d0ab9f9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.104 +1.1.105 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index f908fc5..1c007fb 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.104" +var version = "1.1.105" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index ba5e0f9..26a61b6 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.104" +var version = "1.1.105" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index ad62666..4d75455 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -35,7 +35,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.104" +var version = "1.1.105" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/handlers/forwardproxy.go b/internal/handlers/forwardproxy.go index 494aeb9..46a2f43 100644 --- a/internal/handlers/forwardproxy.go +++ b/internal/handlers/forwardproxy.go @@ -4,7 +4,9 @@ import ( "context" "errors" "log/slog" + "os/exec" "strconv" + "strings" "github.com/gin-gonic/gin" @@ -35,7 +37,10 @@ func (h *ForwardProxyHandler) reload(ctx context.Context, op string) { } func (h *ForwardProxyHandler) Register(rg *gin.RouterGroup) { - g := rg.Group("/forward-proxy/acls") + base := rg.Group("/forward-proxy") + base.GET("/stats", h.Stats) + + g := base.Group("/acls") g.GET("", h.List) g.POST("", h.Create) g.GET("/:id", h.Get) @@ -135,6 +140,68 @@ func (h *ForwardProxyHandler) Delete(c *gin.Context) { h.reload(c.Request.Context(), "delete") } +// Stats liefert Squid-Cache-Statistiken via `squidclient mgr:counters`. +// Die Ausgabe enthält HTTP-Header gefolgt von key = value Zeilen. +func (h *ForwardProxyHandler) Stats(c *gin.Context) { + out, err := exec.Command("squidclient", "-h", "127.0.0.1", "-p", "3128", "mgr:counters").Output() + if err != nil { + response.OK(c, gin.H{ + "error": "squidclient nicht verfügbar: " + err.Error(), + }) + return + } + response.OK(c, gin.H{"stats": parseSquidCounters(string(out))}) +} + +type squidStats struct { + ClientRequests int64 `json:"client_requests"` + CacheHits int64 `json:"cache_hits"` + CacheHitPct float64 `json:"cache_hit_pct"` + ClientErrors int64 `json:"client_errors"` + BytesIn int64 `json:"bytes_in"` + BytesOut int64 `json:"bytes_out"` + ServerRequests int64 `json:"server_requests"` + ServerErrors int64 `json:"server_errors"` +} + +func parseSquidCounters(out string) squidStats { + // squidclient prefixes an HTTP response header block — skip it. + body := out + if idx := strings.Index(out, "\r\n\r\n"); idx >= 0 { + body = out[idx+4:] + } else if idx := strings.Index(out, "\n\n"); idx >= 0 { + body = out[idx+2:] + } + s := squidStats{} + for _, line := range strings.Split(body, "\n") { + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + switch strings.TrimSpace(k) { + case "client_http.requests": + s.ClientRequests = n + case "client_http.hits": + s.CacheHits = n + case "client_http.errors": + s.ClientErrors = n + case "client_http.kbytes_in": + s.BytesIn = n * 1024 + case "client_http.kbytes_out": + s.BytesOut = n * 1024 + case "server.all.requests": + s.ServerRequests = n + case "server.all.errors": + s.ServerErrors = n + } + } + if s.ClientRequests > 0 { + s.CacheHitPct = float64(s.CacheHits) / float64(s.ClientRequests) * 100 + } + return s +} + // validateACL prüft Name (squid-konform), action, acl_type. Squid // nimmt viele Typen — wir whitelisten die, die in einem Forward- // Proxy-Setup üblich sind, damit Tippfehler nicht beim reload diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index af66b6e..fd8c975 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -975,6 +975,18 @@ "deleteConfirm": "ACL {{name}} wirklich löschen?", "emptyTitle": "Noch keine Forward-Proxy-ACLs.", "emptyDesc": "Default ohne ACLs: nur localnet (10/8, 172.16/12, 192.168/16) darf raus. Lege eine ACL an, um spezifische Domains/IPs/Ports gezielt zu erlauben oder zu blocken.", + "statsCard": { + "title": "Squid-Cache-Statistiken", + "clientRequests": "Client-Anfragen", + "cacheHits": "Cache-Treffer", + "cacheHitPct": "Trefferquote", + "clientErrors": "Client-Fehler", + "bytesIn": "Empfangen", + "bytesOut": "Gesendet", + "serverRequests": "Backend-Anfragen", + "serverErrors": "Backend-Fehler", + "sinceRestart": "Seit letztem Neustart" + }, "actions": { "allow": "allow — Zugriff erlauben", "deny": "deny — Zugriff blockieren" diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index ef8b3ee..25d223e 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -975,6 +975,18 @@ "deleteConfirm": "Really delete ACL {{name}}?", "emptyTitle": "No forward-proxy ACLs yet.", "emptyDesc": "Default with no ACLs: only localnet (10/8, 172.16/12, 192.168/16) is allowed out. Add an ACL to selectively allow or block specific domains/IPs/ports.", + "statsCard": { + "title": "Squid cache statistics", + "clientRequests": "Client requests", + "cacheHits": "Cache hits", + "cacheHitPct": "Hit rate", + "clientErrors": "Client errors", + "bytesIn": "Bytes received", + "bytesOut": "Bytes sent", + "serverRequests": "Backend requests", + "serverErrors": "Backend errors", + "sinceRestart": "Since last restart" + }, "actions": { "allow": "allow — permit access", "deny": "deny — block access" diff --git a/management-ui/src/pages/ForwardProxy/index.tsx b/management-ui/src/pages/ForwardProxy/index.tsx index 842f822..9bf6874 100644 --- a/management-ui/src/pages/ForwardProxy/index.tsx +++ b/management-ui/src/pages/ForwardProxy/index.tsx @@ -1,9 +1,9 @@ import { useMemo, useState } from 'react' import { - Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message, + Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, } from 'antd' import type { ColumnsType } from 'antd/es/table' -import { ArrowDownOutlined, ArrowUpOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined } from '@ant-design/icons' +import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' @@ -52,6 +52,24 @@ async function listACLs(): Promise { return (r.data.data as { acls?: ACL[] }).acls ?? [] } +interface SquidStats { + client_requests: number + cache_hits: number + cache_hit_pct: number + client_errors: number + bytes_in: number + bytes_out: number + server_requests: number + server_errors: number +} + +function fmtBytes(n: number): string { + if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB' + if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB' + if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB' + return n + ' B' +} + export default function ForwardProxyPage() { const { t } = useTranslation() const qc = useQueryClient() @@ -68,6 +86,18 @@ export default function ForwardProxyPage() { }) const squid = services?.find(s => s.unit === 'squid.service' || s.unit === 'squid') + const { data: statsData, refetch: refetchStats } = useQuery({ + queryKey: ['fwd-proxy', 'stats'], + queryFn: async () => { + const r = await apiClient.get('/forward-proxy/stats') + if (!isEnvelope(r.data)) return null + const d = r.data.data as { stats?: SquidStats; error?: string } + if (d.error) return null + return d.stats ?? null + }, + refetchInterval: 30_000, + }) + const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() @@ -206,6 +236,54 @@ export default function ForwardProxyPage() { description={t('fwd.helpBody')} /> + {statsData && ( + {t('fwd.statsCard.title')}} + extra={ + + {t('fwd.statsCard.sinceRestart')} + +