feat(fwd-proxy): Squid cache stats card — GET /forward-proxy/stats + squidclient mgr:counters

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-25 16:47:31 +02:00
parent f29d74bad7
commit 711c4c7eb1
8 changed files with 176 additions and 7 deletions

View File

@@ -1 +1 @@
1.1.104
1.1.105

View File

@@ -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")

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<ACL[]> {
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<ACL | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
@@ -206,6 +236,54 @@ export default function ForwardProxyPage() {
description={t('fwd.helpBody')}
/>
{statsData && (
<Card
size="small"
className="mb-12"
title={<><BarChartOutlined /> {t('fwd.statsCard.title')}</>}
extra={
<Space size={4}>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{t('fwd.statsCard.sinceRestart')}</Typography.Text>
<Tooltip title={t('common.refresh')}>
<Button size="small" icon={<ReloadOutlined />} onClick={() => { void refetchStats() }} />
</Tooltip>
</Space>
}
>
<Row gutter={[16, 8]}>
<Col xs={12} sm={6} md={4}>
<Statistic title={t('fwd.statsCard.clientRequests')} value={statsData.client_requests} />
</Col>
<Col xs={12} sm={6} md={4}>
<Statistic title={t('fwd.statsCard.cacheHits')} value={statsData.cache_hits} />
</Col>
<Col xs={12} sm={6} md={4}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{t('fwd.statsCard.cacheHitPct')}</Typography.Text>
<Progress
percent={Math.round(statsData.cache_hit_pct)}
size="small"
strokeColor={statsData.cache_hit_pct >= 50 ? '#16a34a' : statsData.cache_hit_pct >= 20 ? '#ca8a04' : '#dc2626'}
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Statistic title={t('fwd.statsCard.serverRequests')} value={statsData.server_requests} />
</Col>
<Col xs={12} sm={6} md={4}>
<Statistic title={t('fwd.statsCard.bytesIn')} value={fmtBytes(statsData.bytes_in)} />
</Col>
<Col xs={12} sm={6} md={4}>
<Statistic title={t('fwd.statsCard.bytesOut')} value={fmtBytes(statsData.bytes_out)} />
</Col>
</Row>
{statsData.client_errors > 0 && (
<Typography.Text type="danger" style={{ fontSize: 12 }}>
{t('fwd.statsCard.clientErrors')}: {statsData.client_errors}
{statsData.server_errors > 0 && ` · ${t('fwd.statsCard.serverErrors')}: ${statsData.server_errors}`}
</Typography.Text>
)}
</Card>
)}
<DataTable
rowKey="id"
loading={isLoading}