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"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.104"
|
var version = "1.1.105"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.104"
|
var version = "1.1.105"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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) {
|
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.GET("", h.List)
|
||||||
g.POST("", h.Create)
|
g.POST("", h.Create)
|
||||||
g.GET("/:id", h.Get)
|
g.GET("/:id", h.Get)
|
||||||
@@ -135,6 +140,68 @@ func (h *ForwardProxyHandler) Delete(c *gin.Context) {
|
|||||||
h.reload(c.Request.Context(), "delete")
|
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
|
// validateACL prüft Name (squid-konform), action, acl_type. Squid
|
||||||
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
|
// nimmt viele Typen — wir whitelisten die, die in einem Forward-
|
||||||
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
|
// Proxy-Setup üblich sind, damit Tippfehler nicht beim reload
|
||||||
|
|||||||
@@ -975,6 +975,18 @@
|
|||||||
"deleteConfirm": "ACL {{name}} wirklich löschen?",
|
"deleteConfirm": "ACL {{name}} wirklich löschen?",
|
||||||
"emptyTitle": "Noch keine Forward-Proxy-ACLs.",
|
"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.",
|
"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": {
|
"actions": {
|
||||||
"allow": "allow — Zugriff erlauben",
|
"allow": "allow — Zugriff erlauben",
|
||||||
"deny": "deny — Zugriff blockieren"
|
"deny": "deny — Zugriff blockieren"
|
||||||
|
|||||||
@@ -975,6 +975,18 @@
|
|||||||
"deleteConfirm": "Really delete ACL {{name}}?",
|
"deleteConfirm": "Really delete ACL {{name}}?",
|
||||||
"emptyTitle": "No forward-proxy ACLs yet.",
|
"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.",
|
"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": {
|
"actions": {
|
||||||
"allow": "allow — permit access",
|
"allow": "allow — permit access",
|
||||||
"deny": "deny — block access"
|
"deny": "deny — block access"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import {
|
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'
|
} from 'antd'
|
||||||
import type { ColumnsType } from 'antd/es/table'
|
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -52,6 +52,24 @@ async function listACLs(): Promise<ACL[]> {
|
|||||||
return (r.data.data as { acls?: ACL[] }).acls ?? []
|
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() {
|
export default function ForwardProxyPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -68,6 +86,18 @@ export default function ForwardProxyPage() {
|
|||||||
})
|
})
|
||||||
const squid = services?.find(s => s.unit === 'squid.service' || s.unit === 'squid')
|
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 [editing, setEditing] = useState<ACL | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
const [form] = Form.useForm<FormValues>()
|
const [form] = Form.useForm<FormValues>()
|
||||||
@@ -206,6 +236,54 @@ export default function ForwardProxyPage() {
|
|||||||
description={t('fwd.helpBody')}
|
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
|
<DataTable
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
|
|||||||
Reference in New Issue
Block a user