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:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user