merge feat/probe-b6: item 7 B1-B6 (probe collector, history, contract API, ping, themes, notify)

# Conflicts:
#	internal/agent/agent.go
This commit is contained in:
Muse
2026-10-23 00:37:55 +00:00
341 changed files with 16398 additions and 18 deletions
+3
View File
@@ -19,6 +19,9 @@ backup/
bin/
x-ui/
dist/
# Exception: the vendored embedded fallback theme ships its dist/ as
# source (go:embed) — it is not a build output.
!/internal/web/service/probe_theme_default/dist/
release/
node_modules/
+4
View File
@@ -127,6 +127,10 @@ async function performFetch(
if (data instanceof FormData) {
body = data;
headers.delete('Content-Type');
} else if (data instanceof Blob || data instanceof ArrayBuffer) {
// Raw payload (e.g. one chunk of a theme upload): sent as-is.
body = data as BodyInit;
if (!headers.has('Content-Type')) headers.set('Content-Type', 'application/octet-stream');
} else if (!SAFE_METHODS.has(upper)) {
const declaredType = (headers.get('Content-Type') || '').toLowerCase();
if (declaredType.startsWith('application/json')) {
+5
View File
@@ -248,6 +248,11 @@ export default function AppSidebar() {
icon: <CloudServerOutlined />,
label: t('pages.settings.subSettings'),
},
{
key: '/settings#public-page',
icon: <GlobalOutlined />,
label: t('pages.settings.probeThemes.title'),
},
];
if (showSubFormats) {
children.push({
@@ -21,6 +21,7 @@ import { NodeFormSchema, type NodeFormValues, type ProbeResult } from '@/schemas
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { useOutboundTagGroups } from '@/api/queries/useOutboundTags';
import { currentPanelMasterUrl } from './agentMasterUrl';
import ProbeMetaSection from './ProbeMetaSection';
import './NodeFormModal.css';
type Mode = 'add' | 'edit';
@@ -472,6 +473,10 @@ export default function NodeFormModal({
</div>
)}
{mode === 'edit' && isAgent && node?.id ? (
<ProbeMetaSection nodeId={node.id} />
) : null}
{!isAgent && (
<FormField
label={t('pages.nodes.allowPrivateAddress')}
+6
View File
@@ -27,6 +27,7 @@ import {
InfoCircleOutlined,
MoreOutlined,
PlusOutlined,
RadarChartOutlined,
RightOutlined,
SafetyCertificateOutlined,
TeamOutlined,
@@ -48,6 +49,7 @@ interface NodeListProps {
onSelectionChange: (ids: number[]) => void;
onAdd: () => void;
onMtls: () => void;
onProbeTasks: () => void;
onEdit: (node: NodeRecord) => void;
onDelete: (node: NodeRecord) => void;
onProbe: (node: NodeRecord) => void;
@@ -218,6 +220,7 @@ export default function NodeList({
onSelectionChange,
onAdd,
onMtls,
onProbeTasks,
onEdit,
onDelete,
onProbe,
@@ -618,6 +621,9 @@ export default function NodeList({
<Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
{t('pages.nodes.mtls.title')}
</Button>
<Button icon={<RadarChartOutlined />} onClick={onProbeTasks}>
{t('pages.nodes.probeTasks.title')}
</Button>
{selectedIds.length > 0 && (
<Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
{t('pages.nodes.updateSelected', { count: selectedIds.length })}
+9
View File
@@ -33,6 +33,7 @@ import { useNodeMutations } from '@/api/queries/useNodeMutations';
import AppSidebar from '@/layouts/AppSidebar';
import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal';
import ProbeTasksModal from './ProbeTasksModal';
import { setMessageInstance } from '@/utils/messageBus';
import { HttpUtil } from '@/utils';
import type { PanelUpdateInfo } from '../index/PanelUpdateModal';
@@ -104,6 +105,7 @@ export default function NodesPage() {
const [formNode, setFormNode] = useState<NodeRecord | null>(null);
const [selectedIds, setSelectedIds] = useState<number[]>([]);
const [mtlsOpen, setMtlsOpen] = useState(false);
const [probeTasksOpen, setProbeTasksOpen] = useState(false);
const [trustCa, setTrustCa] = useState('');
const [copyingCa, setCopyingCa] = useState(false);
const [savingTrustCa, setSavingTrustCa] = useState(false);
@@ -367,6 +369,7 @@ export default function NodesPage() {
onSelectionChange={setSelectedIds}
onAdd={onAdd}
onMtls={() => setMtlsOpen(true)}
onProbeTasks={() => setProbeTasksOpen(true)}
onEdit={onEdit}
onDelete={onDelete}
onProbe={onProbe}
@@ -381,6 +384,12 @@ export default function NodesPage() {
</Layout.Content>
</Layout>
<ProbeTasksModal
open={probeTasksOpen}
onClose={() => setProbeTasksOpen(false)}
nodes={nodes}
/>
<NodeFormModal
open={formOpen}
mode={formMode}
@@ -0,0 +1,262 @@
import { useEffect, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, Input, InputNumber, Row, Select, Switch, message } from 'antd';
import { HttpUtil } from '@/utils';
// Item 7 B3: probe/public-page metadata for an agent node, edited in
// place from the node edit dialog. The section is deliberately outside
// the node form: it loads and saves on its own (dedicated endpoints,
// effective immediately on save — no node save, no restart).
interface ProbeMetaWire {
price: number;
currency: string;
billingCycle: string;
expiresAt: string | null;
trafficLimit: number; // bytes
trafficMode: string;
trafficResetDay: number;
public: boolean;
sort: number;
group: string;
publicRemark: string;
notify: boolean;
countryPin: string;
ipv4Pin: string;
ipv6Pin: string;
}
const NAMED_CYCLES = [
'once',
'monthly',
'quarterly',
'semiannual',
'yearly',
'biennial',
'triennial',
] as const;
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>{label}</div>
{children}
</div>
);
}
export default function ProbeMetaSection({ nodeId }: { nodeId: number }) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
// Loaded meta, keyed by node id so a stale result for a previously
// edited node is never shown (same discipline as the install block
// in NodeFormModal); visibility derives during render.
const [loaded, setLoaded] = useState<{ nodeId: number; meta: ProbeMetaWire } | null>(null);
const [trafficGb, setTrafficGb] = useState(0);
const [saving, setSaving] = useState(false);
useEffect(() => {
let cancelled = false;
HttpUtil.get<ProbeMetaWire>(`/panel/api/agent/probe-meta/${nodeId}`, {
silent: true,
}).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setLoaded({ nodeId, meta: msg.obj });
setTrafficGb(msg.obj.trafficLimit / 1e9);
}
});
return () => {
cancelled = true;
};
}, [nodeId]);
const meta = loaded && loaded.nodeId === nodeId ? loaded.meta : null;
if (!meta) return null;
const set = <K extends keyof ProbeMetaWire>(key: K, value: ProbeMetaWire[K]) =>
setLoaded((l) => (l ? { nodeId: l.nodeId, meta: { ...l.meta, [key]: value } } : l));
const cycleLabel = (cycle: string) => {
switch (cycle) {
case 'once':
return t('pages.nodes.probe.cycleOnce');
case 'monthly':
return t('pages.nodes.probe.cycleMonthly');
case 'quarterly':
return t('pages.nodes.probe.cycleQuarterly');
case 'semiannual':
return t('pages.nodes.probe.cycleSemiannual');
case 'yearly':
return t('pages.nodes.probe.cycleYearly');
case 'biennial':
return t('pages.nodes.probe.cycleBiennial');
case 'triennial':
return t('pages.nodes.probe.cycleTriennial');
default:
return cycle;
}
};
const cycleOptions: { value: string; label: string }[] = NAMED_CYCLES.map((cycle) => ({
value: cycle,
label: cycleLabel(cycle),
}));
if (!NAMED_CYCLES.includes(meta.billingCycle as (typeof NAMED_CYCLES)[number])) {
cycleOptions.push({ value: meta.billingCycle, label: meta.billingCycle });
}
async function onSave() {
if (!meta) return;
setSaving(true);
try {
const payload: ProbeMetaWire = {
...meta,
trafficLimit: Math.round(trafficGb * 1e9),
};
const msg = await HttpUtil.put(`/panel/api/agent/probe-meta/${nodeId}`, payload, {
silent: true,
});
if (msg?.success) {
setLoaded({ nodeId, meta: payload });
messageApi.success(t('pages.nodes.probe.saved'));
} else {
messageApi.error(msg?.msg || t('pages.nodes.probe.saveFailed'));
}
} finally {
setSaving(false);
}
}
return (
<div style={{ marginBottom: 16 }}>
{messageContextHolder}
<div style={{ fontWeight: 600, marginBottom: 4 }}>{t('pages.nodes.probe.title')}</div>
<p style={{ marginBottom: 12 }}>{t('pages.nodes.probe.hint')}</p>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.public')}>
<Switch checked={meta.public} onChange={(v) => set('public', v)} />
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.notify')}>
<Switch checked={meta.notify} onChange={(v) => set('notify', v)} />
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.group')}>
<Input value={meta.group} onChange={(e) => set('group', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.sort')}>
<InputNumber
value={meta.sort}
onChange={(v) => set('sort', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Field label={t('pages.nodes.probe.publicRemark')}>
<Input value={meta.publicRemark} onChange={(e) => set('publicRemark', e.target.value)} />
</Field>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.price')}>
<InputNumber
value={meta.price}
min={0}
onChange={(v) => set('price', v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.currency')}>
<Input value={meta.currency} onChange={(e) => set('currency', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.billingCycle')}>
<Select
value={meta.billingCycle}
options={cycleOptions}
onChange={(v) => set('billingCycle', v)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.expiresAt')}>
<Input
value={meta.expiresAt ?? ''}
placeholder="YYYY-MM-DD"
onChange={(e) => set('expiresAt', e.target.value || null)}
/>
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.trafficResetDay')}>
<InputNumber
value={meta.trafficResetDay}
min={1}
max={31}
onChange={(v) => set('trafficResetDay', v ?? 1)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.trafficLimit')}>
<InputNumber
value={trafficGb}
min={0}
onChange={(v) => setTrafficGb(v ?? 0)}
style={{ width: '100%' }}
/>
</Field>
</Col>
<Col xs={24} md={12}>
<Field label={t('pages.nodes.probe.trafficMode')}>
<Select
value={meta.trafficMode}
options={[
{ value: 'sum', label: t('pages.nodes.probe.modeSum') },
{ value: 'up', label: t('pages.nodes.probe.modeUp') },
{ value: 'down', label: t('pages.nodes.probe.modeDown') },
{ value: 'max', label: t('pages.nodes.probe.modeMax') },
]}
onChange={(v) => set('trafficMode', v)}
style={{ width: '100%' }}
/>
</Field>
</Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.countryPin')}>
<Input value={meta.countryPin} onChange={(e) => set('countryPin', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.ipv4Pin')}>
<Input value={meta.ipv4Pin} onChange={(e) => set('ipv4Pin', e.target.value)} />
</Field>
</Col>
<Col xs={24} md={8}>
<Field label={t('pages.nodes.probe.ipv6Pin')}>
<Input value={meta.ipv6Pin} onChange={(e) => set('ipv6Pin', e.target.value)} />
</Field>
</Col>
</Row>
<Button type="primary" loading={saving} onClick={onSave}>
{t('pages.nodes.probe.save')}
</Button>
</div>
);
}
@@ -0,0 +1,331 @@
import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Typography,
message,
} from 'antd';
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { HttpUtil } from '@/utils';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
// Item 7 B4: probe (latency) task management. The operator maintains
// the target set here — grouped by the free-form region label, no
// preset list (user ruling Q4). Like ProbeMetaSection the surface is
// self-contained: it loads and saves on its own through the dedicated
// endpoints, effective immediately (the master pushes the set on each
// agent's next config poll; nothing restarts).
interface ProbeTaskWire {
id: number;
name: string;
target: string;
interval: number;
autoJoin: boolean;
sort: number;
region: string;
nodeIds: number[];
}
interface TaskDraft {
id: number; // 0 = new task
name: string;
target: string;
interval: number;
autoJoin: boolean;
region: string;
nodeIds: number[];
baseNodeIds: number[] | null; // editor-open snapshot (existing tasks)
}
const TASKS_URL = '/panel/api/agent/probe-tasks';
export default function ProbeTasksModal({
open,
onClose,
nodes,
}: {
open: boolean;
onClose: () => void;
nodes: NodeRecord[];
}) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [tasks, setTasks] = useState<ProbeTaskWire[] | null>(null);
const [draft, setDraft] = useState<TaskDraft | null>(null);
const [saving, setSaving] = useState(false);
const agentNodes = useMemo(() => nodes.filter((n) => n.nodeType === 'agent'), [nodes]);
const nodeName = useCallback(
(id: number) => nodes.find((n) => n.id === id)?.name ?? `#${id}`,
[nodes],
);
const load = useCallback(async () => {
const msg = await HttpUtil.get<ProbeTaskWire[]>(TASKS_URL, { silent: true });
if (msg?.success && msg.obj) {
setTasks(msg.obj);
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.loadFailed'));
}
}, [messageApi, t]);
// Load on open (afterOpenChange is an event callback, so resetting
// the previous session's state here is not an effect write).
const onAfterOpenChange = (opened: boolean) => {
if (opened) {
setTasks(null);
setDraft(null);
void load();
}
};
// Group by region: region labels ascending, the ungrouped bucket
// last; inside a group the panel order (sort) rules.
const groups = useMemo(() => {
const byRegion = new Map<string, ProbeTaskWire[]>();
for (const task of tasks ?? []) {
const key = task.region ?? '';
const list = byRegion.get(key) ?? [];
list.push(task);
byRegion.set(key, list);
}
return [...byRegion.entries()]
.sort(([a], [b]) => {
if (a === b) return 0;
if (a === '') return 1;
if (b === '') return -1;
return a < b ? -1 : 1;
})
.map(([region, list]) => ({ region, list }));
}, [tasks]);
const startCreate = () =>
setDraft({
id: 0,
name: '',
target: '',
interval: 60,
autoJoin: false,
region: '',
nodeIds: [],
baseNodeIds: null,
});
const startEdit = (task: ProbeTaskWire) =>
setDraft({
id: task.id,
name: task.name,
target: task.target,
interval: task.interval,
autoJoin: task.autoJoin,
region: task.region ?? '',
nodeIds: [...task.nodeIds],
baseNodeIds: [...task.nodeIds],
});
async function onSave() {
if (!draft) return;
setSaving(true);
try {
const payload: Record<string, unknown> = {
id: draft.id,
name: draft.name,
target: draft.target,
interval: draft.interval,
autoJoin: draft.autoJoin,
region: draft.region,
nodeIds: draft.nodeIds,
};
if (draft.baseNodeIds) payload.baseNodeIds = draft.baseNodeIds;
const msg = await HttpUtil.put<{ id: number }>(TASKS_URL, payload, { silent: true });
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.saved'));
setDraft(null);
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
} finally {
setSaving(false);
}
}
async function onDelete(task: ProbeTaskWire) {
const msg = await HttpUtil.delete(`${TASKS_URL}/${task.id}`, { silent: true });
if (msg?.success) {
messageApi.success(t('pages.nodes.probeTasks.deleted'));
if (draft?.id === task.id) setDraft(null);
await load();
} else {
messageApi.error(msg?.msg || t('pages.nodes.probeTasks.saveFailed'));
}
}
const columns: ColumnsType<ProbeTaskWire> = [
{ title: t('pages.nodes.probeTasks.name'), dataIndex: 'name', key: 'name' },
{
title: t('pages.nodes.probeTasks.target'),
dataIndex: 'target',
key: 'target',
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{
title: t('pages.nodes.probeTasks.interval'),
dataIndex: 'interval',
key: 'interval',
width: 90,
render: (v: number) => `${v}s`,
},
{
title: t('pages.nodes.probeTasks.autoJoin'),
dataIndex: 'autoJoin',
key: 'autoJoin',
width: 90,
render: (v: boolean) =>
v ? <Tag color="blue">{t('pages.nodes.probeTasks.autoJoinOn')}</Tag> : null,
},
{
title: t('pages.nodes.probeTasks.nodes'),
dataIndex: 'nodeIds',
key: 'nodeIds',
render: (ids: number[]) => <span title={ids.map(nodeName).join(', ')}>{ids.length}</span>,
},
{
title: t('pages.nodes.probeTasks.actions'),
key: 'actions',
width: 110,
render: (_, task) => (
<Space size="small">
<Button size="small" icon={<EditOutlined />} onClick={() => startEdit(task)}>
{t('pages.nodes.probeTasks.edit')}
</Button>
<Popconfirm
title={t('pages.nodes.probeTasks.deleteConfirm')}
onConfirm={() => void onDelete(task)}
okText={t('pages.nodes.probeTasks.delete')}
cancelText={t('pages.nodes.probeTasks.cancel')}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const setDraftField = <K extends keyof TaskDraft>(key: K, value: TaskDraft[K]) =>
setDraft((d) => (d ? { ...d, [key]: value } : d));
return (
<Modal
open={open}
title={t('pages.nodes.probeTasks.title')}
onCancel={onClose}
afterOpenChange={onAfterOpenChange}
footer={null}
width={780}
destroyOnHidden
>
{messageContextHolder}
<Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
{t('pages.nodes.probeTasks.intro')}
</Typography.Paragraph>
{tasks === null ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.loading')}</Typography.Text>
) : tasks.length === 0 && !draft ? (
<Typography.Text type="secondary">{t('pages.nodes.probeTasks.empty')}</Typography.Text>
) : (
groups.map(({ region, list }) => (
<div key={region || '__ungrouped'} style={{ marginBottom: 16 }}>
<Typography.Text strong>
{region || t('pages.nodes.probeTasks.ungrouped')}
</Typography.Text>
<Table<ProbeTaskWire>
size="small"
rowKey="id"
columns={columns}
dataSource={list}
pagination={false}
style={{ marginTop: 6 }}
/>
</div>
))
)}
{draft ? (
<div
style={{
borderTop: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
paddingTop: 12,
marginTop: 4,
}}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Space wrap>
<Input
placeholder={t('pages.nodes.probeTasks.name')}
value={draft.name}
onChange={(e) => setDraftField('name', e.target.value)}
style={{ width: 180 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.region')}
value={draft.region}
onChange={(e) => setDraftField('region', e.target.value)}
style={{ width: 130 }}
/>
<Input
placeholder={t('pages.nodes.probeTasks.targetPlaceholder')}
value={draft.target}
onChange={(e) => setDraftField('target', e.target.value)}
style={{ width: 230, fontFamily: 'monospace' }}
/>
<InputNumber
min={5}
max={3600}
value={draft.interval}
onChange={(v) => setDraftField('interval', v ?? 60)}
addonAfter="s"
style={{ width: 120 }}
/>
</Space>
<Space wrap>
<span>
{t('pages.nodes.probeTasks.autoJoin')}{' '}
<Switch checked={draft.autoJoin} onChange={(v) => setDraftField('autoJoin', v)} />
</span>
<Select
mode="multiple"
placeholder={t('pages.nodes.probeTasks.nodesPlaceholder')}
value={draft.nodeIds}
onChange={(v) => setDraftField('nodeIds', v)}
options={agentNodes.map((n) => ({ value: n.id, label: n.name }))}
style={{ minWidth: 260 }}
/>
</Space>
<Space>
<Button type="primary" loading={saving} onClick={() => void onSave()}>
{t('pages.nodes.probeTasks.save')}
</Button>
<Button onClick={() => setDraft(null)}>{t('pages.nodes.probeTasks.cancel')}</Button>
</Space>
</Space>
</div>
) : (
<Button icon={<PlusOutlined />} onClick={startCreate} style={{ marginTop: 8 }}>
{t('pages.nodes.probeTasks.add')}
</Button>
)}
</Modal>
);
}
@@ -0,0 +1,908 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Button,
Card,
Col,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Select,
Space,
Switch,
Tag,
Typography,
message,
} from 'antd';
import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { onNumber } from '@/utils/onNumber';
// Item 7 B5: the public status page's settings + theme store. Like
// the probe-meta section this surface is self-contained — it loads
// and saves through its own endpoints and every change takes effect
// immediately (no panel save, no restart). Theme packages are static
// files served by the panel; installing one never runs its code here.
interface ThemeConfigField {
key: string;
type: string;
label: string;
default?: unknown;
help?: string;
options?: { value: string; label: string }[];
}
interface ThemeWire {
name: string;
short: string;
description: string;
version: string;
author: string;
url: string;
config?: ThemeConfigField[];
selected: boolean;
builtin: boolean;
preview: boolean;
}
interface ProbeSettingsWire {
publicPage: boolean;
siteName: string;
site: string;
githubProxy: string;
}
interface ThemesPayload {
themes: ThemeWire[];
settings: ProbeSettingsWire;
favicon: string;
touchIcon: string;
}
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
// The webhook URL and headers are secrets: the GET answers only
// whether they are set, and the PUT omits them unless the admin
// edited the field (an explicit clear sends "").
interface NotifySettingsWire {
notifyGrace: number;
notifyTraffic: number;
notifyExpiry: number;
notifyLogin: boolean;
notifyTelegramText: string;
notifyWebhookBody: string;
telegramConfigured: boolean;
webhookConfigured: boolean;
notifyWebhookUrlSet: boolean;
notifyWebhookHeadersSet: boolean;
}
interface CoverageRow {
name: string;
online: boolean;
hasProbe: boolean;
probeAt: number;
}
interface NotifyPayload {
settings: NotifySettingsWire;
coverage: CoverageRow[];
}
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
const UPLOAD_CHUNK = 1 << 20;
const MAX_ICON_BYTES = 20 * 1024;
function basePathPrefix(): string {
const bp = window.X_UI_BASE_PATH;
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
}
function readAsDataURL(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
// icon budget: small files pass through untouched (the server judges
// the bytes, not the declared type); a large raster is scaled down
// until it fits; a large SVG cannot be scaled and is refused here.
async function fileToIconDataURL(file: File): Promise<string> {
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
throw new Error('tooLarge');
}
const bitmap = await createImageBitmap(file);
try {
for (const size of [256, 180, 128, 96, 64]) {
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
const ctx = canvas.getContext('2d');
if (!ctx) break;
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
}
} finally {
bitmap.close();
}
throw new Error('tooLarge');
}
export default function PublicPageTab() {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [themes, setThemes] = useState<ThemeWire[]>([]);
const [settings, setSettings] = useState<ProbeSettingsWire>({
publicPage: false,
siteName: 'Monitor',
site: '',
githubProxy: '',
});
const [favicon, setFavicon] = useState('');
const [touchIcon, setTouchIcon] = useState('');
const [saving, setSaving] = useState(false);
const [installURL, setInstallURL] = useState('');
const [installing, setInstalling] = useState(false);
const [uploadPct, setUploadPct] = useState<number | null>(null);
const [busyShort, setBusyShort] = useState('');
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
const [coverage, setCoverage] = useState<CoverageRow[]>([]);
const [webhookUrl, setWebhookUrl] = useState('');
const [webhookHeaders, setWebhookHeaders] = useState('');
const [urlTouched, setUrlTouched] = useState(false);
const [headersTouched, setHeadersTouched] = useState(false);
const [notifySaving, setNotifySaving] = useState(false);
const [notifyTesting, setNotifyTesting] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const faviconRef = useRef<HTMLInputElement>(null);
const touchRef = useRef<HTMLInputElement>(null);
const applyPayload = useCallback((payload: ThemesPayload) => {
setThemes(payload.themes ?? []);
setSettings(payload.settings);
setFavicon(payload.favicon ?? '');
setTouchIcon(payload.touchIcon ?? '');
}, []);
const reload = useCallback(async () => {
const msg = await HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true });
if (msg?.success && msg.obj) applyPayload(msg.obj);
}, [applyPayload]);
useEffect(() => {
let cancelled = false;
HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) applyPayload(msg.obj);
});
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
if (!cancelled && msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
}
});
return () => {
cancelled = true;
};
}, [applyPayload]);
async function saveNotify() {
if (!notify) return;
setNotifySaving(true);
try {
const body: Record<string, unknown> = {
notifyGrace: notify.notifyGrace,
notifyTraffic: notify.notifyTraffic,
notifyExpiry: notify.notifyExpiry,
notifyLogin: notify.notifyLogin,
notifyTelegramText: notify.notifyTelegramText,
notifyWebhookBody: notify.notifyWebhookBody,
};
// Secrets ride along only when the field was edited; an
// edited-but-empty field clears the stored value server-side.
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
silent: true,
...JSON_HEADERS,
});
if (msg?.success && msg.obj) {
setNotify(msg.obj.settings);
setCoverage(msg.obj.coverage ?? []);
setWebhookUrl('');
setWebhookHeaders('');
setUrlTouched(false);
setHeadersTouched(false);
messageApi.success(t('pages.settings.probeThemes.saved'));
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setNotifySaving(false);
}
}
async function testNotify() {
setNotifyTesting(true);
try {
const msg = await HttpUtil.post<{ sent: string[] }>('/panel/api/probe/notify/test', undefined, {
silent: true,
});
if (msg?.success) {
messageApi.success(
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
);
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setNotifyTesting(false);
}
}
async function saveSettings() {
setSaving(true);
try {
const msg = await HttpUtil.put(
'/panel/api/probe/settings',
{ ...settings, favicon, touchIcon },
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
} finally {
setSaving(false);
}
}
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
if (!file) return;
try {
const url = await fileToIconDataURL(file);
if (which === 'favicon') setFavicon(url);
else setTouchIcon(url);
} catch {
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
}
}
async function uploadTheme(file: File | undefined) {
if (!file) return;
if (file.size === 0) {
messageApi.error(t('pages.settings.probeThemes.uploadFailed'));
return;
}
setUploadPct(0);
try {
let offset = 0;
while (offset < file.size) {
const slice = file.slice(offset, offset + UPLOAD_CHUNK);
const msg = await HttpUtil.post<{ received?: number; theme?: ThemeWire }>(
'/panel/api/probe/theme-upload',
slice,
{
silent: true,
params: { offset, total: file.size },
headers: { 'Content-Type': 'application/octet-stream' },
},
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.uploadFailed'));
return;
}
if (msg.obj?.theme) {
messageApi.success(
t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }),
);
await reload();
return;
}
offset = msg.obj?.received ?? offset + slice.size;
setUploadPct(Math.round((offset / file.size) * 100));
}
} finally {
setUploadPct(null);
if (fileRef.current) fileRef.current.value = '';
}
}
async function installByURL() {
setInstalling(true);
try {
const msg = await HttpUtil.post<{ theme: ThemeWire }>(
'/panel/api/probe/theme-install',
{ url: installURL },
{ silent: true, ...JSON_HEADERS },
);
if (msg?.success && msg.obj?.theme) {
messageApi.success(t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }));
setInstallURL('');
await reload();
} else {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.installFailed'));
}
} finally {
setInstalling(false);
}
}
async function themeAction(short: string, action: 'select' | 'update' | 'delete') {
setBusyShort(short);
try {
if (action === 'delete') {
const msg = await HttpUtil.delete(`/panel/api/probe/themes/${short}`, { silent: true });
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
return;
}
} else {
const msg = await HttpUtil.post<{ updated?: boolean; version?: string }>(
`/panel/api/probe/themes/${short}/${action}`,
undefined,
{ silent: true },
);
if (!msg?.success) {
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
return;
}
if (action === 'update') {
if (msg.obj?.updated) {
messageApi.success(
t('pages.settings.probeThemes.updatedTo', { version: msg.obj.version }),
);
} else {
messageApi.success(t('pages.settings.probeThemes.alreadyLatest'));
}
}
}
await reload();
} finally {
setBusyShort('');
}
}
return (
<div>
{messageContextHolder}
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
<Row gutter={[16, 12]}>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
<Switch
checked={settings.publicPage}
onChange={(v) => setSettings({ ...settings, publicPage: v })}
/>
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
{t('pages.settings.probeThemes.publicPageHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
<Input
value={settings.siteName}
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
<Input
value={settings.site}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, site: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
<Input
value={settings.githubProxy}
placeholder="https://"
onChange={(e) => setSettings({ ...settings, githubProxy: e.target.value })}
/>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.githubProxyHint')}
</Typography.Text>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
<Space>
{favicon ? <img src={favicon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => faviconRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{favicon ? (
<Button size="small" onClick={() => setFavicon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={faviconRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
<Space>
{touchIcon ? <img src={touchIcon} alt="" style={{ width: 24, height: 24 }} /> : null}
<Button size="small" onClick={() => touchRef.current?.click()}>
{t('pages.settings.probeThemes.chooseImage')}
</Button>
{touchIcon ? (
<Button size="small" onClick={() => setTouchIcon('')}>
{t('clear')}
</Button>
) : null}
</Space>
<input
ref={touchRef}
type="file"
accept="image/*"
hidden
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeThemes.iconHint')}
</Typography.Text>
</div>
</Col>
</Row>
<div style={{ marginTop: 12 }}>
<Button type="primary" loading={saving} onClick={saveSettings}>
{t('save')}
</Button>
</div>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeThemes.title')}
</Typography.Title>
<Space wrap style={{ marginBottom: 16 }}>
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
{uploadPct === null
? t('pages.settings.probeThemes.uploadInstall')
: `${t('pages.settings.probeThemes.uploadInstall')} ${uploadPct}%`}
</Button>
<input
ref={fileRef}
type="file"
accept=".gz,application/gzip"
hidden
onChange={(e) => uploadTheme(e.target.files?.[0])}
/>
<Input
style={{ width: 340 }}
value={installURL}
placeholder={t('pages.settings.probeThemes.installByUrlPlaceholder')}
onChange={(e) => setInstallURL(e.target.value)}
/>
<Button loading={installing} disabled={!installURL.trim()} onClick={installByURL}>
{t('pages.settings.probeThemes.installByUrl')}
</Button>
</Space>
<Row gutter={[16, 16]}>
{themes.map((theme) => (
<Col key={theme.short} xs={24} sm={12} lg={8}>
<Card
hoverable
cover={
theme.preview ? (
<img
alt={theme.name}
src={`${basePathPrefix()}/panel/api/probe/themes/${theme.short}/preview`}
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
/>
) : undefined
}
actions={[
theme.selected ? (
<Tag key="on" color="green">
{t('pages.settings.probeThemes.inUse')}
</Tag>
) : (
<Button
key="use"
type="link"
loading={busyShort === theme.short}
onClick={() => themeAction(theme.short, 'select')}
>
{t('pages.settings.probeThemes.use')}
</Button>
),
<Button
key="update"
type="link"
loading={busyShort === theme.short}
onClick={() => themeAction(theme.short, 'update')}
>
{t('update')}
</Button>,
...(theme.config && theme.config.length > 0
? [
<ThemeConfigButton
key="config"
theme={theme}
onSaved={() => messageApi.success(t('pages.settings.probeThemes.saved'))}
onError={(m) => messageApi.error(m)}
/>,
]
: []),
...(!theme.builtin
? [
<Popconfirm
key="del"
title={t('pages.settings.probeThemes.deleteConfirm')}
onConfirm={() => themeAction(theme.short, 'delete')}
>
<Button type="link" danger icon={<DeleteOutlined />}>
{t('delete')}
</Button>
</Popconfirm>,
]
: []),
]}
>
<Card.Meta
title={
<Space>
{theme.name}
{theme.builtin ? <Tag>{t('pages.settings.probeThemes.builtin')}</Tag> : null}
</Space>
}
description={
<div>
<div>{theme.description}</div>
<div>
v{theme.version} · {theme.author}
</div>
</div>
}
/>
</Card>
</Col>
))}
</Row>
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.notifyTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.notifyHint')}
</Typography.Paragraph>
{notify ? (
<>
<Row gutter={[16, 12]}>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
<InputNumber
min={1}
max={30}
value={notify.notifyGrace}
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.graceHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
<InputNumber
min={0}
max={100}
value={notify.notifyTraffic}
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.trafficHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
<InputNumber
min={0}
max={365}
value={notify.notifyExpiry}
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.expiryHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
<Switch
checked={notify.notifyLogin}
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
/>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.telegramChannel')}
</div>
{notify.telegramConfigured ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
)}
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.telegramHint')}
</Typography.Text>
</div>
</Col>
<Col xs={24} md={8}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.webhookChannel')}
</div>
{notify.webhookConfigured ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
)}
</Col>
<Col xs={24}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
<Input.TextArea
rows={3}
value={notify.notifyTelegramText}
placeholder={'{{title}}\n{{message}}'}
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
/>
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
<Input
value={webhookUrl}
placeholder={
notify.notifyWebhookUrlSet ? t('pages.settings.probeNotify.secretSet') : 'https://'
}
onChange={(e) => {
setWebhookUrl(e.target.value);
setUrlTouched(true);
}}
/>
{notify.notifyWebhookUrlSet && !urlTouched ? (
<Button
type="link"
size="small"
onClick={() => {
setWebhookUrl('');
setUrlTouched(true);
}}
>
{t('pages.settings.probeNotify.clearSecret')}
</Button>
) : null}
</Col>
<Col xs={24} md={12}>
<div style={{ marginBottom: 4 }}>
{t('pages.settings.probeNotify.webhookHeaders')}
</div>
<Input.TextArea
rows={2}
value={webhookHeaders}
placeholder={
notify.notifyWebhookHeadersSet
? t('pages.settings.probeNotify.secretSet')
: 'Authorization: Bearer …'
}
onChange={(e) => {
setWebhookHeaders(e.target.value);
setHeadersTouched(true);
}}
/>
{notify.notifyWebhookHeadersSet && !headersTouched ? (
<Button
type="link"
size="small"
onClick={() => {
setWebhookHeaders('');
setHeadersTouched(true);
}}
>
{t('pages.settings.probeNotify.clearSecret')}
</Button>
) : null}
</Col>
<Col xs={24}>
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
<Input.TextArea
rows={3}
value={notify.notifyWebhookBody}
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
/>
<div>
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.templateHint')}
</Typography.Text>
</div>
</Col>
</Row>
<Space style={{ marginTop: 12 }}>
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
{t('save')}
</Button>
<Button loading={notifyTesting} onClick={testNotify}>
{t('pages.settings.probeNotify.testSend')}
</Button>
</Space>
</>
) : null}
<Typography.Title level={5} style={{ marginTop: 28 }}>
{t('pages.settings.probeNotify.retireTitle')}
</Typography.Title>
<Typography.Paragraph type="secondary">
{t('pages.settings.probeNotify.retireHint')}
</Typography.Paragraph>
<Space direction="vertical">
<span>
{t('pages.settings.probeNotify.retirePublicPage')}:
{settings.publicPage ? (
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
) : (
<Tag color="orange">{t('pages.settings.probeNotify.retirePublicPageOff')}</Tag>
)}
</span>
{coverage.map((row) => (
<span key={row.name}>
{row.name}:
{row.hasProbe ? (
<Tag color="green">{t('pages.settings.probeNotify.retireHasProbe')}</Tag>
) : (
<Tag color="red">{t('pages.settings.probeNotify.retireNoProbe')}</Tag>
)}
{row.online ? (
<Tag color="green">{t('pages.settings.probeNotify.retireOnline')}</Tag>
) : (
<Tag>{t('pages.settings.probeNotify.retireOffline')}</Tag>
)}
{row.probeAt > 0 ? (
<Typography.Text type="secondary">
{t('pages.settings.probeNotify.retireProbeAt', {
time: new Date(row.probeAt).toLocaleString(),
})}
</Typography.Text>
) : null}
</span>
))}
{coverage.length > 0 && coverage.every((row) => row.hasProbe) ? (
<Typography.Text type="success">
{t('pages.settings.probeNotify.retireAllReady')}
</Typography.Text>
) : (
<Typography.Text type="warning">
{t('pages.settings.probeNotify.retireNotReady')}
</Typography.Text>
)}
</Space>
</div>
);
}
// ThemeConfigButton edits one theme's saved config. The form is
// drawn from the theme.json config declaration (passed through
// unparsed by the server); values load from and save to the contract
// endpoint the themes themselves use, and only fields changed from
// their declared defaults are stored (the hub's semantics).
function ThemeConfigButton({
theme,
onSaved,
onError,
}: {
theme: ThemeWire;
onSaved: () => void;
onError: (msg: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [values, setValues] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
const fields = theme.config ?? [];
async function load() {
let saved: Record<string, unknown> = {};
try {
const res = await fetch(`/api/themes/${theme.short}/config`, { credentials: 'same-origin' });
if (res.ok) saved = (await res.json()) as Record<string, unknown>;
} catch {
/* an unreadable config edits from the defaults */
}
const merged: Record<string, unknown> = {};
for (const f of fields) merged[f.key] = saved[f.key] ?? f.default;
setValues(merged);
setOpen(true);
}
async function save() {
setSaving(true);
try {
const changed: Record<string, unknown> = {};
for (const f of fields) {
if (JSON.stringify(values[f.key]) !== JSON.stringify(f.default)) {
changed[f.key] = values[f.key];
}
}
const res = await fetch(`/api/themes/${theme.short}/config`, {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changed),
});
if (res.ok) {
setOpen(false);
onSaved();
} else {
onError((await res.text()) || t('pages.settings.probeThemes.saveFailed'));
}
} finally {
setSaving(false);
}
}
return (
<>
<Button type="link" icon={<SettingOutlined />} onClick={load}>
{t('pages.settings.probeThemes.config')}
</Button>
<Modal
open={open}
title={`${theme.name} · ${t('pages.settings.probeThemes.config')}`}
onCancel={() => setOpen(false)}
onOk={save}
confirmLoading={saving}
>
{fields.map((f) => (
<div key={f.key} style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>{f.label}</div>
{f.type === 'boolean' ? (
<Switch
checked={Boolean(values[f.key])}
onChange={(v) => setValues({ ...values, [f.key]: v })}
/>
) : f.type === 'number' ? (
<InputNumber
value={typeof values[f.key] === 'number' ? (values[f.key] as number) : undefined}
onChange={(v) => setValues({ ...values, [f.key]: v })}
style={{ width: '100%' }}
/>
) : f.type === 'select' && f.options ? (
<Select
value={values[f.key] as string}
options={f.options}
onChange={(v) => setValues({ ...values, [f.key]: v })}
style={{ width: '100%' }}
/>
) : (
<Input
value={typeof values[f.key] === 'string' ? (values[f.key] as string) : ''}
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
/>
)}
{f.help ? (
<div>
<Typography.Text type="secondary">{f.help}</Typography.Text>
</div>
) : null}
</div>
))}
</Modal>
</>
);
}
@@ -29,6 +29,7 @@ import SubscriptionGeneralTab from './SubscriptionGeneralTab';
import SubscriptionFormatsTab from './SubscriptionFormatsTab';
import SubscriptionBalancersTab from './SubscriptionBalancersTab';
import TelegramTab from './TelegramTab';
import PublicPageTab from './PublicPageTab';
import './SettingsPage.css';
interface ApiMsg {
@@ -42,6 +43,7 @@ const tabSlugs = [
'subscription',
'subscription-formats',
'subscription-balancers',
'public-page',
];
function isIp(h: string): boolean {
@@ -205,6 +207,8 @@ export default function SettingsPage() {
return <SubscriptionFormatsTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'subscription-balancers':
return <SubscriptionBalancersTab allSetting={allSetting} updateSetting={updateSetting} />;
case 'public-page':
return <PublicPageTab />;
default:
return <GeneralTab allSetting={allSetting} updateSetting={updateSetting} />;
}
@@ -17,6 +17,7 @@ function renderNodes(nodes: NodeRecord[]) {
onSelectionChange={noop}
onAdd={noop}
onMtls={noop}
onProbeTasks={noop}
onEdit={noop}
onDelete={noop}
onProbe={noop}
@@ -29,6 +29,7 @@ describe('NodeList desktop table row keys', () => {
onSelectionChange={noop}
onAdd={noop}
onMtls={noop}
onProbeTasks={noop}
onEdit={noop}
onDelete={noop}
onProbe={noop}
+29
View File
@@ -125,6 +125,35 @@ export class HttpUtil {
}
}
static async put<T = unknown>(
url: string,
data?: unknown,
options: HttpOptions = {},
): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
const resp = await httpRequest('PUT', url, data, rest);
const msg = this._respToMsg(resp) as Msg<T>;
if (!silent) this._handleMsg(msg, silentSuccess);
return msg;
} catch (error) {
console.error('PUT request failed:', error);
const err = error as {
response?: { data?: { msg?: string; message?: string } };
message?: string;
};
const data = err.response?.data;
const errorMsg = new Msg<T>(
false,
data?.msg || data?.message || err.message || 'Request failed',
);
if (!silent) {
this._handleMsg(errorMsg);
}
return errorMsg;
}
}
static async delete<T = unknown>(url: string, options: HttpOptions = {}): Promise<Msg<T>> {
const { silent, silentSuccess, ...rest } = options;
try {
+49 -1
View File
@@ -102,6 +102,10 @@ type Agent struct {
sbVer string
sbVerFor string
sbVerMod time.Time
// Latency probe runner (item 7 B4, see ping.go): task set pushed
// on the config poll, samples returned on the heartbeat.
probes *probeRunner
}
// ensureSingboxState lazily creates the sing-box runtime state.
@@ -132,6 +136,7 @@ func New(cfg *Config) *Agent {
},
},
stopCh: make(chan struct{}),
probes: newProbeRunner(),
}
}
@@ -150,9 +155,11 @@ func (e *statusError) Error() string {
// Register registers the agent with the master
func (a *Agent) Register() error {
url := fmt.Sprintf("%s/panel/api/agent/register", a.config.MasterURL)
payload := map[string]string{
payload := map[string]any{
"token": a.config.Token,
"name": a.config.NodeName,
// Probe facts ride along (B1): old masters ignore the extra field.
"probeFacts": collectFacts(),
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
@@ -291,6 +298,9 @@ func (a *Agent) Run() error {
// Stop stops the agent
func (a *Agent) Stop() {
if a.probes != nil {
a.probes.stop()
}
close(a.stopCh)
}
@@ -313,13 +323,30 @@ func (a *Agent) sendHeartbeat() error {
"token": a.config.Token,
"timestamp": time.Now().Unix(),
"telemetry": a.collectTelemetry(),
// Probe snapshot (B1): facts are process-cached so re-sending them
// costs nothing and heals a master that missed the register-time
// copy; metrics are sampled fresh each heartbeat. Old masters
// ignore both fields.
"probeFacts": collectFacts(),
"probeMetrics": collectMetrics(),
}
if rep := a.currentUpdateReport(); rep != nil {
payload["updateStatus"] = rep
}
// Latency probe samples (B4) ride the heartbeat — deliberately not
// the traffic report, which stops when xray is down while probing
// must not. Drained here, requeued on any failure below.
var probeBatch []ProbeResult
if a.probes != nil {
probeBatch = a.probes.drainResults()
if len(probeBatch) > 0 {
payload["probeResults"] = probeBatch
}
}
body, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
a.requeueProbeBatch(probeBatch)
return err
}
req.Header.Set("Content-Type", "application/json")
@@ -327,15 +354,25 @@ func (a *Agent) sendHeartbeat() error {
resp, err := a.do(req)
if err != nil {
a.requeueProbeBatch(probeBatch)
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
a.requeueProbeBatch(probeBatch)
return fmt.Errorf("heartbeat failed with status %d", resp.StatusCode)
}
return nil
}
// requeueProbeBatch returns a heartbeat's probe samples to the
// runner buffer after a failed send.
func (a *Agent) requeueProbeBatch(batch []ProbeResult) {
if a.probes != nil {
a.probes.requeueResults(batch)
}
}
// fetchAndApplyConfig fetches the xray config from master and applies it if changed
func (a *Agent) fetchAndApplyConfig() error {
url := fmt.Sprintf("%s/panel/api/agent/config", a.config.MasterURL)
@@ -362,6 +399,11 @@ func (a *Agent) fetchAndApplyConfig() error {
SingboxConfig json.RawMessage `json:"singbox_config"`
SingboxConfigHash string `json:"singbox_config_hash"`
AgentUpdate *updateIntent `json:"agent_update"`
// ProbeTasks is a pointer on purpose (B4): a master too old
// to know probes omits the field and the agent must keep its
// current set, while a present-but-empty list means "run no
// probes" and clears it.
ProbeTasks *[]ProbeTask `json:"probe_tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return err
@@ -383,6 +425,12 @@ func (a *Agent) fetchAndApplyConfig() error {
}
}
// Probe tasks change independently of the xray config, so they
// are applied before the hash early-return below.
if result.ProbeTasks != nil && a.probes != nil {
a.probes.applyTasks(*result.ProbeTasks)
}
// Skip if config hasn't changed
if result.ConfigHash == a.lastConfigHash && result.ConfigHash != "" {
return nil
+267
View File
@@ -0,0 +1,267 @@
package agent
import (
"context"
"net"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// Item 7 B4: latency probes, the agent side. The master pushes the
// probe task set on the config poll (probe_tasks); the agent runs one
// loop per task — resolve, TCP-connect, time the handshake — and the
// samples ride the next heartbeat back (probeResults). A port of
// monitor-probe's agent prober (agent/src/main.rs tcp_probe and its
// per-task scheduler), with the same numbers: at most 64 tasks, a
// 900 ms budget each for resolution and for every dial, at most three
// addresses tried, and a resolution overrun producing NO sample at
// all rather than a recorded miss.
// ProbeTask is one probe target as pushed by the master.
type ProbeTask struct {
ID int64 `json:"id"`
Target string `json:"target"`
Interval int64 `json:"interval"`
}
// ProbeResult is one filed sample: the task and its handshake latency
// in milliseconds; negative means the target did not answer.
type ProbeResult struct {
TaskID int64 `json:"taskId"`
LatencyMs int64 `json:"latencyMs"`
}
const (
// maxProbeTasksRun mirrors the master's per-agent assignment cap
// (probe.MaxProbesPerNode); an over-long list is truncated, never
// run whole, with the loss logged once per apply.
maxProbeTasksRun = 64
// probeDialBudget bounds name resolution and every single dial.
probeDialBudget = 900 * time.Millisecond
// probeMaxAddrs caps how many resolved addresses one probe tries.
probeMaxAddrs = 3
// probeResultCap bounds the unsent-sample buffer; past it the
// oldest samples drop (they describe a master outage, and their
// timestamps would be discarded on arrival anyway).
probeResultCap = 8192
// Probe intervals are clamped into the master's legal range on
// arrival as a backstop (the panel refuses out-of-range values).
minProbeIntervalSec = 5
maxProbeIntervalSec = 3600
)
type runningProbe struct {
target string
interval int64
cancel context.CancelFunc
}
// probeRunner owns the running probe loops and the result buffer.
// All state is behind mu; probe loops only touch it through methods.
type probeRunner struct {
mu sync.Mutex
tasks map[int64]*runningProbe
results []ProbeResult
dropped int64
resolveLogged map[int64]bool
stopped bool
}
func newProbeRunner() *probeRunner {
return &probeRunner{
tasks: map[int64]*runningProbe{},
resolveLogged: map[int64]bool{},
}
}
// clampProbeInterval folds an interval into the legal range.
func clampProbeInterval(sec int64) int64 {
if sec < minProbeIntervalSec {
return minProbeIntervalSec
}
if sec > maxProbeIntervalSec {
return maxProbeIntervalSec
}
return sec
}
// applyTasks reconciles the running loops with a freshly pushed set:
// a task whose (target, interval) is unchanged keeps its loop and its
// phase; anything else is cancelled and, if still wanted, restarted.
func (r *probeRunner) applyTasks(tasks []ProbeTask) {
if len(tasks) > maxProbeTasksRun {
logger.Warningf("probe: master pushed %d tasks, running only the first %d", len(tasks), maxProbeTasksRun)
tasks = tasks[:maxProbeTasksRun]
}
want := make(map[int64]ProbeTask, len(tasks))
for _, t := range tasks {
t.Interval = clampProbeInterval(t.Interval)
want[t.ID] = t
}
r.mu.Lock()
defer r.mu.Unlock()
if r.stopped {
return
}
for id, running := range r.tasks {
t, ok := want[id]
if !ok || t.Target != running.target || t.Interval != running.interval {
running.cancel()
delete(r.tasks, id)
}
}
for id, t := range want {
if _, ok := r.tasks[id]; ok {
continue
}
ctx, cancel := context.WithCancel(context.Background())
r.tasks[id] = &runningProbe{target: t.Target, interval: t.Interval, cancel: cancel}
go r.runLoop(ctx, t)
}
}
// stop cancels every probe loop (agent shutdown).
func (r *probeRunner) stop() {
r.mu.Lock()
defer r.mu.Unlock()
r.stopped = true
for id, running := range r.tasks {
running.cancel()
delete(r.tasks, id)
}
}
// runLoop probes immediately, then on the task's interval. A tick
// that arrives while a probe is still running is dropped rather than
// queued (the hub's MissedTickBehavior::Delay; a Go ticker behaves
// the same way).
func (r *probeRunner) runLoop(ctx context.Context, task ProbeTask) {
r.probeOnce(task)
ticker := time.NewTicker(time.Duration(task.Interval) * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.probeOnce(task)
}
}
}
func (r *probeRunner) probeOnce(task ProbeTask) {
ms, sampled := probeTCP(task.Target, func() {
r.noteResolveTimeout(task.ID, task.Target)
})
if !sampled {
return
}
r.addResult(ProbeResult{TaskID: task.ID, LatencyMs: ms})
}
// noteResolveTimeout logs a skipped round once per task per process:
// a target whose name stops resolving would otherwise be silent
// forever (nothing is filed for it), and one line per interval per
// target would flood the log on a DNS outage (hub discipline).
func (r *probeRunner) noteResolveTimeout(taskID int64, target string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.resolveLogged[taskID] {
return
}
r.resolveLogged[taskID] = true
logger.Warningf("probe: resolving %s overran %s; round skipped (further rounds log nothing)", target, probeDialBudget)
}
func (r *probeRunner) addResult(res ProbeResult) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.results) >= probeResultCap {
r.results = r.results[1:]
r.dropped++
}
r.results = append(r.results, res)
}
// drainResults takes the buffered samples for one heartbeat. The
// caller requeues them when the heartbeat fails, so a master outage
// loses nothing that still fits the buffer.
func (r *probeRunner) drainResults() []ProbeResult {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.results) == 0 {
return nil
}
out := r.results
r.results = nil
return out
}
// requeueResults puts a failed heartbeat's batch back ahead of
// anything probed since, trimming the oldest if the buffer overflows.
func (r *probeRunner) requeueResults(batch []ProbeResult) {
if len(batch) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
merged := make([]ProbeResult, 0, len(batch)+len(r.results))
merged = append(merged, batch...)
merged = append(merged, r.results...)
if len(merged) > probeResultCap {
merged = merged[len(merged)-probeResultCap:]
}
r.results = merged
}
// probeTCP measures one target's TCP handshake latency in
// milliseconds. It returns (ms, true) for a sample to file — the
// handshake time, or -1 when the target did not answer — and
// (0, false) when the round produced no sample: name resolution
// overran its budget, which the hub treats as "nothing happened"
// rather than a miss, because filing it would blame the target for
// the resolver's state. A malformed target is -1: it is re-probed
// every interval and reads as total loss, which is what it is.
func probeTCP(target string, onResolveTimeout func()) (int64, bool) {
host, port, err := net.SplitHostPort(target)
if err != nil {
return -1, true
}
rctx, cancel := context.WithTimeout(context.Background(), probeDialBudget)
addrs, err := (&net.Resolver{}).LookupIPAddr(rctx, host)
timedOut := rctx.Err() == context.DeadlineExceeded
cancel()
if err != nil {
if timedOut {
if onResolveTimeout != nil {
onResolveTimeout()
}
return 0, false
}
return -1, true
}
if len(addrs) == 0 {
return -1, true
}
dialer := &net.Dialer{}
tried := 0
for _, addr := range addrs {
if tried >= probeMaxAddrs {
break
}
tried++
dctx, dcancel := context.WithTimeout(context.Background(), probeDialBudget)
start := time.Now()
conn, derr := dialer.DialContext(dctx, "tcp", net.JoinHostPort(addr.IP.String(), port))
elapsed := time.Since(start)
dcancel()
if derr == nil {
conn.Close()
return elapsed.Milliseconds(), true
}
}
return -1, true
}
+169
View File
@@ -0,0 +1,169 @@
package agent
import (
"encoding/json"
"net"
"testing"
"time"
)
// probeTCP against a live listener yields a real non-negative
// latency; a closed port and a malformed target both file -1; 0 is
// never a latency (contract: failures are negative or absent).
func TestProbeTCPOutcomes(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
conn.Close()
}
}()
ms, sampled := probeTCP(ln.Addr().String(), nil)
if !sampled || ms < 0 {
t.Fatalf("live target: ms=%d sampled=%v", ms, sampled)
}
ms, sampled = probeTCP("127.0.0.1:1", nil)
if !sampled || ms != -1 {
t.Fatalf("closed port: ms=%d sampled=%v", ms, sampled)
}
ms, sampled = probeTCP("not-a-target", nil)
if !sampled || ms != -1 {
t.Fatalf("malformed: ms=%d sampled=%v", ms, sampled)
}
ms, sampled = probeTCP("256.256.256.256:80", nil)
if !sampled || ms != -1 {
t.Fatalf("unresolvable: ms=%d sampled=%v", ms, sampled)
}
}
// applyTasks: an unchanged task keeps its loop; a changed target or
// interval restarts it; a removed task is cancelled; the set is
// truncated at the cap with intervals clamped into range.
func TestProbeRunnerApplyTasks(t *testing.T) {
r := newProbeRunner()
defer r.stop()
r.applyTasks([]ProbeTask{{ID: 1, Target: "127.0.0.1:1", Interval: 60}})
if len(r.tasks) != 1 {
t.Fatalf("tasks %v", r.tasks)
}
first := r.tasks[1]
// Unchanged: same loop object survives.
r.applyTasks([]ProbeTask{{ID: 1, Target: "127.0.0.1:1", Interval: 60}})
if r.tasks[1] != first {
t.Fatal("unchanged task restarted")
}
// Changed interval: replaced, clamped values are what runs.
r.applyTasks([]ProbeTask{{ID: 1, Target: "127.0.0.1:1", Interval: 120}})
if r.tasks[1] == first || r.tasks[1].interval != 120 {
t.Fatal("changed task not restarted")
}
// Removed: cancelled.
r.applyTasks(nil)
if len(r.tasks) != 0 {
t.Fatalf("tasks after clear %v", r.tasks)
}
// Clamp + cap.
many := make([]ProbeTask, 0, 70)
for i := 1; i <= 70; i++ {
many = append(many, ProbeTask{ID: int64(i), Target: "127.0.0.1:1", Interval: 1})
}
r.applyTasks(many)
if len(r.tasks) != maxProbeTasksRun {
t.Fatalf("cap: %d running", len(r.tasks))
}
if r.tasks[1].interval != minProbeIntervalSec {
t.Fatalf("clamp: interval %d", r.tasks[1].interval)
}
}
// A running loop files samples into the buffer; drain takes them and
// requeue restores order ahead of newer samples.
func TestProbeRunnerResultFlow(t *testing.T) {
r := newProbeRunner()
defer r.stop()
r.applyTasks([]ProbeTask{{ID: 9, Target: "127.0.0.1:1", Interval: 3600}})
// The loop probes immediately on start.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if got := r.drainResults(); len(got) > 0 {
if got[0].TaskID != 9 || got[0].LatencyMs != -1 {
t.Fatalf("sample %+v", got[0])
}
goto drained
}
time.Sleep(20 * time.Millisecond)
}
t.Fatal("no immediate sample from a fresh task")
drained:
r.addResult(ProbeResult{TaskID: 1, LatencyMs: 5})
batch := r.drainResults()
r.addResult(ProbeResult{TaskID: 1, LatencyMs: 7})
r.requeueResults(batch)
got := r.drainResults()
if len(got) != 2 || got[0].LatencyMs != 5 || got[1].LatencyMs != 7 {
t.Fatalf("requeue order %+v", got)
}
if again := r.drainResults(); again != nil {
t.Fatalf("second drain %+v", again)
}
}
// Buffer overflow drops the oldest samples, keeping the newest cap.
func TestProbeRunnerBufferCap(t *testing.T) {
r := newProbeRunner()
defer r.stop()
for i := 0; i < probeResultCap+10; i++ {
r.addResult(ProbeResult{TaskID: 1, LatencyMs: int64(i)})
}
got := r.drainResults()
if len(got) != probeResultCap {
t.Fatalf("buffer %d", len(got))
}
if got[0].LatencyMs != 10 || got[len(got)-1].LatencyMs != int64(probeResultCap+9) {
t.Fatalf("kept range %d..%d", got[0].LatencyMs, got[len(got)-1].LatencyMs)
}
if r.dropped != 10 {
t.Fatalf("dropped %d", r.dropped)
}
}
// The wire shapes: a result marshals as taskId/latencyMs, and the
// config decode distinguishes an absent probe_tasks (old master,
// keep current set) from an empty one (clear all).
func TestProbeWireShapes(t *testing.T) {
body, err := json.Marshal(ProbeResult{TaskID: 3, LatencyMs: -1})
if err != nil || string(body) != `{"taskId":3,"latencyMs":-1}` {
t.Fatalf("result wire: %s %v", body, err)
}
decode := func(s string) *[]ProbeTask {
var result struct {
ProbeTasks *[]ProbeTask `json:"probe_tasks"`
}
if err := json.Unmarshal([]byte(s), &result); err != nil {
t.Fatal(err)
}
return result.ProbeTasks
}
if got := decode(`{"config_hash":"x"}`); got != nil {
t.Fatalf("absent field decoded as %v", got)
}
if got := decode(`{"probe_tasks":[]}`); got == nil || len(*got) != 0 {
t.Fatalf("empty list decoded as %v", got)
}
got := decode(`{"probe_tasks":[{"id":2,"target":"1.1.1.1:443","interval":30}]}`)
if got == nil || len(*got) != 1 || (*got)[0].ID != 2 || (*got)[0].Target != "1.1.1.1:443" || (*got)[0].Interval != 30 {
t.Fatalf("task decode %+v", got)
}
}
+950
View File
@@ -0,0 +1,950 @@
package agent
// probe.go ports monitor-probe's agent collector (collect.rs, MIT) to Go:
// host Facts (once per process) and per-heartbeat Metrics read directly from
// /proc and /sys with the standard library only. The port keeps the source's
// accounting discipline:
// - memory "used" is free(1)'s column: MemTotal - MemAvailable;
// - disk "used" is df(1)'s: sum over real mounts of (f_blocks-f_bfree);
// - network totals sum only "counted" interfaces — loopback, container,
// tunnel, bridge/bond/VLAN stacked devices are excluded by a three-layer
// filter so no byte is counted twice;
// - boot epoch = kernel boot_id + FNV-1a fingerprint of the counted set,
// so a set change re-baselines exactly like a reboot;
// - every source degrades independently: an unreadable file yields a zero
// field, never a failed collection round.
import (
"hash/fnv"
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
)
// ProbeFacts mirrors monitor's Facts frame: static host identity, sampled
// once and piggybacked on the register request and (cheaply) heartbeats.
type ProbeFacts struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Kernel string `json:"kernel"`
Arch string `json:"arch"`
Virt string `json:"virt"`
CPUName string `json:"cpuName"`
CPUCores uint32 `json:"cpuCores"`
MemTotal uint64 `json:"memTotal"`
SwapTotal uint64 `json:"swapTotal"`
DiskTotal uint64 `json:"diskTotal"`
AgentVersion string `json:"agentVersion"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
}
// ProbeMetrics mirrors monitor's Metrics frame: one sample per heartbeat.
type ProbeMetrics struct {
BootID string `json:"bootId"`
Iface string `json:"iface"`
Uptime uint64 `json:"uptime"`
CPU float32 `json:"cpu"`
Load [3]float32 `json:"load"`
MemTotal uint64 `json:"memTotal"`
MemUsed uint64 `json:"memUsed"`
SwapTotal uint64 `json:"swapTotal"`
SwapUsed uint64 `json:"swapUsed"`
DiskTotal uint64 `json:"diskTotal"`
DiskUsed uint64 `json:"diskUsed"`
NetRxTotal uint64 `json:"netRxTotal"`
NetTxTotal uint64 `json:"netTxTotal"`
NetRx uint64 `json:"netRx"`
NetTx uint64 `json:"netTx"`
TCP uint32 `json:"tcp"`
UDP uint32 `json:"udp"`
Procs uint32 `json:"procs"`
}
// probeCollector holds the cross-sample state the rates need (previous CPU
// jiffies and per-interface counters). Kept in memory only, like the source.
type probeCollector struct {
mu sync.Mutex
prevCPU *cpuSample
prevNet map[string][2]uint64
prevNetT time.Time
}
type cpuSample struct{ total, idle uint64 }
var (
probeColl = &probeCollector{}
factsOnce sync.Once
factsCache *ProbeFacts
)
// collectFacts returns the process-cached Facts, sampling on first use.
func collectFacts() *ProbeFacts {
factsOnce.Do(func() { factsCache = sampleFacts() })
return factsCache
}
func sampleFacts() *ProbeFacts {
mem := parseMeminfo(readProcFile("meminfo"))
diskTotal, _ := diskUsage(realMountPoints(readProcFile("self/mounts")))
cpuName, cpuCores := cpuInfo(readProcFile("cpuinfo"))
v4, v6 := probeAddresses()
return &ProbeFacts{
Hostname: readTrimFile("/proc/sys/kernel/hostname", "unknown"),
OS: osPrettyName(),
Kernel: readTrimFile("/proc/sys/kernel/osrelease", "unknown"),
Arch: runtime.GOARCH,
Virt: detectVirt(readProcFile("self/mounts"), readProcFile("cpuinfo")),
CPUName: cpuName,
CPUCores: cpuCores,
MemTotal: mem["MemTotal"],
SwapTotal: mem["SwapTotal"],
DiskTotal: diskTotal,
AgentVersion: config.GetAgentDisplayVersion(),
IPv4: v4,
IPv6: v6,
}
}
// collectMetrics takes one full sample. Individual sources fail soft.
func collectMetrics() *ProbeMetrics {
probeColl.mu.Lock()
defer probeColl.mu.Unlock()
mem := parseMeminfo(readProcFile("meminfo"))
memTotal, memUsed := memUsedFree(mem)
swapTotal, swapUsed := swapUsedFree(mem)
diskTotal, diskUsed := diskUsage(realMountPoints(readProcFile("self/mounts")))
counted := countedInterfaces(parseNetDev(readProcFile("net/dev")), sysNetRoot)
var rxTotal, txTotal uint64
names := make([]string, 0, len(counted))
for _, c := range counted {
rxTotal += c.rx
txTotal += c.tx
names = append(names, c.name)
}
bootID := bootEpoch(readTrimFile("/proc/sys/kernel/random/boot_id", ""), names)
rxRate, txRate := probeColl.netRate(counted, time.Now())
tcp, udp := connCounts(readProcFile("net/sockstat"), readProcFile("net/sockstat6"))
return &ProbeMetrics{
BootID: bootID,
Uptime: parseUptime(readProcFile("uptime")),
CPU: probeColl.cpuPercent(parseCPUJiffies(readProcFile("stat"))),
Load: parseLoadavg(readProcFile("loadavg")),
MemTotal: memTotal,
MemUsed: memUsed,
SwapTotal: swapTotal,
SwapUsed: swapUsed,
DiskTotal: diskTotal,
DiskUsed: diskUsed,
NetRxTotal: rxTotal,
NetTxTotal: txTotal,
NetRx: rxRate,
NetTx: txRate,
TCP: tcp,
UDP: udp,
Procs: procCount(),
}
}
// --- file helpers -----------------------------------------------------------
const sysNetRoot = "/sys/class/net"
func readProcFile(name string) string {
b, err := os.ReadFile(filepath.Join("/proc", name))
if err != nil {
return ""
}
return string(b)
}
func readTrimFile(path, fallback string) string {
b, err := os.ReadFile(path)
if err != nil {
return fallback
}
if s := strings.TrimSpace(string(b)); s != "" {
return s
}
return fallback
}
// --- memory (free口径) -------------------------------------------------------
func parseMeminfo(text string) map[string]uint64 {
out := make(map[string]uint64)
for _, line := range strings.Split(text, "\n") {
key, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) == 0 {
continue
}
kb, err := strconv.ParseUint(f[0], 10, 64)
if err != nil {
continue
}
out[key] = kb * 1024
}
return out
}
// memUsedFree is free(1)'s used column: total - MemAvailable, with the
// source's two fallbacks (no MemAvailable -> Free+Buffers+Cached; an
// available above the total, as LXCFS can report, -> total - MemFree).
func memUsedFree(m map[string]uint64) (total, used uint64) {
total = m["MemTotal"]
if total == 0 {
return 0, 0
}
avail, ok := m["MemAvailable"]
if !ok {
avail = m["MemFree"] + m["Buffers"] + m["Cached"]
}
if avail > total {
avail = m["MemFree"]
}
if avail > total {
avail = total
}
return total, total - avail
}
func swapUsedFree(m map[string]uint64) (total, used uint64) {
total = m["SwapTotal"]
free := m["SwapFree"]
if free > total {
free = total
}
return total, total - free
}
// --- cpu --------------------------------------------------------------------
func parseCPUJiffies(stat string) *cpuSample {
for _, line := range strings.Split(stat, "\n") {
if !strings.HasPrefix(line, "cpu ") {
continue
}
f := strings.Fields(line[4:])
if len(f) < 5 {
return nil
}
var v [8]uint64
var total uint64
for i := 0; i < len(f) && i < 8; i++ {
n, err := strconv.ParseUint(f[i], 10, 64)
if err != nil {
return nil
}
v[i] = n
total += n
}
return &cpuSample{total: total, idle: v[3] + v[4]}
}
return nil
}
func (c *probeCollector) cpuPercent(now *cpuSample) float32 {
if now == nil {
return 0
}
prev := c.prevCPU
c.prevCPU = now
if prev == nil || now.total <= prev.total {
return 0
}
dt := float32(now.total - prev.total)
di := float32(0)
if now.idle > prev.idle {
di = float32(now.idle - prev.idle)
}
pct := (dt - di) / dt * 100
if pct < 0 {
pct = 0
}
if pct > 100 {
pct = 100
}
return pct
}
func parseLoadavg(text string) [3]float32 {
var out [3]float32
f := strings.Fields(text)
for i := 0; i < 3 && i < len(f); i++ {
if v, err := strconv.ParseFloat(f[i], 32); err == nil {
out[i] = float32(v)
}
}
return out
}
func parseUptime(text string) uint64 {
f := strings.Fields(text)
if len(f) == 0 {
return 0
}
v, err := strconv.ParseFloat(f[0], 64)
if err != nil || v < 0 {
return 0
}
return uint64(v)
}
// cpuInfo returns the display name and logical core count from /proc/cpuinfo.
func cpuInfo(text string) (string, uint32) {
name := ""
cores := uint32(0)
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "processor":
cores++
case "model name", "Model", "cpu model":
if name == "" {
name = strings.TrimSpace(val)
}
}
}
if cores == 0 {
cores = uint32(runtime.NumCPU())
if cores == 0 {
cores = 1
}
}
if name == "" {
name = armCPUName(text)
}
if name == "" {
name = "unknown"
}
return name, cores
}
// armCPUName decodes AArch64 MIDR implementer/part pairs the way lscpu does,
// joining distinct core types with "/" (e.g. Cortex-A55/Cortex-A76).
func armCPUName(text string) string {
var vendor *armVendor
var names []string
seen := map[string]bool{}
for _, line := range strings.Split(text, "\n") {
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch strings.TrimSpace(key) {
case "CPU implementer":
vendor = nil
if id, ok := parseHexID(val); ok {
for i := range armVendors {
if armVendors[i].id == id {
vendor = &armVendors[i]
}
}
}
case "CPU part":
if vendor == nil {
continue
}
name := vendor.name
if id, ok := parseHexID(val); ok {
for _, p := range vendor.parts {
if p.id == id {
name = p.name
}
}
}
if !seen[name] {
seen[name] = true
names = append(names, name)
}
}
}
return strings.Join(names, "/")
}
func parseHexID(s string) (uint32, bool) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "0x"))
v, err := strconv.ParseUint(s, 16, 32)
if err != nil {
return 0, false
}
return uint32(v), true
}
type armPart struct {
id uint32
name string
}
type armVendor struct {
id uint32
name string
parts []armPart
}
// Subset of the source's MIDR table (util-linux lscpu-arm names): the parts
// plausibly met on VPS hardware. An unlisted part falls back to the vendor.
var armVendors = []armVendor{
{0x41, "ARM", []armPart{
{0xd03, "Cortex-A53"}, {0xd04, "Cortex-A35"}, {0xd05, "Cortex-A55"},
{0xd07, "Cortex-A57"}, {0xd08, "Cortex-A72"}, {0xd09, "Cortex-A73"},
{0xd0a, "Cortex-A75"}, {0xd0b, "Cortex-A76"}, {0xd0c, "Neoverse-N1"},
{0xd0d, "Cortex-A77"}, {0xd40, "Neoverse-V1"}, {0xd41, "Cortex-A78"},
{0xd44, "Cortex-X1"}, {0xd46, "Cortex-A510"}, {0xd47, "Cortex-A710"},
{0xd48, "Cortex-X2"}, {0xd49, "Neoverse-N2"}, {0xd4d, "Cortex-A715"},
{0xd4e, "Cortex-X3"}, {0xd4f, "Neoverse-V2"}, {0xd80, "Cortex-A520"},
{0xd81, "Cortex-A720"}, {0xd82, "Cortex-X4"}, {0xd84, "Neoverse-V3"},
{0xd85, "Cortex-X925"}, {0xd87, "Cortex-A725"}, {0xd8e, "Neoverse-N3"},
}},
{0x48, "HiSilicon", []armPart{
{0xd01, "Kunpeng-920"}, {0xd02, "Kunpeng-920"}, {0xd03, "Kunpeng-920"},
{0xd06, "Kunpeng-950"}, {0xd22, "Kunpeng-920"},
}},
{0x51, "Qualcomm", []armPart{{0x001, "Oryon"}, {0x002, "Oryon-2"}}},
{0x50, "APM", []armPart{{0x000, "X-Gene"}}},
{0xc0, "Ampere", []armPart{{0xac3, "Ampere-1"}, {0xac4, "Ampere-1a"}}},
{0x6d, "Microsoft", []armPart{{0xd49, "Azure-Cobalt-100"}}},
}
// --- os / virt ---------------------------------------------------------------
func osPrettyName() string {
for _, path := range []string{"/etc/os-release", "/usr/lib/os-release"} {
b, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PRETTY_NAME="); ok {
return strings.Trim(v, "\"'")
}
}
}
return "Linux"
}
// detectVirt ports the source's chain: containers first, then hypervisor.
// CPUID is unreachable from Go's stdlib, so the x86 signature check is
// replaced by the DMI strings (the source's own non-x86 fallback) plus the
// /proc/cpuinfo hypervisor flag — a guest of a hypervisor that hides both
// reads as "none", same caveat the source documents for cleared CPUID bits.
func detectVirt(mounts, cpuinfo string) string {
if _, err := os.Stat("/proc/vz"); err == nil {
if _, err := os.Stat("/proc/bc"); err != nil {
return "openvz"
}
}
if s := readTrimFile("/run/systemd/container", ""); s != "" {
return s
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return "docker"
}
if _, err := os.Stat("/run/.containerenv"); err == nil {
return "podman"
}
for _, line := range strings.Split(mounts, "\n") {
f := strings.Fields(line)
if len(f) >= 3 && f[2] == "fuse.lxcfs" && strings.HasPrefix(f[1], "/proc/") {
return "lxc"
}
}
if _, err := os.Stat("/proc/xen"); err == nil {
return "xen"
}
if _, err := os.Stat("/sys/hypervisor/type"); err == nil {
return "xen"
}
dmi := strings.ToLower(readTrimFile("/sys/class/dmi/id/product_name", "") + " " +
readTrimFile("/sys/class/dmi/id/sys_vendor", ""))
for _, m := range []struct{ word, name string }{
{"kvm", "kvm"}, {"qemu", "kvm"}, {"amazon ec2", "kvm"}, {"google", "kvm"},
{"microsoft", "hyper-v"}, {"vmware", "vmware"}, {"virtualbox", "virtualbox"},
{"bhyve", "bhyve"}, {"xen", "xen"},
} {
if strings.Contains(dmi, m.word) {
return m.name
}
}
if _, err := os.Stat("/sys/bus/acpi/devices/QEMU0002:00"); err == nil {
return "kvm"
}
if strings.Contains(cpuinfo, "hypervisor") {
return "vm"
}
return "none"
}
// --- network: counted set, epoch, rates -------------------------------------
type netCounters struct {
name string
rx, tx uint64
}
func parseNetDev(text string) []netCounters {
var out []netCounters
lines := strings.Split(text, "\n")
for _, line := range lines {
name, rest, ok := strings.Cut(line, ":")
if !ok {
continue
}
f := strings.Fields(rest)
if len(f) < 16 {
continue
}
rx, err1 := strconv.ParseUint(f[0], 10, 64)
tx, err2 := strconv.ParseUint(f[8], 10, 64)
if err1 != nil || err2 != nil {
continue
}
out = append(out, netCounters{name: strings.TrimSpace(name), rx: rx, tx: tx})
}
return out
}
// skipIfacePrefixes is layer 1 of the filter (source SKIP_IFACES).
var skipIfacePrefixes = []string{
"lo", "docker", "veth", "br-", "virbr", "tap", "tun", "wg", "tailscale",
"cni", "flannel", "podman", "fwbr", "fwpr", "fwln", "ifb", "gretap",
"erspan", "kube", "cali", "nerdctl", "lxc", "cilium", "zt",
}
func skipIfaceName(name string) bool {
for _, p := range skipIfacePrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// isStackedName is layer 2: bonds, bridges, VLAN children, PPPoE overlays —
// the kernel books their bytes again on the device beneath.
func isStackedName(name string) bool {
if strings.Contains(name, ".") {
return true
}
for _, p := range []string{"bond", "br", "vlan", "vmbr", "pppoe-"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
var tunnelLinkTypes = map[string]bool{
"65534": true, "768": true, "769": true, "776": true, "778": true, "823": true,
}
// countedElsewhere is layer 3 (sysfs): the kernel shows this device's bytes
// are already counted on another device. Hardware (a `device` link) exempts
// a device from the tunnel/port rules — a raw-IP LTE modem shares its link
// type with WireGuard but carries real traffic.
func countedElsewhere(sysRoot, name string) bool {
dev := filepath.Join(sysRoot, name)
uevent := readTrimFile(filepath.Join(dev, "uevent"), "")
devtypeIs := func(set ...string) bool {
for _, line := range strings.Split(uevent, "\n") {
for _, want := range set {
if line == want {
return true
}
}
}
return false
}
if devtypeIs("DEVTYPE=bridge", "DEVTYPE=bond") {
return true
}
if entries, err := os.ReadDir(dev); err == nil {
for _, e := range entries {
if strings.HasPrefix(e.Name(), "lower_") {
return true
}
}
}
if _, err := os.Stat(filepath.Join(dev, "device")); err == nil {
return false
}
if _, err := os.Stat(filepath.Join(dev, "brport")); err == nil {
return true
}
if tunnelLinkTypes[readTrimFile(filepath.Join(dev, "type"), "")] {
return true
}
return devtypeIs("DEVTYPE=vxlan", "DEVTYPE=geneve")
}
// ifaceCounts applies the three layers. Exposed for tests with a fake sysfs.
func ifaceCounts(sysRoot, name string) bool {
if skipIfaceName(name) || isStackedName(name) {
return false
}
return !countedElsewhere(sysRoot, name)
}
func countedInterfaces(all []netCounters, sysRoot string) []netCounters {
out := make([]netCounters, 0, len(all))
for _, c := range all {
if ifaceCounts(sysRoot, c.name) {
out = append(out, c)
}
}
return out
}
// bootEpoch = "{kernel boot_id}/{FNV-1a-64 of the sorted counted names}".
// The master only tests it for equality: any change re-baselines.
func bootEpoch(kernelBootID string, names []string) string {
sorted := append([]string(nil), names...)
sort.Strings(sorted)
h := fnv.New64a()
_, _ = h.Write([]byte(strings.Join(sorted, "\n")))
return kernelBootID + "/" + hex16(h.Sum64())
}
func hex16(v uint64) string {
const digits = "0123456789abcdef"
var buf [16]byte
for i := 15; i >= 0; i-- {
buf[i] = digits[v&0xf]
v >>= 4
}
return string(buf[:])
}
// netRate diffs per interface over those present in both samples
// (saturating), divided by the elapsed wall time.
func (c *probeCollector) netRate(counted []netCounters, now time.Time) (uint64, uint64) {
prev, prevT := c.prevNet, c.prevNetT
next := make(map[string][2]uint64, len(counted))
for _, ct := range counted {
next[ct.name] = [2]uint64{ct.rx, ct.tx}
}
c.prevNet, c.prevNetT = next, now
if prev == nil {
return 0, 0
}
secs := now.Sub(prevT).Seconds()
if secs <= 0 {
return 0, 0
}
var rx, tx uint64
for _, ct := range counted {
p, ok := prev[ct.name]
if !ok {
continue
}
if ct.rx > p[0] {
rx += ct.rx - p[0]
}
if ct.tx > p[1] {
tx += ct.tx - p[1]
}
}
return uint64(float64(rx) / secs), uint64(float64(tx) / secs)
}
// --- addresses (scope §1.5) ---------------------------------------------------
// probeAddresses picks one address per family: candidates come from
// non-skip interfaces that are up, are not link-local, prefer public over
// private, and for v6 avoid temporary/deprecated/tentative addresses.
func probeAddresses() (string, string) {
transient := transientV6(readProcFile("net/if_inet6"))
ifaces, err := net.Interfaces()
if err != nil {
return "", ""
}
type cand struct {
ip net.IP
transient bool
}
var held []cand
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 {
continue
}
if readTrimFile(filepath.Join(sysNetRoot, ifc.Name, "operstate"), "up") != "up" {
continue
}
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
continue
}
if !lendsAddress(ifc.Name, ip) {
continue
}
c := cand{ip: ip}
if ip6 := ip.To16(); ip.To4() == nil && ip6 != nil {
for _, t := range transient {
if t.Equal(ip6) {
c.transient = true
}
}
}
held = append(held, c)
}
}
pick := func(v6 bool) string {
var best *cand
for i := range held {
c := &held[i]
if (c.ip.To4() == nil) != v6 {
continue
}
if best == nil {
best = c
continue
}
bp, cp := isPublicIP(best.ip), isPublicIP(c.ip)
if cp != bp {
if cp {
best = c
}
continue
}
if v6 && best.transient && !c.transient {
best = c
}
}
if best == nil {
return ""
}
return best.ip.String()
}
return pick(false), pick(true)
}
// lendsAddress mirrors the source: a skipped interface lends no address,
// except a WireGuard interface lending a public v6 that is not in
// Cloudflare WARP's shared 2606:4700::/32.
func lendsAddress(name string, ip net.IP) bool {
if !skipIfaceName(name) {
return true
}
if strings.HasPrefix(name, "wg") && ip.To4() == nil && isPublicIP(ip) {
ip16 := ip.To16()
return !(ip16[0] == 0x26 && ip16[1] == 0x06 && ip16[2] == 0x47 && ip16[3] == 0x00)
}
return false
}
// transientV6 parses /proc/net/if_inet6 for addresses carrying
// TEMPORARY|DADFAILED|DEPRECATED|TENTATIVE flags.
func transientV6(text string) []net.IP {
const transientFlags = 0x01 | 0x08 | 0x20 | 0x40
var out []net.IP
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 6 {
continue
}
flags, err := strconv.ParseUint(f[4], 16, 32)
if err != nil || flags&transientFlags == 0 {
continue
}
if len(f[0]) != 32 {
continue
}
ip := make(net.IP, 16)
ok := true
for i := 0; i < 16; i++ {
b, err := strconv.ParseUint(f[0][i*2:i*2+2], 16, 8)
if err != nil {
ok = false
break
}
ip[i] = byte(b)
}
if ok {
out = append(out, ip)
}
}
return out
}
// isPublicIP mirrors monitor's is_public: v4 excludes RFC1918, CGNAT
// 100.64/10, loopback, link-local, 0/8, >=224, 192.0.0/24, 198.18/15;
// v6 is only 2000::/3.
func isPublicIP(ip net.IP) bool {
if ip4 := ip.To4(); ip4 != nil {
a, b, c := ip4[0], ip4[1], ip4[2]
switch {
case ip4.IsPrivate(), ip4.IsLoopback(), ip4.IsLinkLocalUnicast():
return false
case a == 0 || a >= 224:
return false
case a == 100 && b&0xc0 == 64:
return false
case a == 192 && b == 0 && c == 0:
return false
case a == 198 && b&0xfe == 18:
return false
}
return true
}
ip16 := ip.To16()
if ip16 == nil {
return false
}
return ip16[0]&0xe0 == 0x20
}
// --- sockets / processes ------------------------------------------------------
func connCounts(v4, v6 string) (tcp, udp uint32) {
stat := func(text, prefix, key string) uint32 {
for _, line := range strings.Split(text, "\n") {
if !strings.HasPrefix(line, prefix) {
continue
}
f := strings.Fields(line[len(prefix):])
for i := 0; i+1 < len(f); i++ {
if f[i] == key {
if n, err := strconv.ParseUint(f[i+1], 10, 32); err == nil {
return uint32(n)
}
return 0
}
}
}
return 0
}
tcp = stat(v4, "TCP:", "inuse") + stat(v4, "TCP:", "tw") + stat(v6, "TCP6:", "inuse")
udp = stat(v4, "UDP:", "inuse") + stat(v6, "UDP6:", "inuse")
return tcp, udp
}
func procCount() uint32 {
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
var n uint32
for _, e := range entries {
name := e.Name()
digits := name != ""
for i := 0; i < len(name); i++ {
if name[i] < '0' || name[i] > '9' {
digits = false
break
}
}
if digits {
n++
}
}
return n
}
// --- disk (df口径) ---------------------------------------------------------------
var skipFSTypes = map[string]bool{
"tmpfs": true, "devtmpfs": true, "proc": true, "sysfs": true,
"cgroup": true, "cgroup2": true, "devpts": true, "mqueue": true,
"hugetlbfs": true, "debugfs": true, "tracefs": true, "securityfs": true,
"pstore": true, "bpf": true, "configfs": true, "fusectl": true,
"binfmt_misc": true, "autofs": true, "squashfs": true, "ramfs": true,
"efivarfs": true, "nsfs": true, "overlay": true, "ecryptfs": true,
"fuse": true, "rpc_pipefs": true,
"nfs": true, "nfs4": true, "cifs": true, "smb3": true, "ceph": true,
"glusterfs": true, "9p": true,
}
func skipFSType(fstype string) bool {
if skipFSTypes[fstype] {
return true
}
// Flavours: "fuse.lxcfs" is skipped by its "fuse." prefix, like the source.
if i := strings.IndexByte(fstype, '.'); i > 0 && skipFSTypes[fstype[:i]] {
return true
}
return false
}
// realMountPoints ports parse_mounts: only the topmost mount per path,
// deduped by source device (zfs by pool), real storage only.
func realMountPoints(text string) []string {
type row struct{ dev, mount, fstype string }
var rows []row
top := map[string]int{}
for _, line := range strings.Split(text, "\n") {
f := strings.Fields(line)
if len(f) < 3 {
continue
}
rows = append(rows, row{f[0], f[1], f[2]})
top[f[1]] = len(rows) - 1
}
seen := map[string]bool{}
var out []string
for i, r := range rows {
if top[r.mount] != i || skipFSType(r.fstype) {
continue
}
if !strings.HasPrefix(r.dev, "/") && r.fstype != "zfs" && r.fstype != "btrfs" {
continue
}
key := r.dev
if r.fstype == "zfs" {
if j := strings.IndexByte(key, '/'); j >= 0 {
key = key[:j]
}
}
if seen[key] {
continue
}
seen[key] = true
out = append(out, strings.ReplaceAll(r.mount, `\040`, " "))
}
return out
}
// diskUsage sums statfs over the mounts: total = f_blocks, used =
// f_blocks - f_bfree (NOT f_bavail — df does not charge the ext reserve).
func diskUsage(mounts []string) (total, used uint64) {
for _, m := range mounts {
var st syscall.Statfs_t
if err := syscall.Statfs(m, &st); err != nil {
continue
}
bs := uint64(st.Frsize)
if bs == 0 {
bs = uint64(st.Bsize)
}
total += st.Blocks * bs
if st.Blocks > st.Bfree {
used += (st.Blocks - st.Bfree) * bs
}
}
return total, used
}
+235
View File
@@ -0,0 +1,235 @@
package agent
import (
"net"
"os"
"path/filepath"
"testing"
"time"
)
// Sample /proc/net/dev with a realistic mix: physical NIC, loopback, docker
// bridge + veth pair, WireGuard tunnel, a VLAN child and a bridge holding
// the host address (vmbr-style stacked name).
const sampleNetDev = `Inter-| Receive | Transmit
face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed
lo: 1000 10 0 0 0 0 0 0 1000 10 0 0 0 0 0 0
eth0: 5000000 5000 0 0 0 0 0 0 3000000 4000 0 0 0 0 0 0
docker0: 999000 900 0 0 0 0 0 0 999000 900 0 0 0 0 0 0
veth1234: 888000 800 0 0 0 0 0 0 888000 800 0 0 0 0 0 0
wg0: 777000 700 0 0 0 0 0 0 777000 700 0 0 0 0 0 0
eth0.100: 666000 600 0 0 0 0 0 0 666000 600 0 0 0 0 0 0
vmbr0: 555000 500 0 0 0 0 0 0 555000 500 0 0 0 0 0 0
`
func TestCountedInterfacesNameLayers(t *testing.T) {
// Empty sysfs root: layers 1+2 (names) must already exclude everything
// but eth0.
counted := countedInterfaces(parseNetDev(sampleNetDev), t.TempDir())
if len(counted) != 1 || counted[0].name != "eth0" {
t.Fatalf("counted = %+v, want only eth0", counted)
}
if counted[0].rx != 5000000 || counted[0].tx != 3000000 {
t.Fatalf("eth0 counters = %d/%d", counted[0].rx, counted[0].tx)
}
}
func TestCountedElsewhereSysfsLayer(t *testing.T) {
root := t.TempDir()
mk := func(name string, files map[string]string, dirs ...string) {
dev := filepath.Join(root, name)
if err := os.MkdirAll(dev, 0o755); err != nil {
t.Fatal(err)
}
for f, content := range files {
if err := os.WriteFile(filepath.Join(dev, f), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
for _, d := range dirs {
if err := os.MkdirAll(filepath.Join(dev, d), 0o755); err != nil {
t.Fatal(err)
}
}
}
// Plain NIC: no uevent devtype, hardware device link present.
mk("eth0", map[string]string{"type": "1", "uevent": ""}, "device")
// A user-named bridge: caught by DEVTYPE alone.
mk("lan0", map[string]string{"type": "1", "uevent": "DEVTYPE=bridge\n"})
// A VM tap under a made-up name: caught as a bridge port.
mk("vnet0", map[string]string{"type": "1", "uevent": ""}, "brport")
// A user-named tunnel (type 768 = ipip, no hardware).
mk("he-ipv6", map[string]string{"type": "768", "uevent": ""})
// Raw-IP LTE modem: tunnel link type (65534) but real hardware -> counts.
mk("wwan0", map[string]string{"type": "65534", "uevent": ""}, "device")
// macvlan child: has a lower_* link to its parent.
mk("mac0", map[string]string{"type": "1", "uevent": ""}, "lower_eth0")
want := map[string]bool{
"eth0": true, "lan0": false, "vnet0": false,
"he-ipv6": false, "wwan0": true, "mac0": false,
}
for name, w := range want {
if got := ifaceCounts(root, name); got != w {
t.Errorf("ifaceCounts(%s) = %v, want %v", name, got, w)
}
}
}
func TestBootEpochDiscipline(t *testing.T) {
base := bootEpoch("boot-1", []string{"eth0", "eth1"})
// Order must not matter (the set is sorted before hashing).
if got := bootEpoch("boot-1", []string{"eth1", "eth0"}); got != base {
t.Fatalf("epoch not order-stable: %s vs %s", got, base)
}
if len(base) != len("boot-1/")+16 {
t.Fatalf("epoch shape wrong: %q", base)
}
// A set change is a new epoch (re-baseline), like the source.
if got := bootEpoch("boot-1", []string{"eth0"}); got == base {
t.Fatal("epoch unchanged after counted set change")
}
// A kernel reboot is a new epoch even with the same set.
if got := bootEpoch("boot-2", []string{"eth0", "eth1"}); got == base {
t.Fatal("epoch unchanged after reboot")
}
// Known vector: FNV-1a-64 of "eth0" (offset basis, prime per hash/fnv).
if got := bootEpoch("b", []string{"eth0"}); got != "b/"+fnvEth0 {
t.Fatalf("epoch digest = %s, want b/%s", got, fnvEth0)
}
}
const fnvEth0 = "c41bf7607a25a404" // hash/fnv FNV-1a-64 over "eth0"
func TestNetRateBaselineAndSaturation(t *testing.T) {
c := &probeCollector{}
t0 := time.Unix(1000, 0)
// First sample only establishes the baseline.
if rx, tx := c.netRate([]netCounters{{"eth0", 1000, 2000}}, t0); rx != 0 || tx != 0 {
t.Fatalf("first sample rate = %d/%d, want 0/0", rx, tx)
}
// Second sample 10s later: +10000/+5000 bytes -> 1000/500 B/s.
if rx, tx := c.netRate([]netCounters{{"eth0", 11000, 7000}}, t0.Add(10*time.Second)); rx != 1000 || tx != 500 {
t.Fatalf("rate = %d/%d, want 1000/500", rx, tx)
}
// A counter going backwards (restart) saturates to 0, never wraps.
if rx, tx := c.netRate([]netCounters{{"eth0", 5, 5}}, t0.Add(20*time.Second)); rx != 0 || tx != 0 {
t.Fatalf("regressed rate = %d/%d, want 0/0", rx, tx)
}
// An interface joining later contributes nothing in its first sample.
rx, _ := c.netRate([]netCounters{{"eth0", 105, 5}, {"eth1", 999999, 0}}, t0.Add(30*time.Second))
if rx != 10 {
t.Fatalf("rate with joining iface = %d, want 10", rx)
}
}
func TestMemUsedFreeSemantics(t *testing.T) {
// free口径: used = total - available (cache not charged as used).
total, used := memUsedFree(map[string]uint64{
"MemTotal": 1000, "MemAvailable": 400, "MemFree": 100, "Buffers": 50, "Cached": 200,
})
if total != 1000 || used != 600 {
t.Fatalf("got %d/%d, want 1000/600", total, used)
}
// No MemAvailable (old kernels): Free+Buffers+Cached fallback.
_, used = memUsedFree(map[string]uint64{
"MemTotal": 1000, "MemFree": 100, "Buffers": 50, "Cached": 200,
})
if used != 650 {
t.Fatalf("fallback used = %d, want 650", used)
}
// LXCFS can report available > total: fall back to total - MemFree.
_, used = memUsedFree(map[string]uint64{
"MemTotal": 1000, "MemAvailable": 5000, "MemFree": 250,
})
if used != 750 {
t.Fatalf("lxcfs used = %d, want 750", used)
}
// Swap: total - free, SwapCached NOT subtracted.
st, su := swapUsedFree(map[string]uint64{"SwapTotal": 500, "SwapFree": 125})
if st != 500 || su != 375 {
t.Fatalf("swap = %d/%d, want 500/375", st, su)
}
}
func TestParseMountsDfSemantics(t *testing.T) {
mounts := `
sysfs /sys sysfs rw 0 0
/dev/sda1 / ext4 rw 0 0
tmpfs /run tmpfs rw 0 0
/dev/sda1 /mnt/bind ext4 rw 0 0
/dev/sdb1 /data ext4 rw 0 0
/dev/sdb1 /data/bind ext4 rw 0 0
server:/export /mnt/nfs nfs rw 0 0
overlay /var/lib/docker overlay rw 0 0
tank/home /home zfs rw 0 0
tank/data /srv zfs rw 0 0
/dev/sdc1 /shadow ext4 rw 0 0
tmpfs /shadow tmpfs rw 0 0
`
got := realMountPoints(mounts)
// / (sda1) and /data (sdb1) dedupe their binds; zfs dedupes by pool;
// /shadow's real fs is shadowed by a later tmpfs so it drops out.
want := []string{"/", "/data", "/home"}
if len(got) != len(want) {
t.Fatalf("mounts = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("mounts = %v, want %v", got, want)
}
}
}
func TestConnCountsSockstat(t *testing.T) {
v4 := "sockets: used 100\nTCP: inuse 10 orphan 0 tw 5 alloc 20 mem 1\nUDP: inuse 7 mem 0\n"
v6 := "TCP6: inuse 3\nUDP6: inuse 2\n"
tcp, udp := connCounts(v4, v6)
// tw counted once (v4 line serves both families): 10+5+3; udp 7+2.
if tcp != 18 || udp != 9 {
t.Fatalf("tcp/udp = %d/%d, want 18/9", tcp, udp)
}
}
func TestCPUPercentDiff(t *testing.T) {
c := &probeCollector{}
if got := c.cpuPercent(&cpuSample{total: 1000, idle: 800}); got != 0 {
t.Fatalf("first sample = %v, want 0", got)
}
// +1000 total, +600 idle -> 40% busy.
got := c.cpuPercent(&cpuSample{total: 2000, idle: 1400})
if got < 39.9 || got > 40.1 {
t.Fatalf("cpu = %v, want ~40", got)
}
}
func parseIP(t *testing.T, s string) net.IP {
t.Helper()
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("bad test ip %q", s)
}
return ip
}
func TestIsPublicIP(t *testing.T) {
cases := map[string]bool{
"8.8.8.8": true,
"192.168.1.1": false,
"100.64.0.1": false, // CGNAT
"198.18.0.1": false, // TUN fake-IP range
"192.0.0.9": false,
"224.0.0.1": false,
"2001:db8::1": true,
"fd00::1": false, // ULA
"fe80::1": false,
"2606:4700::1": true, // public by range; WARP exclusion lives in lendsAddress
"::1": false,
}
for s, want := range cases {
ip := parseIP(t, s)
if got := isPublicIP(ip); got != want {
t.Errorf("isPublicIP(%s) = %v, want %v", s, got, want)
}
}
}
+34
View File
@@ -55,6 +55,40 @@ type AgentNode struct {
// a telemetry object.
TelemetryAt int64 `json:"telemetryAt"`
// Probe snapshot (item 7, B1): the monitor-probe-style host Facts and
// latest Metrics the agent piggybacks on register + heartbeat. Only the
// latest values live here; history lands in B2's probe tables. Agents
// that never reported a probe block keep ProbeAt == 0 and zero values.
ProbeHostname string `json:"probeHostname"`
ProbeOS string `json:"probeOs"`
ProbeKernel string `json:"probeKernel"`
ProbeArch string `json:"probeArch"`
ProbeVirt string `json:"probeVirt"`
ProbeCPUName string `json:"probeCpuName"`
ProbeCPUCores uint32 `json:"probeCpuCores"`
ProbeBootID string `json:"probeBootId"`
ProbeIface string `json:"probeIface"`
ProbeLoad1 float32 `json:"probeLoad1"`
ProbeLoad5 float32 `json:"probeLoad5"`
ProbeLoad15 float32 `json:"probeLoad15"`
ProbeMemTotal uint64 `json:"probeMemTotal"`
ProbeMemUsed uint64 `json:"probeMemUsed"`
ProbeSwapTotal uint64 `json:"probeSwapTotal"`
ProbeSwapUsed uint64 `json:"probeSwapUsed"`
ProbeDiskTotal uint64 `json:"probeDiskTotal"`
ProbeDiskUsed uint64 `json:"probeDiskUsed"`
ProbeNetRxTot uint64 `json:"probeNetRxTotal"`
ProbeNetTxTot uint64 `json:"probeNetTxTotal"`
ProbeNetRx uint64 `json:"probeNetRx"`
ProbeNetTx uint64 `json:"probeNetTx"`
ProbeTCP uint32 `json:"probeTcp"`
ProbeUDP uint32 `json:"probeUdp"`
ProbeProcs uint32 `json:"probeProcs"`
ProbeCPU float32 `json:"probeCpu"`
// ProbeAt is the unix-milli time of the last heartbeat that carried a
// probe metrics block.
ProbeAt int64 `json:"probeAt"`
// Seen source addresses (public-address resolution): the source IP of
// the agent's own authenticated requests, as observed by the master —
// the CF-Connecting-IP header when the agent comes through Cloudflare,
+259 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/selfupdate"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"github.com/gin-gonic/gin"
@@ -187,8 +188,9 @@ func (a *AgentController) touchAgent(id uint, name string) {
// POST /panel/api/agent/tokens; the agent only claims its token here.
func (a *AgentController) register(c *gin.Context) {
var req struct {
Token string `json:"token"`
Name string `json:"name"`
Token string `json:"token"`
Name string `json:"name"`
ProbeFacts *heartbeatProbeFacts `json:"probeFacts"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Token == "" {
jsonMsg(c, "Token required", errRequest)
@@ -201,6 +203,9 @@ func (a *AgentController) register(c *gin.Context) {
}
a.captureSeenIP(c, agent)
a.touchAgent(uint(agent.Id), req.Name)
if req.ProbeFacts != nil {
a.updateAgentProbe(uint(agent.Id), req.ProbeFacts, nil)
}
logger.Infof("Agent registered: %s (token %s...)", req.Name, agent.TokenPrefix)
jsonObj(c, gin.H{"message": "registered"}, nil)
}
@@ -251,6 +256,195 @@ func (a *AgentController) updateAgentTelemetry(id uint, t *heartbeatTelemetry) {
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
}
// heartbeatProbeFacts / heartbeatProbeMetrics are the monitor-probe-style
// host snapshot an agent piggybacks on register and heartbeat (item 7 B1).
// Every field is a pointer for the same reason as telemetry: an absent
// field means "not reported" and must not overwrite the stored value, so
// old agents (no probe block at all) and partial reports both merge
// cleanly. JSON names must match internal/agent's ProbeFacts/ProbeMetrics.
type heartbeatProbeFacts struct {
Hostname *string `json:"hostname"`
OS *string `json:"os"`
Kernel *string `json:"kernel"`
Arch *string `json:"arch"`
Virt *string `json:"virt"`
CPUName *string `json:"cpuName"`
CPUCores *uint32 `json:"cpuCores"`
MemTotal *uint64 `json:"memTotal"`
SwapTotal *uint64 `json:"swapTotal"`
DiskTotal *uint64 `json:"diskTotal"`
}
type heartbeatProbeMetrics struct {
BootID *string `json:"bootId"`
Iface *string `json:"iface"`
Uptime *uint64 `json:"uptime"`
Load *[3]float32 `json:"load"`
MemTotal *uint64 `json:"memTotal"`
MemUsed *uint64 `json:"memUsed"`
SwapTotal *uint64 `json:"swapTotal"`
SwapUsed *uint64 `json:"swapUsed"`
DiskTotal *uint64 `json:"diskTotal"`
DiskUsed *uint64 `json:"diskUsed"`
NetRxTotal *uint64 `json:"netRxTotal"`
NetTxTotal *uint64 `json:"netTxTotal"`
NetRx *uint64 `json:"netRx"`
NetTx *uint64 `json:"netTx"`
TCP *uint32 `json:"tcp"`
UDP *uint32 `json:"udp"`
Procs *uint32 `json:"procs"`
CPU *float32 `json:"cpu"`
}
// updateAgentProbe persists the reported probe facts/metrics onto the agent
// row. Only fields actually present in the report are written. Facts and
// metrics share one update so a heartbeat carries a single write; register
// calls it with metrics == nil.
func (a *AgentController) updateAgentProbe(id uint, f *heartbeatProbeFacts, m *heartbeatProbeMetrics) {
updates := map[string]any{}
if f != nil {
if f.Hostname != nil {
updates["probe_hostname"] = *f.Hostname
}
if f.OS != nil {
updates["probe_os"] = *f.OS
}
if f.Kernel != nil {
updates["probe_kernel"] = *f.Kernel
}
if f.Arch != nil {
updates["probe_arch"] = *f.Arch
}
if f.Virt != nil {
updates["probe_virt"] = *f.Virt
}
if f.CPUName != nil {
updates["probe_cpu_name"] = *f.CPUName
}
if f.CPUCores != nil {
updates["probe_cpu_cores"] = *f.CPUCores
}
if f.MemTotal != nil {
updates["probe_mem_total"] = *f.MemTotal
}
if f.SwapTotal != nil {
updates["probe_swap_total"] = *f.SwapTotal
}
if f.DiskTotal != nil {
updates["probe_disk_total"] = *f.DiskTotal
}
}
if m != nil {
updates["probe_at"] = time.Now().UnixMilli()
if m.BootID != nil {
updates["probe_boot_id"] = *m.BootID
}
if m.Iface != nil {
updates["probe_iface"] = *m.Iface
}
if m.Load != nil {
updates["probe_load1"] = (*m.Load)[0]
updates["probe_load5"] = (*m.Load)[1]
updates["probe_load15"] = (*m.Load)[2]
}
if m.MemTotal != nil {
updates["probe_mem_total"] = *m.MemTotal
}
if m.MemUsed != nil {
updates["probe_mem_used"] = *m.MemUsed
}
if m.SwapTotal != nil {
updates["probe_swap_total"] = *m.SwapTotal
}
if m.SwapUsed != nil {
updates["probe_swap_used"] = *m.SwapUsed
}
if m.DiskTotal != nil {
updates["probe_disk_total"] = *m.DiskTotal
}
if m.DiskUsed != nil {
updates["probe_disk_used"] = *m.DiskUsed
}
if m.NetRxTotal != nil {
updates["probe_net_rx_tot"] = *m.NetRxTotal
}
if m.NetTxTotal != nil {
updates["probe_net_tx_tot"] = *m.NetTxTotal
}
if m.NetRx != nil {
updates["probe_net_rx"] = *m.NetRx
}
if m.NetTx != nil {
updates["probe_net_tx"] = *m.NetTx
}
if m.TCP != nil {
updates["probe_tcp"] = *m.TCP
}
if m.UDP != nil {
updates["probe_udp"] = *m.UDP
}
if m.Procs != nil {
updates["probe_procs"] = *m.Procs
}
if m.CPU != nil {
updates["probe_cpu"] = *m.CPU
}
}
if len(updates) == 0 {
return
}
_ = database.GetDB().Model(&model.AgentNode{}).Where("id = ?", id).Updates(updates).Error
}
// probeSample flattens a heartbeat metrics block into the probe
// package's Sample. Absent fields become zero values — the agent sends
// a full block whenever it sends one, and the probe store treats the
// block's presence (not per-field presence) as the signal, mirroring
// the hub's report frames.
func probeSample(m *heartbeatProbeMetrics) probe.Sample {
var s probe.Sample
if m.BootID != nil {
s.BootID = *m.BootID
}
if m.Uptime != nil {
s.Uptime = *m.Uptime
}
if m.CPU != nil {
s.CPU = *m.CPU
}
if m.MemUsed != nil {
s.MemUsed = *m.MemUsed
}
if m.SwapUsed != nil {
s.SwapUsed = *m.SwapUsed
}
if m.DiskUsed != nil {
s.DiskUsed = *m.DiskUsed
}
if m.NetRxTotal != nil {
s.NetRxTotal = *m.NetRxTotal
}
if m.NetTxTotal != nil {
s.NetTxTotal = *m.NetTxTotal
}
if m.NetRx != nil {
s.NetRx = *m.NetRx
}
if m.NetTx != nil {
s.NetTx = *m.NetTx
}
if m.TCP != nil {
s.TCP = *m.TCP
}
if m.UDP != nil {
s.UDP = *m.UDP
}
if m.Procs != nil {
s.Procs = *m.Procs
}
return s
}
// heartbeatUpdateStatus is the agent's report about a one-click update
// intent (item 20), piggybacked on the heartbeat like telemetry.
type heartbeatUpdateStatus struct {
@@ -318,6 +512,23 @@ func (a *AgentController) confirmAgentUpdate(id uint, reportedVersion string) {
}).Error
}
// heartbeatProbeResult is one latency sample an agent piggybacks on
// its heartbeat (item 7 B4). LatencyMs is a pointer: an absent field
// means "no sample" (a resolution overrun produces none) and the row
// is skipped, while a present negative value is a genuine recorded
// miss — the two must never collapse into each other.
type heartbeatProbeResult struct {
TaskID int64 `json:"taskId"`
LatencyMs *int64 `json:"latencyMs"`
}
// maxProbeResultsPerHeartbeat caps one heartbeat's probe batch. The
// agent's buffer holds far less than this in practice (64 tasks × 60 s
// at the fastest legal interval is under a thousand samples); the cap
// exists so a buggy or hostile reporter cannot make one request file
// an unbounded batch.
const maxProbeResultsPerHeartbeat = 2048
func (a *AgentController) heartbeat(c *gin.Context) {
agent, ok := a.findAgent(bearerToken(c))
if !ok {
@@ -330,10 +541,43 @@ func (a *AgentController) heartbeat(c *gin.Context) {
var req struct {
Telemetry *heartbeatTelemetry `json:"telemetry"`
UpdateStatus *heartbeatUpdateStatus `json:"updateStatus"`
ProbeFacts *heartbeatProbeFacts `json:"probeFacts"`
ProbeMetrics *heartbeatProbeMetrics `json:"probeMetrics"`
ProbeResults []heartbeatProbeResult `json:"probeResults"`
}
_ = c.ShouldBindJSON(&req)
// The last-seen stamp as it stood before this heartbeat: the probe
// ledger's unseen-boot rule measures a reboot against it, so it
// must be captured before touchAgent overwrites it.
prevLastSeen := agent.LastSeen
a.touchAgent(uint(agent.Id), "")
reportedVersion := agent.AgentVersion
if req.ProbeFacts != nil || req.ProbeMetrics != nil {
a.updateAgentProbe(uint(agent.Id), req.ProbeFacts, req.ProbeMetrics)
}
if req.ProbeMetrics != nil {
// Item 7 B2: file the sample into probe.db history + the
// whole-machine traffic ledger. Best-effort by design — the
// probe package logs and swallows its own failures.
probe.Record(int64(agent.Id), probeSample(req.ProbeMetrics), prevLastSeen)
}
if len(req.ProbeResults) > 0 {
// Item 7 B4: file the latency batch, stamped at arrival (hub
// semantics). Results ride the heartbeat rather than the
// traffic report on purpose: probing must keep working while
// xray is down, and the traffic report early-returns then.
results := make([]probe.PingResult, 0, len(req.ProbeResults))
for _, r := range req.ProbeResults {
if r.TaskID <= 0 || r.LatencyMs == nil {
continue
}
results = append(results, probe.PingResult{TaskID: r.TaskID, LatencyMs: *r.LatencyMs})
if len(results) >= maxProbeResultsPerHeartbeat {
break
}
}
probe.InsertPings(int64(agent.Id), results)
}
if req.Telemetry != nil {
a.updateAgentTelemetry(uint(agent.Id), req.Telemetry)
if req.Telemetry.AgentVersion != nil && *req.Telemetry.AgentVersion != "" {
@@ -373,6 +617,12 @@ func (a *AgentController) getConfig(c *gin.Context) {
payload := gin.H{
"config_hash": hex.EncodeToString(sum[:]),
"xray_config": json.RawMessage(raw),
// Item 7 B4: the agent's probe task set rides the existing
// config poll (no new channel). Always present from this
// master version — an empty list means "run no probes", while
// an absent field (older master) leaves the agent's current
// set untouched.
"probe_tasks": probe.PingTasksFor(int64(agent.Id)),
}
// ITEM6 B2: the sing-box shape rides alongside the xray config
// (parallel generation path; agents on the current runtime ignore
@@ -575,6 +825,13 @@ func (a *AgentController) listTokens(c *gin.Context) {
func (a *AgentController) deleteToken(c *gin.Context) {
_ = database.GetDB().Delete(&model.AgentNode{}, "id = ?", c.Param("id")).Error
// Item 7 B3: the agent's probe data dies with its row (hub
// delete_node cascades the same set, metrics and traffic
// included) and the shared snapshot drops it at once.
if id, err := strconv.Atoi(c.Param("id")); err == nil {
probe.DeleteAgentData(int64(id))
service.InvalidateProbeSnapshot()
}
jsonObj(c, gin.H{"message": "deleted"}, nil)
}
+118
View File
@@ -0,0 +1,118 @@
package controller
import (
"bytes"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// Item 7 B1: the heartbeat/register optionally carry a monitor-probe-style
// probe snapshot (facts + metrics). Reported fields land on the agent row;
// a heartbeat without a probe block (old agents) must not clear stored
// values, and a partial block only overwrites what it carries.
func TestAgentHeartbeatProbe(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
const token = "probe-test-token"
agent := &model.AgentNode{
TokenHash: hashAgentToken(token),
TokenPrefix: "probe-",
Name: "probe-node",
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
post := func(path, body string) int {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec.Code
}
load := func() model.AgentNode {
var row model.AgentNode
if err := db.First(&row, agent.Id).Error; err != nil {
t.Fatalf("reload agent: %v", err)
}
return row
}
facts := `"probeFacts":{"hostname":"host-1","os":"Debian GNU/Linux 13","kernel":"6.12.1",` +
`"arch":"amd64","virt":"kvm","cpuName":"Test CPU","cpuCores":4,` +
`"memTotal":4000000000,"swapTotal":1000000000,"diskTotal":8000000000,` +
`"agentVersion":"A1.0.1","ipv4":"203.0.113.9","ipv6":"2001:db8::9"}`
metrics := `"probeMetrics":{"bootId":"boot-x/0123456789abcdef","iface":"",` +
`"uptime":3600,"cpu":12.5,"load":[0.5,0.25,0.125],` +
`"memTotal":4000000000,"memUsed":1500000000,"swapTotal":1000000000,"swapUsed":5000000,` +
`"diskTotal":8000000000,"diskUsed":3000000000,` +
`"netRxTotal":111000,"netTxTotal":222000,"netRx":1000,"netTx":2000,` +
`"tcp":42,"udp":7,"procs":123}`
// Register carries facts only.
if code := post("/panel/api/agent/register",
`{"token":"`+token+`","name":"probe-node",`+facts+`}`); code != http.StatusOK {
t.Fatalf("register with facts: status %d", code)
}
row := load()
if row.ProbeHostname != "host-1" || row.ProbeVirt != "kvm" || row.ProbeCPUCores != 4 ||
row.ProbeMemTotal != 4000000000 || row.ProbeDiskTotal != 8000000000 {
t.Fatalf("register facts not stored: %+v", row)
}
if row.ProbeAt != 0 {
t.Fatalf("register must not stamp ProbeAt (no metrics yet): %d", row.ProbeAt)
}
// Heartbeat with facts+metrics stores the latest snapshot.
if code := post("/panel/api/agent/heartbeat",
`{"token":"`+token+`","timestamp":1,`+facts+`,`+metrics+`}`); code != http.StatusOK {
t.Fatalf("heartbeat with probe: status %d", code)
}
row = load()
if row.ProbeBootID != "boot-x/0123456789abcdef" || row.ProbeMemUsed != 1500000000 ||
row.ProbeDiskUsed != 3000000000 || row.ProbeNetRxTot != 111000 || row.ProbeNetTxTot != 222000 ||
row.ProbeNetRx != 1000 || row.ProbeNetTx != 2000 || row.ProbeTCP != 42 || row.ProbeUDP != 7 ||
row.ProbeProcs != 123 || row.ProbeLoad1 != 0.5 || row.ProbeLoad15 != 0.125 || row.ProbeAt == 0 {
t.Fatalf("heartbeat probe not stored: %+v", row)
}
if row.ProbeCPU < 12.4 || row.ProbeCPU > 12.6 {
t.Fatalf("probe cpu = %v, want 12.5", row.ProbeCPU)
}
// Bare heartbeat (old agent): stored probe values survive untouched.
if code := post("/panel/api/agent/heartbeat", `{"token":"`+token+`","timestamp":2}`); code != http.StatusOK {
t.Fatalf("bare heartbeat: status %d", code)
}
row = load()
if row.ProbeBootID != "boot-x/0123456789abcdef" || row.ProbeMemUsed != 1500000000 || row.ProbeHostname != "host-1" {
t.Fatalf("bare heartbeat cleared probe: %+v", row)
}
// Partial metrics: only the carried field changes.
if code := post("/panel/api/agent/heartbeat",
`{"token":"`+token+`","timestamp":3,"probeMetrics":{"netRx":7777}}`); code != http.StatusOK {
t.Fatalf("partial heartbeat: status %d", code)
}
row = load()
if row.ProbeNetRx != 7777 || row.ProbeNetTx != 2000 || row.ProbeMemUsed != 1500000000 {
t.Fatalf("partial probe merged wrong: %+v", row)
}
}
+14
View File
@@ -194,6 +194,20 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
// never reach these.
agentAdmin := api.Group("/agent")
a.agentController.RegisterAdminRoutes(agentAdmin)
// Item 7 B3: probe metadata editing rides the same admin group
// (/panel/api/agent/probe-meta/:nodeId).
NewProbeMetaController(agentAdmin)
// Item 7 B4: probe task (latency target) management, same group
// (/panel/api/agent/probe-tasks*).
NewProbeTasksController(agentAdmin)
// Item 7 B5: theme store + public-surface settings, on the API
// group itself (/panel/api/probe/*).
NewProbeThemesController(api)
// Item 7 B6: notification settings + test send + cf-probe
// retirement coverage, same group (/panel/api/probe/notify*).
NewProbeNotifyController(api)
// Hosts API — per-inbound override endpoints for subscription links
hosts := api.Group("/hosts")
+5
View File
@@ -9,6 +9,7 @@ import (
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probenotify"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -124,6 +125,10 @@ func (a *IndexController) login(c *gin.Context) {
return
}
// Item 7 B6: the probe notification system's sign-in alert
// (queued, non-blocking; notify_login gates it inside).
probenotify.SignedIn("密码登录", remoteIP)
logger.Infof("%s logged in successfully", safeUser)
jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
}
+261
View File
@@ -0,0 +1,261 @@
package controller
import (
"fmt"
"math"
"net"
"strconv"
"strings"
"time"
"unicode"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/gin-gonic/gin"
)
// Item 7 B3: probe metadata editing (minimal set). The fields are the
// operator-owned display/plan data of probe_meta (ITEM7-SCOPE §5.2),
// edited from the node page's agent edit dialog and saved through the
// panel-authenticated agent admin group — effective immediately (the
// snapshot cache is invalidated; no restart anywhere). Validation
// mirrors the hub's patch_error chain field for field.
type ProbeMetaController struct{}
func NewProbeMetaController(g *gin.RouterGroup) *ProbeMetaController {
pc := &ProbeMetaController{}
g.GET("/probe-meta/:nodeId", pc.getMeta)
g.PUT("/probe-meta/:nodeId", pc.putMeta)
return pc
}
// probeMetaForm is the wire shape of the edit form: the full meta row,
// replaced wholesale on save.
type probeMetaForm struct {
Price float64 `json:"price"`
Currency string `json:"currency"`
BillingCycle string `json:"billingCycle"`
ExpiresAt *string `json:"expiresAt"`
TrafficLimit int64 `json:"trafficLimit"`
TrafficMode string `json:"trafficMode"`
TrafficResetDay int64 `json:"trafficResetDay"`
Public bool `json:"public"`
Sort int64 `json:"sort"`
Group string `json:"group"`
PublicRemark string `json:"publicRemark"`
Notify bool `json:"notify"`
CountryPin string `json:"countryPin"`
IPv4Pin string `json:"ipv4Pin"`
IPv6Pin string `json:"ipv6Pin"`
}
func metaToForm(m probe.Meta) probeMetaForm {
return probeMetaForm{
Price: m.Price,
Currency: m.Currency,
BillingCycle: m.BillingCycle,
ExpiresAt: m.ExpiresAt,
TrafficLimit: m.TrafficLimit,
TrafficMode: m.TrafficMode,
TrafficResetDay: m.TrafficResetDay,
Public: m.Public,
Sort: m.Sort,
Group: m.GroupName,
PublicRemark: m.PublicRemark,
Notify: m.Notify,
CountryPin: m.CountryPin,
IPv4Pin: m.IPv4Pin,
IPv6Pin: m.IPv6Pin,
}
}
func (f probeMetaForm) toMeta(agentID int64) probe.Meta {
return probe.Meta{
AgentID: agentID,
Price: f.Price,
Currency: f.Currency,
BillingCycle: f.BillingCycle,
ExpiresAt: f.ExpiresAt,
TrafficLimit: f.TrafficLimit,
TrafficMode: f.TrafficMode,
TrafficResetDay: f.TrafficResetDay,
Public: f.Public,
Sort: f.Sort,
GroupName: f.Group,
PublicRemark: f.PublicRemark,
Notify: f.Notify,
CountryPin: f.CountryPin,
IPv4Pin: f.IPv4Pin,
IPv6Pin: f.IPv6Pin,
}
}
// agentForNode resolves the agent bound to a panel node row.
func agentForNode(c *gin.Context) (model.AgentNode, bool) {
nodeID, err := strconv.Atoi(c.Param("nodeId"))
if err != nil {
return model.AgentNode{}, false
}
var agent model.AgentNode
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
return model.AgentNode{}, false
}
return agent, true
}
func (pc *ProbeMetaController) getMeta(c *gin.Context) {
agent, ok := agentForNode(c)
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
meta, found, err := probe.MetaFor(int64(agent.Id))
if err != nil {
jsonMsg(c, "load probe meta failed", err)
return
}
if !found {
meta = probe.DefaultMeta(int64(agent.Id))
}
jsonObj(c, metaToForm(meta), nil)
}
func (pc *ProbeMetaController) putMeta(c *gin.Context) {
agent, ok := agentForNode(c)
if !ok {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
var form probeMetaForm
if err := c.ShouldBindJSON(&form); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if msg := validateProbeMetaForm(&form); msg != "" {
jsonMsg(c, msg, errRequest)
return
}
if err := probe.SaveMeta(form.toMeta(int64(agent.Id))); err != nil {
jsonMsg(c, "save probe meta failed", err)
return
}
service.InvalidateProbeSnapshot()
jsonObj(c, gin.H{"message": "ok"}, nil)
}
// ---- hub patch_error ports ----
func hasControl(s string) bool {
for _, r := range s {
if unicode.IsControl(r) {
return true
}
}
return false
}
var namedCycles = map[string]int64{
"monthly": 1, "quarterly": 3, "semiannual": 6,
"yearly": 12, "biennial": 24, "triennial": 36,
}
func cycleMonths(cycle string) (int64, bool) {
if m, ok := namedCycles[cycle]; ok {
return m, true
}
if strings.HasSuffix(cycle, "m") {
if n, err := strconv.ParseInt(strings.TrimSuffix(cycle, "m"), 10, 64); err == nil && n >= 1 && n <= 1200 {
return n, true
}
}
return 0, false
}
func cycleName(months int64) string {
for name, m := range namedCycles {
if m == months {
return name
}
}
return fmt.Sprintf("%dm", months)
}
// validateProbeMetaForm normalizes the form in place (hub patch_error
// order: group, public remark, limits, billing, pins) and returns the
// first refusal's message, "" when the form may be stored.
func validateProbeMetaForm(f *probeMetaForm) string {
f.Group = strings.TrimSpace(f.Group)
if len([]rune(f.Group)) > 13 || hasControl(f.Group) {
return "分组名最多 13 个字,不能含控制字符"
}
f.PublicRemark = strings.TrimSpace(f.PublicRemark)
if len([]rune(f.PublicRemark)) > 100 || hasControl(f.PublicRemark) {
return "公开备注最多 100 个字,不能含控制字符"
}
if f.TrafficResetDay < 1 || f.TrafficResetDay > 31 {
return "流量重置日要在 1 到 31 之间"
}
if math.IsNaN(f.Price) || math.IsInf(f.Price, 0) || f.Price < 0 || f.TrafficLimit < 0 {
return "价格和流量上限不能是负数"
}
f.Currency = strings.ToUpper(strings.TrimSpace(f.Currency))
if len(f.Currency) != 3 || !isUpperASCII(f.Currency) {
return "货币要填三个字母的代码,如 USD、CNY、HKD"
}
if f.BillingCycle != "once" {
months, ok := cycleMonths(f.BillingCycle)
if !ok {
return "付款周期要是整月,在 1 个月到 100 年之间"
}
f.BillingCycle = cycleName(months)
}
f.CountryPin = strings.ToUpper(strings.TrimSpace(f.CountryPin))
if f.CountryPin != "" && (len(f.CountryPin) != 2 || !isUpperASCII(f.CountryPin)) {
return "国家要填两个字母的代码,留空则自动识别"
}
for _, pin := range []struct {
val *string
v6 bool
}{
{&f.IPv4Pin, false},
{&f.IPv6Pin, true},
} {
typed := strings.TrimSpace(*pin.val)
if typed == "" {
*pin.val = ""
continue
}
ip := net.ParseIP(typed)
if ip == nil || (ip.To4() == nil) != pin.v6 {
if pin.v6 {
return "IPv6 要填一个 IPv6 地址,或者留空"
}
return "IPv4 要填一个 IPv4 地址,或者留空"
}
*pin.val = ip.String()
}
if f.ExpiresAt != nil {
v := strings.TrimSpace(*f.ExpiresAt)
if v == "" {
f.ExpiresAt = nil
} else if _, err := time.ParseInLocation("2006-01-02", v, time.Local); err != nil {
return "到期日期格式应为 YYYY-MM-DD"
} else {
f.ExpiresAt = &v
}
}
return ""
}
func isUpperASCII(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] < 'A' || s[i] > 'Z' {
return false
}
}
return true
}
+73
View File
@@ -0,0 +1,73 @@
package controller
import (
"encoding/json"
"net/http"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probenotify"
"github.com/gin-gonic/gin"
)
// Item 7 B6: the notification settings surface — the notify_* block
// (offline grace, traffic step, expiry window, login alerts, the
// webhook channel and the two message templates), a test send, and
// the cf-probe retirement coverage the operator checks before
// standing the old probe fleet down. Like the rest of the probe
// surfaces these endpoints answer in the panel's Msg envelope and
// take effect immediately; validation refusals are operator-facing
// text, shown verbatim.
type ProbeNotifyController struct{}
func NewProbeNotifyController(g *gin.RouterGroup) *ProbeNotifyController {
pc := &ProbeNotifyController{}
g.GET("/probe/notify", pc.get)
g.PUT("/probe/notify", pc.put)
g.POST("/probe/notify/test", pc.test)
return pc
}
type probeNotifyPayload struct {
Settings probenotify.SettingsView `json:"settings"`
Coverage []service.ProbeCoverageRow `json:"coverage"`
}
func (pc *ProbeNotifyController) get(c *gin.Context) {
jsonObj(c, probeNotifyPayload{
Settings: probenotify.GetSettings(),
Coverage: service.ProbeAgentCoverage(),
}, nil)
}
func (pc *ProbeNotifyController) put(c *gin.Context) {
var body map[string]json.RawMessage
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "请求格式不对"})
return
}
if err := probenotify.SaveSettings(body); err != nil {
// SaveSettings validates everything before writing anything;
// its errors are the operator's answer (a range, a malformed
// header block), not machine faults.
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: err.Error()})
return
}
// Answer with the fresh view, like the GET: the tab re-renders
// the channel status and secret-set flags from it.
jsonObj(c, probeNotifyPayload{
Settings: probenotify.GetSettings(),
Coverage: service.ProbeAgentCoverage(),
}, nil)
}
func (pc *ProbeNotifyController) test(c *gin.Context) {
sent, err := probenotify.Test()
if err != nil {
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: err.Error()})
return
}
jsonObj(c, gin.H{"sent": sent}, nil)
}
+382
View File
@@ -0,0 +1,382 @@
package controller
import (
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
)
// Item 7 B3: the public status surface — the monitor hub's contract
// API, hosted by the panel process itself at the engine ROOT (outside
// the panel's secret basePath, or themes' root-relative /api calls
// could never reach it; ITEM7-SCOPE §5.5). Field shapes follow hub
// src/api.rs exactly: the five endpoints' payloads are the bare
// contract JSON, NOT the panel's Msg envelope.
//
// Audience discipline mirrors the hub: a request is "authed" (admin
// rendering) when it carries a live panel session; anonymous callers
// see only public=1 nodes and only while the public page is on, and
// no rendering ever carries ip/hostname/remark/token material.
// historySlots bounds concurrent history builds (hub HISTORY_GATE=4):
// refused, not queued, once full.
var historySlots = make(chan struct{}, 4)
// publicStreams counts open anonymous /api/ws streams (hub
// PUBLIC_STREAMS=1000); admin streams are not counted.
var publicStreams atomic.Int64
const maxThemeConfigBytes = 64 * 1024
type PublicProbeController struct {
upgrader ws.Upgrader
}
// NewPublicProbeController registers the contract routes on the
// engine root and returns the controller (web.go also calls its
// HandleFallback from the engine's NoRoute).
func NewPublicProbeController(engine *gin.Engine) *PublicProbeController {
pc := &PublicProbeController{
upgrader: ws.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
// Session trust for the stream is decided per tick from
// the panel session, not from Origin; the hub accepts
// any origin for the anonymous audience by design.
CheckOrigin: func(r *http.Request) bool { return true },
},
}
engine.GET("/api/me", pc.me)
engine.GET("/api/nodes", pc.nodes)
engine.GET("/api/nodes/:id/metrics", pc.nodeMetrics)
engine.GET("/api/ws", pc.liveWS)
engine.GET("/api/themes/:short/config", pc.themeConfig)
engine.PUT("/api/themes/:short/config", pc.saveThemeConfig)
return pc
}
func probeAuthed(c *gin.Context) bool {
return session.IsLogin(c)
}
// fromOtherOrigin mirrors hub from_other_origin: a browser request
// whose Sec-Fetch-Site is present and not "same-origin" did not come
// from this panel's own pages (a sibling subdomain is same-site but
// not same-origin, and a WebSocket is not held back by CORS).
func fromOtherOrigin(c *gin.Context) bool {
site := c.GetHeader("Sec-Fetch-Site")
return site != "" && site != "same-origin"
}
func answerText(c *gin.Context, status int, text string) {
c.String(status, "%s", text)
}
func (pc *PublicProbeController) me(c *gin.Context) {
c.JSON(http.StatusOK, struct {
Authed bool `json:"authed"`
GitHub bool `json:"github"`
SiteName string `json:"site_name"`
PublicPage bool `json:"public_page"`
HistoryDays int64 `json:"history_days"`
Site string `json:"site"`
}{
Authed: probeAuthed(c),
GitHub: service.ProbeGitHubConfigured(),
SiteName: service.ProbeSiteName(),
PublicPage: service.ProbePublicPageEnabled(),
HistoryDays: probe.RetentionDays(),
Site: service.ProbeSiteURL(),
})
}
func (pc *PublicProbeController) nodes(c *gin.Context) {
full := probeAuthed(c)
if !full && !service.ProbePublicPageEnabled() {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
if full {
// The admin frame carries every node's addresses and private
// notes; keep it out of every cache (hub discipline).
c.Header("Cache-Control", "no-store, no-transform")
}
// The engine's gzip middleware compresses for clients that accept
// it; the body is the shared snapshot frame either way.
c.Data(http.StatusOK, "application/json", service.ProbeSnapshotJSON(full))
}
type metricsResponse struct {
Metrics []probe.MetricRow `json:"metrics"`
Ping []probe.PingRow `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
Step int64 `json:"step"`
}
func (pc *PublicProbeController) nodeMetrics(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
full := probeAuthed(c)
if !service.ProbeNodeReadable(full, id) {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
select {
case historySlots <- struct{}{}:
defer func() { <-historySlots }()
default:
answerText(c, http.StatusServiceUnavailable, "查询历史的请求太多,稍后再试")
return
}
hours := int64(6)
if raw := c.Query("hours"); raw != "" {
h, perr := strconv.ParseInt(raw, 10, 64)
if perr != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
hours = h
}
if max := probe.RetentionDays() * 24; hours < 1 {
hours = 1
} else if hours > max {
hours = max
}
var points *int64
if raw := c.Query("points"); raw != "" {
p, perr := strconv.ParseInt(raw, 10, 64)
if perr != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
points = &p
}
series := c.Query("series")
wantMetrics := series == "" || series == "metrics"
wantPing := series == "" || series == "ping"
span := probe.ComputeSpan(hours, points, time.Now().Unix())
resp := metricsResponse{
Metrics: []probe.MetricRow{},
Ping: []probe.PingRow{},
Probes: map[string]string{},
Loss: map[string]float64{},
Step: span.Step,
}
if wantMetrics {
rows, err := probe.MetricHistory(id, span)
if err != nil {
logger.Warningf("probe public: metrics %d: %v", id, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
resp.Metrics = rows
}
if wantPing {
// B4: the real latency series over the same window/bucket as
// the metrics above (hub metrics handler series=ping).
pingRows, pingLoss, err := probe.PingHistory(id, span)
if err != nil {
logger.Warningf("probe public: ping %d: %v", id, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
names, err := probe.PingTaskNames(id)
if err != nil {
logger.Warningf("probe public: ping names %d: %v", id, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
resp.Ping = pingRows
resp.Probes = names
resp.Loss = pingLoss
}
c.JSON(http.StatusOK, resp)
}
// liveWS is the hub's browser stream: a snapshot frame every 2 s,
// text by default, binary gzip frames for the anonymous audience when
// opened with ?gzip. Audience eligibility is re-checked every tick —
// a socket outlives both the session and the public-page switch.
func (pc *PublicProbeController) liveWS(c *gin.Context) {
authed := probeAuthed(c) && !fromOtherOrigin(c)
if !authed && !service.ProbePublicPageEnabled() {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
counted := !authed
if counted {
if publicStreams.Add(1) > 1000 {
publicStreams.Add(-1)
answerText(c, http.StatusServiceUnavailable, "同时在线的访客太多,请稍后再试")
return
}
defer publicStreams.Add(-1)
}
_, gzipWanted := c.GetQuery("gzip")
conn, err := pc.upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return
}
defer conn.Close()
// The browser sends nothing but the close handshake; cap frames
// at the hub's 64 KiB anyway so a rogue peer cannot make the read
// pump buffer without bound.
conn.SetReadLimit(64 * 1024)
done := make(chan struct{})
go func() {
defer close(done)
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}()
send := func() bool {
// Audience re-check (hub stream_audience): the admin frame is
// sent only while the session that opened it is still live;
// the public frame only while the page stays open. The
// stream closes rather than downgrading.
if authed {
if !probeAuthed(c) {
return false
}
} else if !service.ProbePublicPageEnabled() {
return false
}
var frameType int
var frame []byte
if gzipWanted && !authed {
frameType = ws.BinaryMessage
frame = service.ProbeSnapshotGzip()
} else if authed {
frameType = ws.TextMessage
frame = service.ProbeSnapshotJSON(true)
} else {
frameType = ws.TextMessage
frame = service.ProbeSnapshotJSON(false)
}
_ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
return conn.WriteMessage(frameType, frame) == nil
}
// The first frame goes out immediately (hub's interval ticks at
// once), then one per 2 s tick.
if !send() {
return
}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
if !send() {
return
}
}
}
}
func (pc *PublicProbeController) themeConfig(c *gin.Context) {
if !probeAuthed(c) && !service.ProbePublicPageEnabled() {
answerText(c, http.StatusUnauthorized, "需要登录后查看")
return
}
short := c.Param("short")
if !service.ValidThemeShort(short) {
answerText(c, http.StatusNotFound, "请求的内容不存在")
return
}
raw, err := service.ProbeThemeConfigValue(short)
if err != nil {
logger.Warningf("probe public: theme config %s: %v", short, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
c.Data(http.StatusOK, "application/json", []byte(raw))
}
func (pc *PublicProbeController) saveThemeConfig(c *gin.Context) {
if !probeAuthed(c) {
answerText(c, http.StatusUnauthorized, "登录已失效,请重新登录")
return
}
if fromOtherOrigin(c) {
answerText(c, http.StatusForbidden, "请求来自其他网站,已拒绝")
return
}
short := c.Param("short")
// Hub save_theme_config gates on selectable, not on a disk copy:
// the built-in theme's config is savable before any install.
if !service.ValidThemeShort(short) || !service.ProbeThemeSelectable(short) {
answerText(c, http.StatusBadRequest, "主题没有安装")
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxThemeConfigBytes+1))
if err != nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
if len(body) > maxThemeConfigBytes {
answerText(c, http.StatusRequestEntityTooLarge, "提交的内容过大")
return
}
var obj map[string]json.RawMessage
if err := json.Unmarshal(body, &obj); err != nil || obj == nil {
answerText(c, http.StatusBadRequest, "请求格式不对")
return
}
if err := service.SaveProbeThemeConfigRaw(short, string(body)); err != nil {
logger.Warningf("probe public: save theme config %s: %v", short, err)
answerText(c, http.StatusInternalServerError, "服务内部出错")
return
}
c.Status(http.StatusNoContent)
}
// HandleFallback serves the public theme from the engine's NoRoute:
// unknown /api/* paths answer the hub's API 404, everything outside
// the panel basePath falls to the current theme's dist (SPA fallback
// inside). Returns false when the request is the panel's own (its
// SPA fallback decides) or when no theme is installed to answer.
func (pc *PublicProbeController) HandleFallback(c *gin.Context) bool {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
return false
}
path := c.Request.URL.Path
if path == "/api" || strings.HasPrefix(path, "/api/") {
answerText(c, http.StatusNotFound, "没有这个接口:"+path)
return true
}
basePath, _ := c.Get("base_path")
if bp, ok := basePath.(string); ok && bp != "" && bp != "/" {
if strings.HasPrefix(path, bp) {
return false
}
} else if strings.HasPrefix(path, "/panel") || strings.HasPrefix(path, "/assets") {
return false
}
return service.ServeProbeThemePath(c, strings.TrimPrefix(path, "/"))
}
@@ -0,0 +1,593 @@
package controller
import (
"bytes"
"compress/gzip"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
)
// Item 7 B3 contract tests: the five public endpoints, audience
// discipline (anonymous callers never see addresses / hostnames /
// private remarks), theme hosting + fallback, theme config round
// trip, and the probe-meta edit endpoints. Field sets are asserted
// exactly against the hub's node_view / metrics definitions.
type probeTestRig struct {
engine *gin.Engine
dbDir string
store *probe.Store
cookies []*http.Cookie
}
func newProbeTestRig(t *testing.T) *probeTestRig {
t.Helper()
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
st := probe.Default()
if st == nil {
t.Fatal("probe store unavailable")
}
service.InvalidateProbeSnapshot()
engine := gin.New()
store := cookie.NewStore([]byte("probe-public-test-secret"))
engine.Use(sessions.Sessions("3x-ui", store))
engine.Use(func(c *gin.Context) { c.Set("base_path", "/panel/") })
a := &APIController{}
engine.GET("/test-login", func(c *gin.Context) {
var user model.User
if err := database.GetDB().First(&user).Error; err != nil {
c.Status(http.StatusInternalServerError)
return
}
if err := session.SetLoginUser(c, &user); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusOK)
})
pc := NewPublicProbeController(engine)
agentAdmin := engine.Group("/panel/api/agent")
agentAdmin.Use(a.checkAPIAuth)
NewProbeMetaController(agentAdmin)
NewProbeTasksController(agentAdmin)
engine.NoRoute(func(c *gin.Context) {
if pc.HandleFallback(c) {
return
}
c.AbortWithStatus(http.StatusNotFound)
})
rig := &probeTestRig{engine: engine, dbDir: dbDir, store: st}
// Log in once; handlers that need the anonymous side just skip the
// cookies.
rec := rig.do(http.MethodGet, "/test-login", nil, false)
if rec.Code != http.StatusOK {
t.Fatalf("test login: %d", rec.Code)
}
rig.cookies = rec.Result().Cookies()
if len(rig.cookies) == 0 {
t.Fatal("test login set no session cookie")
}
return rig
}
func (r *probeTestRig) do(method, path string, body []byte, authed bool) *httptest.ResponseRecorder {
var rd io.Reader
if body != nil {
rd = bytes.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if authed {
for _, ck := range r.cookies {
req.AddCookie(ck)
}
}
rec := httptest.NewRecorder()
r.engine.ServeHTTP(rec, req)
return rec
}
func (r *probeTestRig) setSetting(t *testing.T, key, value string) {
t.Helper()
db := database.GetDB()
var row model.Setting
err := db.Where("key = ?", key).First(&row).Error
if database.IsNotFound(err) {
if err := db.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
t.Fatalf("set setting %s: %v", key, err)
}
return
}
if err != nil {
t.Fatalf("read setting %s: %v", key, err)
}
row.Value = value
if err := db.Save(&row).Error; err != nil {
t.Fatalf("save setting %s: %v", key, err)
}
}
// seedAgent creates a bound node + agent pair with a live probe
// snapshot, a meta row and a traffic ledger line.
func (r *probeTestRig) seedAgent(t *testing.T, name string, public bool) (agentID int64, nodeID int) {
t.Helper()
db := database.GetDB()
node := &model.Node{Name: name, Remark: "private-remark-" + name}
if err := db.Create(node).Error; err != nil {
t.Fatalf("seed node: %v", err)
}
now := time.Now()
agent := &model.AgentNode{
TokenHash: "hash-" + name, TokenPrefix: "tp-", Name: "agent-" + name,
NodeID: &node.Id,
LastSeen: now.UnixMilli(),
AgentVersion: "A1.0.0",
IPv4: "203.0.113.9",
SeenIPv4: "198.51.100.4",
ProbeHostname: "secret-host-" + name,
ProbeOS: "Debian GNU/Linux 13", ProbeKernel: "6.12.1", ProbeArch: "amd64",
ProbeVirt: "kvm", ProbeCPUName: "Test CPU", ProbeCPUCores: 4,
ProbeIface: "eth0",
ProbeMemTotal: 4_000_000_000, ProbeMemUsed: 1_500_000_000,
ProbeSwapTotal: 1_000_000_000, ProbeSwapUsed: 5_000_000,
ProbeDiskTotal: 8_000_000_000, ProbeDiskUsed: 3_000_000_000,
ProbeNetRxTot: 111_000, ProbeNetTxTot: 222_000,
ProbeNetRx: 1000, ProbeNetTx: 2000,
ProbeTCP: 42, ProbeUDP: 7, ProbeProcs: 123,
ProbeCPU: 12.5, UptimeSecs: 3600,
ProbeAt: now.UnixMilli(),
}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("seed agent: %v", err)
}
id := int64(agent.Id)
r.store.DeleteAgentData(id) // the default store is process-wide; start clean
exp := now.AddDate(0, 1, 0).Format("2006-01-02")
meta := probe.DefaultMeta(id)
meta.Public = public
meta.Price = 10
meta.ExpiresAt = &exp
meta.GroupName = "grp"
meta.PublicRemark = "shown publicly"
if err := probe.SaveMeta(meta); err != nil {
t.Fatalf("seed meta: %v", err)
}
month := probe.PeriodStart(now, 1).Format("2006-01-02")
day := now.Format("2006-01-02")
if _, err := r.store.DB().Exec(`INSERT INTO probe_traffic
(agent_id, total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start)
VALUES (?, 111, 222, 33, 44, ?, 5, 6, ?)`, id, month, day); err != nil {
t.Fatalf("seed traffic: %v", err)
}
return id, node.Id
}
func keySet(t *testing.T, body []byte, path ...string) map[string]bool {
t.Helper()
var v any
if err := json.Unmarshal(body, &v); err != nil {
t.Fatalf("decode: %v", err)
}
cur := v
for _, p := range path {
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: not an object at %q", path, p)
}
cur = m[p]
}
m, ok := cur.(map[string]any)
if !ok {
t.Fatalf("path %v: not an object", path)
}
out := map[string]bool{}
for k := range m {
out[k] = true
}
return out
}
func assertKeys(t *testing.T, got map[string]bool, want []string) {
t.Helper()
w := map[string]bool{}
for _, k := range want {
w[k] = true
}
var extra, missing []string
for k := range got {
if !w[k] {
extra = append(extra, k)
}
}
for k := range w {
if !got[k] {
missing = append(missing, k)
}
}
sort.Strings(extra)
sort.Strings(missing)
if len(extra) > 0 || len(missing) > 0 {
t.Fatalf("key set mismatch: extra=%v missing=%v", extra, missing)
}
}
var anonNodeKeys = []string{
"id", "name", "country", "group", "public_remark", "sort", "public",
"online", "last_seen", "last_seen_ago", "metrics",
"os", "kernel", "arch", "virt", "cpu_name", "cpu_cores",
"mem_total", "swap_total", "disk_total", "agent_version",
"price", "currency", "billing_cycle", "expires_at", "expires_in",
"traffic_limit", "traffic_mode", "traffic_reset_day",
"total_rx", "total_tx", "month_rx", "month_tx", "month_used",
"month_start", "day_rx", "day_tx",
}
var metricKeys = []string{
"uptime", "cpu", "load", "mem_total", "mem_used", "swap_total",
"swap_used", "disk_total", "disk_used", "net_rx", "net_tx",
"tcp", "udp", "procs", "total_rx", "total_tx", "month_rx", "month_tx",
}
var historyKeys = []string{
"ts", "cpu", "mem_used", "disk_used", "net_rx", "net_tx",
"net_rx_max", "net_tx_max", "cpu_max", "minutes",
"swap_used", "tcp", "udp", "procs",
}
func TestProbePublicContract(t *testing.T) {
rig := newProbeTestRig(t)
pubID, _ := rig.seedAgent(t, "PubNode", true)
privID, privNodeID := rig.seedAgent(t, "PrivNode", false)
service.InvalidateProbeSnapshot()
// --- public page off (the shipped default) ---
rec := rig.do(http.MethodGet, "/api/me", nil, false)
if rec.Code != 200 {
t.Fatalf("me anon: %d", rec.Code)
}
assertKeys(t, keySet(t, rec.Body.Bytes()),
[]string{"authed", "github", "site_name", "public_page", "history_days", "site"})
var me map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &me)
if me["authed"] != false || me["public_page"] != false || me["site_name"] != "Monitor" ||
me["history_days"] != float64(7) || me["github"] != false || me["site"] != "" {
t.Fatalf("me defaults wrong: %v", me)
}
if rec := rig.do(http.MethodGet, "/api/nodes", nil, false); rec.Code != 401 {
t.Fatalf("nodes anon with page off: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/nodes/1/metrics", nil, false); rec.Code != 401 {
t.Fatalf("metrics anon with page off: %d", rec.Code)
}
// --- public page on: the anonymous audience ---
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
rec = rig.do(http.MethodGet, "/api/nodes", nil, false)
if rec.Code != 200 {
t.Fatalf("nodes anon: %d (%s)", rec.Code, rec.Body.String())
}
body := rec.Body.String()
for _, leak := range []string{
"secret-host", "private-remark", `"hostname"`, `"remark"`,
`"ip":`, `"ipv4"`, `"ipv6"`, "203.0.113.9", "198.51.100.4", "eth0",
} {
if strings.Contains(body, leak) {
t.Fatalf("anonymous frame leaks %q: %s", leak, body)
}
}
var frame struct {
Nodes []map[string]json.RawMessage `json:"nodes"`
Admin bool `json:"admin"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &frame); err != nil {
t.Fatalf("frame decode: %v", err)
}
if frame.Admin || len(frame.Nodes) != 1 {
t.Fatalf("anon frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
}
nodeBody, _ := json.Marshal(frame.Nodes[0])
assertKeys(t, keySet(t, nodeBody), anonNodeKeys)
var node map[string]any
_ = json.Unmarshal(nodeBody, &node)
if node["id"] != float64(pubID) || node["name"] != "PubNode" || node["online"] != true ||
node["month_rx"] != float64(33) || node["month_used"] != float64(77) ||
node["total_tx"] != float64(222) || node["price"] != float64(10) {
t.Fatalf("anon node values wrong: %v", node)
}
metricsObj, _ := json.Marshal(node["metrics"])
assertKeys(t, keySet(t, metricsObj), metricKeys)
// Metrics history for the public node; the private one refuses.
base := time.Now().Unix()
base -= base % 60
for _, ts := range []int64{base - 120, base - 60} {
if _, err := rig.store.DB().Exec(`INSERT INTO probe_metric
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max)
VALUES (?, ?, 10, 100, 0, 0, 1000, 2000, 1500, 2500, 3, 2, 10, 12)`,
pubID, ts); err != nil {
t.Fatalf("seed metric: %v", err)
}
}
rec = rig.do(http.MethodGet, "/api/nodes/"+itoa(pubID)+"/metrics?hours=1", nil, false)
if rec.Code != 200 {
t.Fatalf("metrics anon: %d (%s)", rec.Code, rec.Body.String())
}
var hist struct {
Metrics []map[string]json.RawMessage `json:"metrics"`
Ping []any `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
Step int64 `json:"step"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &hist); err != nil {
t.Fatalf("metrics decode: %v", err)
}
if hist.Step != 60 || len(hist.Metrics) != 2 || len(hist.Ping) != 0 ||
len(hist.Probes) != 0 || len(hist.Loss) != 0 {
t.Fatalf("metrics shape wrong: %+v", hist)
}
rowBody, _ := json.Marshal(hist.Metrics[0])
assertKeys(t, keySet(t, rowBody), historyKeys)
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, false); rec.Code != 401 {
t.Fatalf("metrics for private node anon: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/nodes/abc/metrics", nil, false); rec.Code != 400 {
t.Fatalf("metrics bad id: %d", rec.Code)
}
// --- the admin audience ---
rec = rig.do(http.MethodGet, "/api/nodes", nil, true)
if rec.Code != 200 {
t.Fatalf("nodes admin: %d", rec.Code)
}
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Fatalf("admin frame cache header: %q", cc)
}
_ = json.Unmarshal(rec.Body.Bytes(), &frame)
if !frame.Admin || len(frame.Nodes) != 2 {
t.Fatalf("admin frame: admin=%v nodes=%d", frame.Admin, len(frame.Nodes))
}
adminBody := rec.Body.String()
for _, want := range []string{"secret-host-PubNode", "private-remark-PubNode", "203.0.113.9"} {
if !strings.Contains(adminBody, want) {
t.Fatalf("admin frame missing %q", want)
}
}
if rec := rig.do(http.MethodGet, "/api/nodes/"+itoa(privID)+"/metrics", nil, true); rec.Code != 200 {
t.Fatalf("metrics for private node admin: %d", rec.Code)
}
// --- theme hosting + API 404 discipline ---
themeDir := filepath.Join(rig.dbDir, "themes", "testtheme", "dist")
if err := os.MkdirAll(filepath.Join(themeDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
indexHTML := "<!doctype html><title>theme</title>"
if err := os.WriteFile(filepath.Join(themeDir, "index.html"), []byte(indexHTML), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(themeDir, "assets", "app-abc123.js"), []byte("console.log(1)"), 0o644); err != nil {
t.Fatal(err)
}
rig.setSetting(t, "theme", "testtheme")
rec = rig.do(http.MethodGet, "/", nil, false)
if rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("theme root: %d %q", rec.Code, rec.Body.String())
}
etag := rec.Header().Get("ETag")
if etag == "" || !strings.Contains(rec.Header().Get("Cache-Control"), "no-cache") {
t.Fatalf("shell headers: etag=%q cc=%q", etag, rec.Header().Get("Cache-Control"))
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("If-None-Match", etag)
rec304 := httptest.NewRecorder()
rig.engine.ServeHTTP(rec304, req)
if rec304.Code != 304 {
t.Fatalf("etag revalidation: %d", rec304.Code)
}
if rec := rig.do(http.MethodGet, "/some/spa/route", nil, false); rec.Code != 200 || rec.Body.String() != indexHTML {
t.Fatalf("spa fallback: %d", rec.Code)
}
rec = rig.do(http.MethodGet, "/assets/app-abc123.js", nil, false)
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable") {
t.Fatalf("asset serving: %d cc=%q", rec.Code, rec.Header().Get("Cache-Control"))
}
if rec := rig.do(http.MethodGet, "/assets/missing.js", nil, false); rec.Code != 404 {
t.Fatalf("missing asset: %d", rec.Code)
}
rec = rig.do(http.MethodGet, "/api/nope", nil, false)
if rec.Code != 404 || !strings.Contains(rec.Body.String(), "没有这个接口:/api/nope") {
t.Fatalf("api 404: %d %q", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodGet, "/panel/unknown", nil, false); rec.Code != 404 {
t.Fatalf("panel path must stay with the panel: %d", rec.Code)
}
// --- theme config round trip ---
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Code != 200 || rec.Body.String() != "{}" {
t.Fatalf("config default: %d %q", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), false); rec.Code != 401 {
t.Fatalf("config PUT anon: %d", rec.Code)
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`{"accent":"red"}`), true); rec.Code != 204 {
t.Fatalf("config PUT admin: %d (%s)", rec.Code, rec.Body.String())
}
if rec := rig.do(http.MethodGet, "/api/themes/testtheme/config", nil, false); rec.Body.String() != `{"accent":"red"}` {
t.Fatalf("config round trip: %q", rec.Body.String())
}
if rec := rig.do(http.MethodPut, "/api/themes/notinstalled/config", []byte(`{}`), true); rec.Code != 400 {
t.Fatalf("config PUT uninstalled: %d", rec.Code)
}
if rec := rig.do(http.MethodPut, "/api/themes/testtheme/config", []byte(`[1,2]`), true); rec.Code != 400 {
t.Fatalf("config PUT non-object: %d", rec.Code)
}
if rec := rig.do(http.MethodGet, "/api/themes/bad!short/config", nil, true); rec.Code != 404 {
t.Fatalf("config bad short: %d", rec.Code)
}
// --- probe-meta edit endpoints ---
metaPath := "/panel/api/agent/probe-meta/" + itoa(int64(privNodeID))
if rec := rig.do(http.MethodGet, metaPath, nil, false); rec.Code == 200 {
t.Fatal("probe-meta GET without login must not succeed")
}
rec = rig.do(http.MethodGet, metaPath, nil, true)
if rec.Code != 200 {
t.Fatalf("probe-meta GET: %d (%s)", rec.Code, rec.Body.String())
}
var env struct {
Success bool `json:"success"`
Obj struct {
Currency string `json:"currency"`
BillingCycle string `json:"billingCycle"`
Public bool `json:"public"`
} `json:"obj"`
}
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if !env.Success || env.Obj.Currency != "USD" || env.Obj.Public || env.Obj.BillingCycle != "monthly" {
t.Fatalf("probe-meta GET body: %s", rec.Body.String())
}
bad := []byte(`{"price":1,"currency":"USD","billingCycle":"monthly","trafficLimit":0,` +
`"trafficMode":"sum","trafficResetDay":32,"public":true}`)
rec = rig.do(http.MethodPut, metaPath, bad, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Success {
t.Fatalf("reset day 32 must be refused: %s", rec.Body.String())
}
good := []byte(`{"price":3.5,"currency":"usd","billingCycle":"12m","trafficLimit":2000000000,` +
`"trafficMode":"max","trafficResetDay":15,"public":false,"ipv4Pin":" 203.0.113.9 "}`)
rec = rig.do(http.MethodPut, metaPath, good, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if !env.Success {
t.Fatalf("probe-meta PUT: %s", rec.Body.String())
}
rec = rig.do(http.MethodGet, metaPath, nil, true)
_ = json.Unmarshal(rec.Body.Bytes(), &env)
if env.Obj.Currency != "USD" || env.Obj.BillingCycle != "yearly" || env.Obj.Public {
t.Fatalf("probe-meta normalization not stored: %s", rec.Body.String())
}
saved, ok, err := probe.MetaFor(privID)
if err != nil || !ok || saved.IPv4Pin != "203.0.113.9" || saved.TrafficLimit != 2_000_000_000 {
t.Fatalf("stored meta: ok=%v err=%v %+v", ok, err, saved)
}
if rec := rig.do(http.MethodGet, "/panel/api/agent/probe-meta/999999", nil, true); rec.Code != 404 {
t.Fatalf("probe-meta unbound node: %d", rec.Code)
}
}
func TestProbeWebSocket(t *testing.T) {
rig := newProbeTestRig(t)
_, _ = rig.seedAgent(t, "WsNode", true)
rig.setSetting(t, "public_page", "on")
service.InvalidateProbeSnapshot()
srv := httptest.NewServer(rig.engine)
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/ws"
readFrame := func(conn *ws.Conn) (int, map[string]any) {
t.Helper()
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
mt, raw, err := conn.ReadMessage()
if err != nil {
t.Fatalf("ws read: %v", err)
}
if mt == ws.BinaryMessage {
zr, err := gzip.NewReader(bytes.NewReader(raw))
if err != nil {
t.Fatalf("gzip frame: %v", err)
}
raw, err = io.ReadAll(zr)
if err != nil {
t.Fatalf("gunzip frame: %v", err)
}
}
var frame map[string]any
if err := json.Unmarshal(raw, &frame); err != nil {
t.Fatalf("frame is not JSON: %v (%q)", err, raw)
}
return mt, frame
}
// Anonymous text frame.
conn, _, err := ws.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("dial anon: %v", err)
}
mt, frame := readFrame(conn)
if mt != ws.TextMessage || frame["admin"] != false {
t.Fatalf("anon frame: type=%d admin=%v", mt, frame["admin"])
}
if nodes, ok := frame["nodes"].([]any); !ok || len(nodes) != 1 {
t.Fatalf("anon frame nodes: %v", frame["nodes"])
}
conn.Close()
// Anonymous gzip frame: binary, gunzips to the same shape.
conn, _, err = ws.DefaultDialer.Dial(wsURL+"?gzip=1", nil)
if err != nil {
t.Fatalf("dial gzip: %v", err)
}
mt, frame = readFrame(conn)
if mt != ws.BinaryMessage || frame["admin"] != false {
t.Fatalf("gzip frame: type=%d admin=%v", mt, frame["admin"])
}
conn.Close()
// Admin frame over the session cookie.
hdr := http.Header{}
for _, ck := range rig.cookies {
hdr.Add("Cookie", ck.String())
}
conn, _, err = ws.DefaultDialer.Dial(wsURL, hdr)
if err != nil {
t.Fatalf("dial admin: %v", err)
}
_, frame = readFrame(conn)
if frame["admin"] != true {
t.Fatalf("admin frame: %v", frame["admin"])
}
conn.Close()
}
func itoa(v int64) string {
return strconv.FormatInt(v, 10)
}
+333
View File
@@ -0,0 +1,333 @@
package controller
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
"github.com/gin-gonic/gin"
)
// Item 7 B4: probe task management — the operator-maintained latency
// target set (ITEM7-SCOPE §5.7, user ruling Q4: no preset list; the
// operator keeps targets, grouped by the free-form region label, from
// the panel). Endpoints ride the panel-authenticated agent admin
// group like B3's probe-meta, and validation mirrors hub api.rs's
// save_ping_task chain refusal for refusal — including refusing (not
// clamping) an out-of-range interval.
//
// The panel-facing id space is the panel NODE id, as on the
// probe-meta surface; assignments are stored against agent ids and
// translated at this layer, where the binding lives.
type ProbeTasksController struct{}
func NewProbeTasksController(g *gin.RouterGroup) *ProbeTasksController {
pc := &ProbeTasksController{}
g.GET("/probe-tasks", pc.listTasks)
g.PUT("/probe-tasks", pc.saveTask)
g.DELETE("/probe-tasks/:id", pc.deleteTask)
g.POST("/probe-tasks/reorder", pc.reorderTasks)
g.PUT("/probe-tasks/by-node/:nodeId", pc.setNodeTasks)
return pc
}
// probeTaskForm is the wire shape of one task as the panel edits it.
type probeTaskForm struct {
ID int64 `json:"id"`
Name string `json:"name"`
Target string `json:"target"`
Interval int64 `json:"interval"`
AutoJoin bool `json:"autoJoin"`
Sort int64 `json:"sort"`
Region string `json:"region"`
NodeIDs []int64 `json:"nodeIds"`
BaseNodeIDs *[]int64 `json:"baseNodeIds"`
}
// agentsByNode maps panel node id → agent row for every bound agent.
func agentsByNode() (map[int64]model.AgentNode, error) {
var agents []model.AgentNode
if err := database.GetDB().Where("node_id IS NOT NULL").Find(&agents).Error; err != nil {
return nil, err
}
out := make(map[int64]model.AgentNode, len(agents))
for _, ag := range agents {
out[int64(*ag.NodeID)] = ag
}
return out, nil
}
// agentDisplayName renders an agent for operator-facing messages: the
// bound node's name where there is one (the panel identifies nodes by
// name and never shows an id, as the hub does).
func agentDisplayName(agentID int64) string {
var ag model.AgentNode
if err := database.GetDB().Where("id = ?", agentID).First(&ag).Error; err == nil {
if ag.NodeID != nil {
var node model.Node
if err := database.GetDB().Where("id = ?", *ag.NodeID).First(&node).Error; err == nil && node.Name != "" {
return node.Name
}
}
if ag.Name != "" {
return ag.Name
}
}
return fmt.Sprintf("#%d", agentID)
}
// nodeName renders a panel node for messages, "" when it is gone.
func nodeName(nodeID int64) string {
var node model.Node
if err := database.GetDB().Where("id = ?", nodeID).First(&node).Error; err != nil {
return ""
}
return node.Name
}
// resolveAgentIDs translates the form's node ids into agent ids,
// refusing the whole save when one names a node that is gone or has
// no agent bound (the hub's foreign key does this by construction;
// the naming keeps the refusal readable).
func resolveAgentIDs(nodeIDs []int64, bound map[int64]model.AgentNode) ([]int64, string) {
out := make([]int64, 0, len(nodeIDs))
for _, nodeID := range nodeIDs {
ag, ok := bound[nodeID]
if !ok {
if name := nodeName(nodeID); name != "" {
return nil, fmt.Sprintf("节点「%s」未绑定 agent,无法指派探测任务", name)
}
return nil, fmt.Sprintf("节点 %d 不存在,可能已被删除", nodeID)
}
out = append(out, int64(ag.Id))
}
return out, ""
}
func (pc *ProbeTasksController) listTasks(c *gin.Context) {
tasks, err := probe.PingTasks()
if err != nil {
jsonMsg(c, "load probe tasks failed", err)
return
}
bound, err := agentsByNode()
if err != nil {
jsonMsg(c, "load probe tasks failed", err)
return
}
nodeByAgent := make(map[int64]int64, len(bound))
for nodeID, ag := range bound {
nodeByAgent[int64(ag.Id)] = nodeID
}
forms := make([]probeTaskForm, 0, len(tasks))
for _, t := range tasks {
f := probeTaskForm{
ID: t.ID, Name: t.Name, Target: t.Target, Interval: t.Interval,
AutoJoin: t.AutoJoin, Sort: t.Sort, Region: t.Region, NodeIDs: []int64{},
}
for _, agentID := range t.AgentIDs {
if nodeID, ok := nodeByAgent[agentID]; ok {
f.NodeIDs = append(f.NodeIDs, nodeID)
}
}
forms = append(forms, f)
}
jsonObj(c, forms, nil)
}
// validProbeTarget mirrors hub valid_target: a target the agent can
// resolve — host:port, an IPv6 literal bracketed as a URL writes one.
// A bare contains(':') would admit a bare IPv6 address, ":443" with no
// host and "host:" with no port, each of which probes at 100% loss
// forever with nothing identifying the target as the cause.
func validProbeTarget(target string) bool {
var host, port string
if rest, ok := strings.CutPrefix(target, "["); ok {
h, p, found := strings.Cut(rest, "]:")
if !found {
return false
}
host, port = h, p
} else {
// rsplit_once(':'): host:port splits on the last colon.
i := strings.LastIndex(target, ":")
if i < 0 {
return false
}
host, port = target[:i], target[i+1:]
}
if host == "" || strings.Contains(host, ":") && !strings.HasPrefix(target, "[") {
return false
}
n, err := strconv.Atoi(port)
return err == nil && n > 0 && n <= 65535
}
// validateProbeTaskForm normalizes the form in place (hub order:
// trim, presence, name length, target shape, interval range) and
// returns the first refusal's message, "" when it may be stored. The
// region rule is ours (the hub has no region): the meta group rule.
func validateProbeTaskForm(f *probeTaskForm) string {
// Trimmed into the stored value rather than a discarded copy:
// what reaches the agent is the stored target, and a trailing
// space from a paste would leave the probe reporting loss
// indefinitely (hub discipline, same for the name).
f.Name = strings.TrimSpace(f.Name)
f.Target = strings.TrimSpace(f.Target)
f.Region = strings.TrimSpace(f.Region)
if f.Name == "" || f.Target == "" {
return "请填写名称和目标"
}
if len([]rune(f.Name)) > 64 {
return "名称最多 64 个字"
}
if !validProbeTarget(f.Target) {
return "目标要写成「主机:端口」,例如 1.1.1.1:443 或 [2606:4700:4700::1111]:443"
}
// Refused rather than clamped: the agent clamps again on
// arrival, so an out-of-range value would silently become a
// different number while the panel still displays what was
// entered (hub).
if f.Interval < probe.MinProbeInterval || f.Interval > 3600 {
return fmt.Sprintf("间隔要在 %d 到 3600 秒之间", probe.MinProbeInterval)
}
if len([]rune(f.Region)) > 13 || hasControl(f.Region) {
return "地区最多 13 个字,不能含控制字符"
}
return ""
}
// probeTaskError renders a store refusal in the hub's words.
func probeTaskError(c *gin.Context, err error) {
var crowded *probe.PingCrowdedError
var autoJoin *probe.PingAutoJoinError
switch {
case errors.Is(err, probe.ErrPingTaskGone):
jsonMsg(c, err.Error(), errRequest)
case errors.As(err, &crowded):
jsonMsg(c, fmt.Sprintf("节点「%s」会被分配超过 %d 个探测任务,agent 最多只跑这么多,多出来的会被静默丢掉",
agentDisplayName(crowded.AgentID), probe.MaxProbesPerNode), errRequest)
case errors.As(err, &autoJoin):
jsonMsg(c, fmt.Sprintf("新节点会自动加入 %d 个探测任务,agent 最多只跑 %d 个",
autoJoin.Count, probe.MaxProbesPerNode), errRequest)
default:
jsonMsg(c, err.Error(), errRequest)
}
}
func (pc *ProbeTasksController) saveTask(c *gin.Context) {
var form probeTaskForm
if err := c.ShouldBindJSON(&form); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if form.NodeIDs == nil {
form.NodeIDs = []int64{}
}
if msg := validateProbeTaskForm(&form); msg != "" {
jsonMsg(c, msg, errRequest)
return
}
bound, err := agentsByNode()
if err != nil {
jsonMsg(c, "save probe task failed", err)
return
}
agentIDs, refusal := resolveAgentIDs(form.NodeIDs, bound)
if refusal != "" {
jsonMsg(c, refusal, errRequest)
return
}
var base *[]int64
if form.BaseNodeIDs != nil {
baseIDs, refusal := resolveAgentIDs(*form.BaseNodeIDs, bound)
if refusal != "" {
jsonMsg(c, refusal, errRequest)
return
}
base = &baseIDs
}
id, err := probe.SavePingTask(probe.PingTask{
ID: form.ID,
Name: form.Name,
Target: form.Target,
Interval: form.Interval,
AutoJoin: form.AutoJoin,
Region: form.Region,
AgentIDs: agentIDs,
}, base)
if err != nil {
probeTaskError(c, err)
return
}
jsonObj(c, gin.H{"id": id}, nil)
}
func (pc *ProbeTasksController) deleteTask(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if err := probe.DeletePingTask(id); err != nil {
jsonMsg(c, "delete probe task failed", err)
return
}
jsonObj(c, gin.H{"message": "ok"}, nil)
}
func (pc *ProbeTasksController) reorderTasks(c *gin.Context) {
var form struct {
IDs []int64 `json:"ids"`
}
if err := c.ShouldBindJSON(&form); err != nil {
jsonMsg(c, "Invalid request", err)
return
}
if err := probe.ReorderPingTasks(form.IDs); err != nil {
jsonMsg(c, err.Error(), errRequest)
return
}
jsonObj(c, gin.H{"message": "ok"}, nil)
}
// setNodeTasks edits one node's probe assignments from the node's
// side (hub set_node_ping_tasks): tasks/base are task ids, only the
// difference from base is applied.
func (pc *ProbeTasksController) setNodeTasks(c *gin.Context) {
nodeID, err := strconv.Atoi(c.Param("nodeId"))
if err != nil {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
var agent model.AgentNode
if err := database.GetDB().Where("node_id = ?", nodeID).First(&agent).Error; err != nil {
c.JSON(404, gin.H{"message": "agent not found"})
return
}
var form struct {
Tasks []int64 `json:"tasks"`
Base []int64 `json:"base"`
}
if err := c.ShouldBindJSON(&form); err != nil {
jsonMsg(c, "监控列表格式不对", errRequest)
return
}
if form.Tasks == nil {
form.Tasks = []int64{}
}
if form.Base == nil {
form.Base = []int64{}
}
if err := probe.SetNodePingTasks(int64(agent.Id), form.Tasks, form.Base); err != nil {
probeTaskError(c, err)
return
}
jsonObj(c, gin.H{"message": "ok"}, nil)
}
@@ -0,0 +1,138 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/gin-gonic/gin"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// Item 7 B4: the agent-facing wire — probe_tasks rides the config
// response (always present, empty list clears), and probeResults on
// the heartbeat are filed stamped at arrival, with junk rows
// (unknown/absent latency, non-positive task ids) skipped and
// unassigned tasks dropped by the store's assignment gate.
func TestAgentProbeTasksWire(t *testing.T) {
gin.SetMode(gin.TestMode)
dbDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", dbDir)
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
t.Fatalf("InitDB: %v", err)
}
t.Cleanup(func() { _ = database.CloseDB() })
db := database.GetDB()
node := &model.Node{Name: "b4-wire"}
if err := db.Create(node).Error; err != nil {
t.Fatal(err)
}
const token = "b4-wire-token"
agent := &model.AgentNode{
TokenHash: hashAgentToken(token), TokenPrefix: "b4-",
Name: "b4-wire-agent", NodeID: &node.Id,
}
if err := db.Create(agent).Error; err != nil {
t.Fatal(err)
}
agentID := int64(agent.Id)
probe.DeleteAgentData(agentID)
taskID, err := probe.SavePingTask(probe.PingTask{
Name: "wire", Target: "1.1.1.1:443", Interval: 30,
AgentIDs: []int64{agentID},
}, nil)
if err != nil {
t.Fatal(err)
}
engine := gin.New()
NewAgentController(engine.Group("/panel/api/agent"))
do := func(method, path, body string) (int, []byte) {
var rd *bytes.Reader
if body != "" {
rd = bytes.NewReader([]byte(body))
} else {
rd = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rd)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
engine.ServeHTTP(rec, req)
return rec.Code, rec.Body.Bytes()
}
const cfgPath = "/panel/api/agent/config"
const hbPath = "/panel/api/agent/heartbeat"
// Config carries the task set.
code, body := do(http.MethodGet, cfgPath, "")
if code != http.StatusOK {
t.Fatalf("config: %d %s", code, body)
}
var cfg struct {
ProbeTasks []probe.PingTaskRef `json:"probe_tasks"`
}
if err := json.Unmarshal(body, &cfg); err != nil {
t.Fatal(err)
}
if len(cfg.ProbeTasks) != 1 || cfg.ProbeTasks[0].ID != taskID ||
cfg.ProbeTasks[0].Target != "1.1.1.1:443" || cfg.ProbeTasks[0].Interval != 30 {
t.Fatalf("config probe_tasks: %s", body)
}
// Heartbeat with a mixed batch: only the valid, assigned sample
// lands. (All rows share the arrival stamp, so the two filings of
// the same task collapse — assert on the surviving value.)
hb := `{"probeResults":[` +
`{"taskId":` + itoa(taskID) + `,"latencyMs":42},` +
`{"taskId":999999,"latencyMs":5},` +
`{"taskId":-3,"latencyMs":5},` +
`{"taskId":` + itoa(taskID) + `}` +
`]}`
code, body = do(http.MethodPost, hbPath, hb)
if code != http.StatusOK {
t.Fatalf("heartbeat: %d %s", code, body)
}
st := probe.Default()
var count int64
var latency int64
if err := st.DB().QueryRow(`SELECT COUNT(*), COALESCE(MAX(latency), -999) FROM probe_ping_record WHERE agent_id = ?`, agentID).Scan(&count, &latency); err != nil {
t.Fatal(err)
}
if count != 1 || latency != 42 {
t.Fatalf("filed rows %d latency %d", count, latency)
}
// After the task is unassigned, config reports an empty (present)
// list and further results are dropped by the assignment gate.
if err := probe.SetNodePingTasks(agentID, []int64{}, []int64{taskID}); err != nil {
t.Fatal(err)
}
code, body = do(http.MethodGet, cfgPath, "")
if code != http.StatusOK {
t.Fatalf("config 2: %d", code)
}
if !bytes.Contains(body, []byte(`"probe_tasks":[]`)) {
t.Fatalf("empty probe_tasks not present: %s", body)
}
code, _ = do(http.MethodPost, hbPath, `{"probeResults":[{"taskId":`+itoa(taskID)+`,"latencyMs":77}]}`)
if code != http.StatusOK {
t.Fatalf("heartbeat 2: %d", code)
}
if err := st.DB().QueryRow(`SELECT COUNT(*) FROM probe_ping_record WHERE agent_id = ? AND latency = 77`, agentID).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatal("unassigned result was filed")
}
probe.DeletePingTask(taskID)
}
+281
View File
@@ -0,0 +1,281 @@
package controller
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// Item 7 B4 controller tests: probe task management endpoints (hub
// validation chain, node-id translation, base-diff, reorder), the
// metrics series=ping read going live, and heartbeat ingestion of
// probeResults + config delivery of probe_tasks.
type envelope struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
func decodeEnvelope(t *testing.T, body []byte) envelope {
t.Helper()
var env envelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("envelope: %v (%s)", err, body)
}
return env
}
func cleanPingTasks(t *testing.T) {
t.Helper()
tasks, err := probe.PingTasks()
if err != nil {
t.Fatal(err)
}
for _, task := range tasks {
if err := probe.DeletePingTask(task.ID); err != nil {
t.Fatal(err)
}
}
}
const tasksPath = "/panel/api/agent/probe-tasks"
func TestProbeTasksAuthAndValidation(t *testing.T) {
rig := newProbeTestRig(t)
cleanPingTasks(t)
// Anonymous callers are refused by the group auth.
rec := rig.do(http.MethodPut, tasksPath, []byte(`{"name":"x","target":"1.1.1.1:443","interval":60}`), false)
if rec.Code == http.StatusOK && decodeEnvelope(t, rec.Body.Bytes()).Success {
t.Fatal("anonymous save succeeded")
}
cases := []struct {
name string
body string
want string
}{
{"empty name", `{"name":" ","target":"1.1.1.1:443","interval":60}`, "请填写名称和目标"},
{"empty target", `{"name":"x","target":"","interval":60}`, "请填写名称和目标"},
{"long name", `{"name":"` + strings.Repeat("名", 65) + `","target":"1.1.1.1:443","interval":60}`, "名称最多 64 个字"},
{"no port", `{"name":"x","target":"1.1.1.1","interval":60}`, "目标要写成"},
{"empty port", `{"name":"x","target":"1.1.1.1:","interval":60}`, "目标要写成"},
{"port range", `{"name":"x","target":"1.1.1.1:65536","interval":60}`, "目标要写成"},
{"bare v6", `{"name":"x","target":"2606:4700:4700::1111:443","interval":60}`, "目标要写成"},
{"interval low", `{"name":"x","target":"1.1.1.1:443","interval":3}`, "间隔要在 5 到 3600 秒之间"},
{"interval high", `{"name":"x","target":"1.1.1.1:443","interval":3601}`, "间隔要在 5 到 3600 秒之间"},
{"long region", `{"name":"x","target":"1.1.1.1:443","interval":60,"region":"` + strings.Repeat("区", 14) + `"}`, "地区最多 13 个字"},
}
for _, tc := range cases {
rec := rig.do(http.MethodPut, tasksPath, []byte(tc.body), true)
env := decodeEnvelope(t, rec.Body.Bytes())
if env.Success {
t.Errorf("%s: save succeeded, want refusal", tc.name)
continue
}
if !strings.Contains(env.Msg, tc.want) {
t.Errorf("%s: msg %q, want fragment %q", tc.name, env.Msg, tc.want)
}
}
if tasks, _ := probe.PingTasks(); len(tasks) != 0 {
t.Fatalf("refused saves leaked %d tasks", len(tasks))
}
// Valid forms: bracketed v6, boundary interval, 13-rune region.
for _, body := range []string{
`{"name":"v6","target":"[2606:4700:4700::1111]:443","interval":5}`,
`{"name":"edge","target":"example.com:65535","interval":3600,"region":"` + strings.Repeat("区", 13) + `"}`,
} {
rec := rig.do(http.MethodPut, tasksPath, []byte(body), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("valid save refused: %s (%s)", env.Msg, body)
}
}
cleanPingTasks(t)
}
func TestProbeTasksNodeMappingAndLifecycle(t *testing.T) {
rig := newProbeTestRig(t)
cleanPingTasks(t)
agentID, nodeID := rig.seedAgent(t, "b4node", true)
// A node with no agent bound cannot take assignments.
db := database.GetDB()
lone := &model.Node{Name: "b4-lone"}
if err := db.Create(lone).Error; err != nil {
t.Fatal(err)
}
body := `{"name":"t1","target":"1.1.1.1:443","interval":60,"nodeIds":[` + itoa(int64(lone.Id)) + `]}`
rec := rig.do(http.MethodPut, tasksPath, []byte(body), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); env.Success || !strings.Contains(env.Msg, "未绑定 agent") {
t.Fatalf("unbound node save: %+v", env)
}
// Create assigned to the bound node.
body = `{"name":"t1","target":"1.1.1.1:443","interval":60,"region":"亚洲","nodeIds":[` + itoa(int64(nodeID)) + `]}`
rec = rig.do(http.MethodPut, tasksPath, []byte(body), true)
env := decodeEnvelope(t, rec.Body.Bytes())
if !env.Success {
t.Fatalf("create: %s", env.Msg)
}
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(env.Obj, &created); err != nil || created.ID == 0 {
t.Fatalf("create obj: %s", env.Obj)
}
refs := probe.PingTasksFor(agentID)
if len(refs) != 1 || refs[0].ID != created.ID {
t.Fatalf("agent view: %v", refs)
}
// The list speaks node ids.
rec = rig.do(http.MethodGet, tasksPath, nil, true)
env = decodeEnvelope(t, rec.Body.Bytes())
var list []probeTaskForm
if err := json.Unmarshal(env.Obj, &list); err != nil || len(list) != 1 {
t.Fatalf("list: %s", env.Obj)
}
if len(list[0].NodeIDs) != 1 || list[0].NodeIDs[0] != int64(nodeID) || list[0].Region != "亚洲" {
t.Fatalf("list form %+v", list[0])
}
// by-node edit: clear the assignment with a base diff.
byNode := `{"tasks":[],"base":[` + itoa64(created.ID) + `]}`
rec = rig.do(http.MethodPut, tasksPath+"/by-node/"+itoa(int64(nodeID)), []byte(byNode), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("by-node clear: %s", env.Msg)
}
if refs := probe.PingTasksFor(agentID); len(refs) != 0 {
t.Fatalf("after by-node clear: %v", refs)
}
// by-node on an unknown node 404s.
rec = rig.do(http.MethodPut, tasksPath+"/by-node/999999", []byte(`{"tasks":[],"base":[]}`), true)
if rec.Code != http.StatusNotFound {
t.Fatalf("by-node unknown node: %d", rec.Code)
}
// Reorder: second task, flip, duplicates refused.
rec = rig.do(http.MethodPut, tasksPath, []byte(`{"name":"t2","target":"8.8.8.8:53","interval":30}`), true)
env = decodeEnvelope(t, rec.Body.Bytes())
var created2 struct {
ID int64 `json:"id"`
}
_ = json.Unmarshal(env.Obj, &created2)
flip := `{"ids":[` + itoa64(created2.ID) + `,` + itoa64(created.ID) + `]}`
rec = rig.do(http.MethodPost, tasksPath+"/reorder", []byte(flip), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("reorder: %s", env.Msg)
}
dup := `{"ids":[` + itoa64(created.ID) + `,` + itoa64(created.ID) + `]}`
rec = rig.do(http.MethodPost, tasksPath+"/reorder", []byte(dup), true)
if env := decodeEnvelope(t, rec.Body.Bytes()); env.Success {
t.Fatal("duplicate reorder accepted")
}
tasks, _ := probe.PingTasks()
if len(tasks) != 2 || tasks[0].ID != created2.ID {
t.Fatalf("order after flip %+v", tasks)
}
// Delete clears the agent's view too.
rec = rig.do(http.MethodDelete, tasksPath+"/"+itoa64(created.ID), nil, true)
if env := decodeEnvelope(t, rec.Body.Bytes()); !env.Success {
t.Fatalf("delete: %s", env.Msg)
}
if tasks, _ := probe.PingTasks(); len(tasks) != 1 {
t.Fatalf("after delete %v", tasks)
}
cleanPingTasks(t)
}
// series=ping serves real rows now: medians from filed samples, the
// probes label map, and whole-window loss for a fully-missed task.
func TestProbeMetricsPingSeriesLive(t *testing.T) {
rig := newProbeTestRig(t)
cleanPingTasks(t)
rig.setSetting(t, "public_page", "on")
agentID, _ := rig.seedAgent(t, "b4metrics", true)
good, err := probe.SavePingTask(probe.PingTask{Name: "good", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{agentID}}, nil)
if err != nil {
t.Fatal(err)
}
bad, err := probe.SavePingTask(probe.PingTask{Name: "bad", Target: "127.0.0.1:1", Interval: 60, AgentIDs: []int64{agentID}}, nil)
if err != nil {
t.Fatal(err)
}
st := probe.Default()
now := time.Now().Unix()
for i := 0; i < 3; i++ {
if err := st.InsertPings(agentID, []probe.PingResult{{TaskID: good, LatencyMs: 10}}, now-int64(i)*10); err != nil {
t.Fatal(err)
}
}
if err := st.InsertPings(agentID, []probe.PingResult{{TaskID: bad, LatencyMs: -1}}, now); err != nil {
t.Fatal(err)
}
rec := rig.do(http.MethodGet, "/api/nodes/"+itoa64(agentID)+"/metrics?series=ping&hours=1", nil, false)
if rec.Code != http.StatusOK {
t.Fatalf("metrics: %d %s", rec.Code, rec.Body.String())
}
var resp struct {
Ping []struct {
TaskID int64 `json:"task_id"`
Latency *int64 `json:"latency"`
Loss *int64 `json:"loss"`
} `json:"ping"`
Probes map[string]string `json:"probes"`
Loss map[string]float64 `json:"loss"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
var goodRow, badRow bool
for _, row := range resp.Ping {
switch row.TaskID {
case good:
goodRow = true
if row.Latency == nil || *row.Latency != 10 {
t.Fatalf("good latency %v", row.Latency)
}
case bad:
badRow = true
if row.Latency != nil {
t.Fatalf("bad latency %v, want null", *row.Latency)
}
if row.Loss == nil || *row.Loss != 100 {
t.Fatalf("bad bucket loss %v", row.Loss)
}
}
}
if !goodRow || !badRow {
t.Fatalf("rows %+v", resp.Ping)
}
if resp.Probes[itoa64(good)] != "good" || resp.Probes[itoa64(bad)] != "bad" {
t.Fatalf("probes %v", resp.Probes)
}
if resp.Loss[itoa64(bad)] != 100.0 {
t.Fatalf("window loss %v", resp.Loss)
}
if _, ok := resp.Loss[itoa64(good)]; ok {
t.Fatalf("good task in loss map %v", resp.Loss)
}
cleanPingTasks(t)
}
func itoa64(n int64) string {
return strings.TrimSpace(jsonNumber(n))
}
func jsonNumber(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
+189
View File
@@ -0,0 +1,189 @@
package controller
import (
"errors"
"io"
"net/http"
"strconv"
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
"github.com/gin-gonic/gin"
)
// Item 7 B5: theme store administration — list/install (chunked
// upload or GitHub address)/update/delete/switch, the public-surface
// settings block, and site icons. Endpoints ride the panel-
// authenticated API group and answer in the panel's Msg envelope;
// operator-facing refusals from the service layer (a bad package, a
// bad repository address) are shown verbatim, machine faults are
// logged and answered generically. The public contract surface in
// probe_public.go is separate and stays envelope-free.
type ProbeThemesController struct{}
func NewProbeThemesController(g *gin.RouterGroup) *ProbeThemesController {
pc := &ProbeThemesController{}
g.GET("/probe/themes", pc.list)
g.GET("/probe/themes/:short/preview", pc.preview)
g.POST("/probe/theme-upload", pc.upload)
g.POST("/probe/theme-install", pc.install)
g.POST("/probe/themes/:short/update", pc.update)
g.POST("/probe/themes/:short/select", pc.selectTheme)
g.DELETE("/probe/themes/:short", pc.remove)
g.GET("/probe/settings", pc.getSettings)
g.PUT("/probe/settings", pc.putSettings)
return pc
}
// themeFail answers a store operation: a refusal is the operator's
// answer, anything else is a machine fault (jsonMsgObj logs it).
func themeFail(c *gin.Context, what string, err error) {
var refusal *service.ProbeThemeRefusal
if errors.As(err, &refusal) {
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: refusal.Msg})
return
}
jsonMsg(c, what, err)
}
type probeThemesPayload struct {
Themes []service.ProbeTheme `json:"themes"`
Settings service.ProbePublicSettings `json:"settings"`
Favicon string `json:"favicon"`
TouchIcon string `json:"touchIcon"`
}
func (pc *ProbeThemesController) list(c *gin.Context) {
themes, err := service.ProbeThemes()
if err != nil {
jsonMsg(c, "load themes failed", err)
return
}
jsonObj(c, probeThemesPayload{
Themes: themes,
Settings: service.GetProbePublicSettings(),
Favicon: service.ProbeIconSetting("favicon"),
TouchIcon: service.ProbeIconSetting("touch_icon"),
}, nil)
}
func (pc *ProbeThemesController) preview(c *gin.Context) {
data := service.ProbeThemePreview(c.Param("short"))
if data == nil {
c.Status(http.StatusNotFound)
return
}
// Not cached: reinstalling a theme under the same name also
// replaces the image (hub theme_preview).
c.Header("Cache-Control", "no-cache")
c.Data(http.StatusOK, "image/png", data)
}
// upload receives one chunk of a theme.tar.gz (hub Chunk protocol:
// ?offset=&total=, the raw piece as the body). A non-final piece
// answers the length received so far; the final piece installs and
// answers the installed theme.
func (pc *ProbeThemesController) upload(c *gin.Context) {
offset, oerr := strconv.ParseInt(c.Query("offset"), 10, 64)
total, terr := strconv.ParseInt(c.Query("total"), 10, 64)
if oerr != nil || terr != nil || offset < 0 || total < 0 {
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "分片参数不对"})
return
}
body := io.LimitReader(c.Request.Body, service.MaxThemeArchive+1)
received, err := service.ReceiveProbeThemeChunk(offset, total, body)
if err != nil {
themeFail(c, "theme upload failed", err)
return
}
if received < total {
jsonObj(c, gin.H{"received": received}, nil)
return
}
theme, err := service.FinishProbeThemeUpload()
if err != nil {
themeFail(c, "theme install failed", err)
return
}
jsonObj(c, gin.H{"theme": theme}, nil)
}
type probeInstallForm struct {
URL string `json:"url"`
}
func (pc *ProbeThemesController) install(c *gin.Context) {
var form probeInstallForm
if err := c.ShouldBindJSON(&form); err != nil {
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "请求格式不对"})
return
}
theme, err := service.InstallProbeThemeFromRepo(form.URL)
if err != nil {
themeFail(c, "theme install failed", err)
return
}
jsonObj(c, gin.H{"theme": theme}, nil)
}
func (pc *ProbeThemesController) update(c *gin.Context) {
updated, version, err := service.ProbeThemeUpdate(c.Param("short"))
if err != nil {
themeFail(c, "theme update failed", err)
return
}
jsonObj(c, gin.H{"updated": updated, "version": version}, nil)
}
func (pc *ProbeThemesController) selectTheme(c *gin.Context) {
if err := service.SetProbeCurrentTheme(c.Param("short")); err != nil {
themeFail(c, "theme switch failed", err)
return
}
jsonMsg(c, "", nil)
}
func (pc *ProbeThemesController) remove(c *gin.Context) {
if err := service.RemoveProbeTheme(c.Param("short")); err != nil {
themeFail(c, "theme delete failed", err)
return
}
jsonMsg(c, "", nil)
}
type probeSettingsForm struct {
service.ProbePublicSettings
Favicon string `json:"favicon"`
TouchIcon string `json:"touchIcon"`
}
func (pc *ProbeThemesController) getSettings(c *gin.Context) {
jsonObj(c, probeSettingsForm{
ProbePublicSettings: service.GetProbePublicSettings(),
Favicon: service.ProbeIconSetting("favicon"),
TouchIcon: service.ProbeIconSetting("touch_icon"),
}, nil)
}
func (pc *ProbeThemesController) putSettings(c *gin.Context) {
var form probeSettingsForm
if err := c.ShouldBindJSON(&form); err != nil {
c.JSON(http.StatusOK, entity.Msg{Success: false, Msg: "请求格式不对"})
return
}
if err := service.SetProbePublicSettings(form.ProbePublicSettings); err != nil {
themeFail(c, "save probe settings failed", err)
return
}
if err := service.SetProbeIconSetting("favicon", form.Favicon); err != nil {
themeFail(c, "save probe settings failed", err)
return
}
if err := service.SetProbeIconSetting("touch_icon", form.TouchIcon); err != nil {
themeFail(c, "save probe settings failed", err)
return
}
jsonMsg(c, "", nil)
}
+22
View File
@@ -0,0 +1,22 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// ProbeHistoryJob is the hourly housekeeping of the item 7 probe
// history (roll_up + prune against probe.db). The heavy lifting lives
// in the probe package; the job is a thin cron shell, and every failure
// is logged there rather than propagated — a broken history store must
// never disturb the panel.
type ProbeHistoryJob struct{}
// NewProbeHistoryJob creates a probe history maintenance job instance.
func NewProbeHistoryJob() *ProbeHistoryJob {
return new(ProbeHistoryJob)
}
// Run executes one maintenance pass (roll_up, then prune).
func (j *ProbeHistoryJob) Run() {
probe.Maintain()
}
+38
View File
@@ -0,0 +1,38 @@
package job
import (
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probenotify"
)
// ProbeNotifyJob is the notification sweep (item 7 B6): every 30s it
// examines agent connectivity and billing-period traffic and queues
// the offline / recovery / traffic alerts. The engine swallows its
// own failures into log lines — a broken notification pass must
// never disturb the panel.
type ProbeNotifyJob struct{}
// NewProbeNotifyJob creates a probe notification sweep job instance.
func NewProbeNotifyJob() *ProbeNotifyJob {
return new(ProbeNotifyJob)
}
// Run executes one sweep pass.
func (j *ProbeNotifyJob) Run() {
probenotify.Sweep()
}
// ProbeNotifyHousekeepingJob is the hourly half (item 7 B6): roll
// online nodes' expiry dates forward, then send the day's expiry
// digest over the dates as they now stand.
type ProbeNotifyHousekeepingJob struct{}
// NewProbeNotifyHousekeepingJob creates a probe notification
// housekeeping job instance.
func NewProbeNotifyHousekeepingJob() *ProbeNotifyHousekeepingJob {
return new(ProbeNotifyHousekeepingJob)
}
// Run executes one housekeeping pass.
func (j *ProbeNotifyHousekeepingJob) Run() {
probenotify.Housekeeping()
}
+291
View File
@@ -0,0 +1,291 @@
package probe
import (
"math"
"time"
)
// Sample is one heartbeat's probe metrics block, as the master received
// it (item 7 B1 wire shape). A Sample is only built when the heartbeat
// carried a metrics block at all; individual zero values mirror the
// agent's softened reads, exactly like a hub report frame.
type Sample struct {
BootID string
Uptime uint64 // seconds; 0 = unreadable, i.e. no boot time
CPU float32
MemUsed uint64
SwapUsed uint64
DiskUsed uint64
NetRxTotal uint64 // kernel lifetime counters of the counted ifaces
NetTxTotal uint64
NetRx uint64 // agent-reported per-second rates (peaks, fallback)
NetTx uint64
TCP uint32
UDP uint32
Procs uint32
}
// reading is one report's pair of kernel byte counters, the boot epoch
// they belong to, and when the master received them (hub Reading).
type reading struct {
epoch string
rx, tx int64
at time.Time // wall clock: stamps and calendar dates
tick time.Time // monotonic anchor for rate durations
booted *int64 // unix seconds the machine booted, by master's clock
booked bool
}
// continuedBy reports whether next continues this reading's span. A new
// epoch or a counter that moved backwards leaves nothing to subtract.
func (r *reading) continuedBy(next *reading) bool {
return r.epoch == next.epoch && next.rx >= r.rx && next.tx >= r.tx
}
// minuteAcc is the running mean/peak accumulator for the minute in
// progress (hub Minute): mean fields are summed, peaks are maxed.
type minuteAcc struct {
n float64
cpuSum float64
memSum float64
swapSum float64
diskSum float64
tcpSum float64
udpSum float64
procsSum float64
cpuPeak float64
rxPeak uint64
txPeak uint64
}
func (a *minuteAcc) add(s *Sample) {
a.n++
a.cpuSum += float64(s.CPU)
a.memSum += float64(s.MemUsed)
a.swapSum += float64(s.SwapUsed)
a.diskSum += float64(s.DiskUsed)
a.tcpSum += float64(s.TCP)
a.udpSum += float64(s.UDP)
a.procsSum += float64(s.Procs)
if float64(s.CPU) > a.cpuPeak {
a.cpuPeak = float64(s.CPU)
}
if s.NetRx > a.rxPeak {
a.rxPeak = s.NetRx
}
if s.NetTx > a.txPeak {
a.txPeak = s.NetTx
}
}
// counterMark is where a history row's network rate starts: the kernel
// counters as an earlier report carried them (hub Mark).
type counterMark struct {
tick time.Time
epoch string
rx int64
tx int64
}
// nodeState is one agent's in-memory ingest state (hub Agent + the held
// reading of file()). It is rebuilt within one heartbeat of a master
// restart; nothing here is the only copy of anything booked.
type nodeState struct {
lastMinute *int64 // minute bucket the stored-row cadence is on
acc minuteAcc
mark *counterMark
held *reading
}
func u64toI64(v uint64) (int64, bool) {
if v > math.MaxInt64 {
return 0, false
}
return int64(v), true
}
// Record files one heartbeat sample: it books the traffic reading (hub
// file()) and, on a minute change, stores the aggregated minute row
// (hub report()). at is the arrival time on the master's clock;
// prevLastSeenSec is the agent's last-seen stamp from before this
// heartbeat (seconds), which the unseen-boot rule measures against.
//
// Errors are returned for tests; the facade swallows them.
func (s *Store) Record(agentID int64, smp Sample, at time.Time, prevLastSeenSec int64) error {
s.mu.Lock()
defer s.mu.Unlock()
st := s.nodes[agentID]
if st == nil {
st = &nodeState{}
s.nodes[agentID] = st
}
// The traffic reading exists only with an epoch and a valid pair of
// counters: no reading is not a reading of zero — booking zero would
// align the baseline to it and book the next report's lifetime
// counter as one delta.
var rd *reading
if smp.BootID != "" {
if rx, ok1 := u64toI64(smp.NetRxTotal); ok1 {
if tx, ok2 := u64toI64(smp.NetTxTotal); ok2 {
rd = &reading{epoch: smp.BootID, rx: rx, tx: tx, at: at, tick: at}
if smp.Uptime > 0 {
b := at.Unix() - int64(smp.Uptime)
rd.booted = &b
}
}
}
}
if rd != nil {
if err := s.file(st, agentID, rd, prevLastSeenSec); err != nil {
return err
}
}
// Minute row: the accumulator takes every sample; a sample arriving
// in a new minute flushes a row stamped with that minute, summarizing
// the interval since the previous row (hub semantics — the means
// include the flushing sample).
minute := at.Unix() / 60
st.acc.add(&smp)
store := false
if st.lastMinute == nil {
st.lastMinute = &minute
} else if *st.lastMinute != minute {
store = true
}
if store {
if err := s.storeRow(st, agentID, smp, rd, at, minute); err != nil {
return err
}
st.acc = minuteAcc{}
st.lastMinute = &minute
}
if rd != nil {
if store || st.mark == nil || st.mark.epoch != rd.epoch {
st.mark = &counterMark{tick: rd.tick, epoch: rd.epoch, rx: rd.rx, tx: rd.tx}
}
}
return nil
}
// file books readings into the traffic row about once a minute rather
// than with every report (hub file()): each booking is a DB write, and
// skipping the readings in between loses nothing because the kernel
// counter keeps counting — provided every break is booked on both
// sides:
//
// - no reading held: book this one;
// - a break (epoch change / counter backwards): book the held reading
// as the last of its span, then this one as the first of the next;
// - a new minute: book the held reading, dated by its own arrival, so
// bytes moved before midnight count toward that day; where it was
// already booked, book this one instead.
func (s *Store) file(st *nodeState, agentID int64, rd *reading, prevLastSeenSec int64) error {
book := func(r *reading) error {
_, err := s.accumulate(agentID, r.epoch, r.rx, r.tx, r.at, r.booted, prevLastSeenSec)
return err
}
var bookLast, bookThis bool
switch {
case st.held == nil:
bookThis = true
case !st.held.continuedBy(rd):
bookLast, bookThis = !st.held.booked, true
case st.held.at.Unix()/60 != rd.at.Unix()/60:
bookLast, bookThis = !st.held.booked, st.held.booked
default:
bookLast, bookThis = false, false
}
if bookLast {
if err := book(st.held); err != nil {
return err
}
}
if bookThis {
if err := book(rd); err != nil {
return err
}
}
rd.booked = bookThis
st.held = rd
return nil
}
// storeRow writes the flushed minute row (hub insert_metric via
// Minute.write_into): mean fields from the accumulator (integer means
// rounded), peaks from the accumulator, and the network rates derived
// from the counter mark when the span is unbroken — so the stored
// rates integrate to the booked traffic — else the agent's own rates
// from the flushing sample.
func (s *Store) storeRow(st *nodeState, agentID int64, smp Sample, rd *reading, at time.Time, minute int64) error {
acc := &st.acc
row := struct {
cpu, cpuMax float64
mem, swap, disk int64
netRx, netTx int64
netRxMax, netTxMax int64
tcp, udp, procs int64
}{
cpu: float64(smp.CPU), cpuMax: float64(smp.CPU),
mem: int64(smp.MemUsed), swap: int64(smp.SwapUsed), disk: int64(smp.DiskUsed),
netRx: int64(smp.NetRx), netTx: int64(smp.NetTx),
netRxMax: int64(smp.NetRx), netTxMax: int64(smp.NetTx),
tcp: int64(smp.TCP), udp: int64(smp.UDP), procs: int64(smp.Procs),
}
if acc.n > 0 {
row.cpu = acc.cpuSum / acc.n
row.mem = int64(math.Round(acc.memSum / acc.n))
row.swap = int64(math.Round(acc.swapSum / acc.n))
row.disk = int64(math.Round(acc.diskSum / acc.n))
row.tcp = int64(math.Round(acc.tcpSum / acc.n))
row.udp = int64(math.Round(acc.udpSum / acc.n))
row.procs = int64(math.Round(acc.procsSum / acc.n))
row.cpuMax = acc.cpuPeak
row.netRxMax = int64(acc.rxPeak)
row.netTxMax = int64(acc.txPeak)
}
if rd != nil && st.mark != nil && st.mark.epoch == rd.epoch {
elapsed := at.Sub(st.mark.tick).Seconds()
if elapsed < 1.0 {
elapsed = 1.0
}
if d := rd.rx - st.mark.rx; d > 0 {
row.netRx = int64(float64(d) / elapsed)
} else {
row.netRx = 0
}
if d := rd.tx - st.mark.tx; d > 0 {
row.netTx = int64(float64(d) / elapsed)
} else {
row.netTx = 0
}
}
_, err := s.db.Exec(`
INSERT OR REPLACE INTO probe_metric
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
tcp, udp, procs, net_rx_max, net_tx_max, cpu_max)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
agentID, minute*60, row.cpu, row.mem, row.swap, row.disk, row.netRx, row.netTx,
row.tcp, row.udp, row.procs, row.netRxMax, row.netTxMax, row.cpuMax)
return err
}
// BookHeld books every node's held, not-yet-booked reading (hub
// book_held): a node that reboots before reporting again would otherwise
// take the bytes since its last booking with it. Called on master
// shutdown; best-effort. prevLastSeen is no longer meaningful at
// shutdown, so the unseen-boot exception cannot fire from here (the
// readings being booked continue spans the master already saw).
func (s *Store) BookHeld() {
s.mu.Lock()
defer s.mu.Unlock()
for id, st := range s.nodes {
if st.held != nil && !st.held.booked {
r := st.held
if _, err := s.accumulate(id, r.epoch, r.rx, r.tx, r.at, r.booted, 0); err == nil {
r.booked = true
}
}
}
}
+175
View File
@@ -0,0 +1,175 @@
package probe
import (
"testing"
"time"
)
type metricRow struct {
ts int64
cpu, cpuMax float64
mem, swap, disk int64
netRx, netTx, netRxMax, netTxMax int64
tcp, udp, procs int64
}
func readMetricRows(t *testing.T, st *Store, agent int64) []metricRow {
t.Helper()
rows, err := st.db.Query(`
SELECT ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
tcp, udp, procs, net_rx_max, net_tx_max, cpu_max
FROM probe_metric WHERE agent_id = ? ORDER BY ts`, agent)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []metricRow
for rows.Next() {
var r metricRow
if err := rows.Scan(&r.ts, &r.cpu, &r.mem, &r.swap, &r.disk, &r.netRx, &r.netTx,
&r.tcp, &r.udp, &r.procs, &r.netRxMax, &r.netTxMax, &r.cpuMax); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func rec(t *testing.T, st *Store, agent int64, smp Sample, at time.Time) {
t.Helper()
if err := st.Record(agent, smp, at, 0); err != nil {
t.Fatalf("record: %v", err)
}
}
// Samples landing in the same minute bucket merge into one row when
// the next minute's sample flushes it: mean fields average (ints
// rounded), peaks max, and the stored net rate derives from the
// kernel counters between the row marks, not from the agent's rates.
func TestMinuteBucketMergeAndDerivedRates(t *testing.T) {
st := newTestStore(t)
base := time.Now().Add(-10 * time.Minute).Truncate(time.Minute)
mk := func(cpu float32, rxTot uint64, rate uint64, mem uint64) Sample {
return Sample{BootID: "k/fp", CPU: cpu, MemUsed: mem, NetRxTotal: rxTot, NetTxTotal: rxTot * 2,
NetRx: rate, NetTx: rate * 2, TCP: 3, UDP: 2, Procs: 10}
}
rec(t, st, 1, mk(10, 60_000, 1000, 100), base) // minute M, first sample
rec(t, st, 1, mk(20, 90_000, 1000, 200), base.Add(30*time.Second)) // minute M again
rec(t, st, 1, mk(30, 150_000, 1000, 300), base.Add(time.Minute)) // minute M+1: flushes
rows := readMetricRows(t, st, 1)
if len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
r := rows[0]
if r.ts != base.Add(time.Minute).Unix() {
t.Fatalf("row stamped %d, want flushing minute %d", r.ts, base.Add(time.Minute).Unix())
}
if r.cpu != 20 { // mean of 10,20,30 (flushing sample included)
t.Fatalf("cpu mean=%v, want 20", r.cpu)
}
if r.mem != 200 {
t.Fatalf("mem mean=%d, want 200", r.mem)
}
if r.cpuMax != 30 {
t.Fatalf("cpu_max=%v, want 30", r.cpuMax)
}
if r.netRx != 1500 { // (150000-60000) / 60s between marks
t.Fatalf("net_rx=%d, want 1500 (counter-derived)", r.netRx)
}
if r.netRxMax != 1000 {
t.Fatalf("net_rx_max=%d, want agent peak 1000", r.netRxMax)
}
// Next minute: single-sample row; rate from the new mark.
rec(t, st, 1, mk(40, 210_000, 700, 400), base.Add(2*time.Minute))
rows = readMetricRows(t, st, 1)
if len(rows) != 2 {
t.Fatalf("want 2 rows, got %d", len(rows))
}
r = rows[1]
if r.cpu != 40 || r.netRx != 1000 || r.netRxMax != 700 {
t.Fatalf("second row wrong: %+v", r)
}
}
// At an epoch break the counter mark no longer applies, so the row
// keeps the agent's own reported rates.
func TestMinuteRowEpochBreakKeepsAgentRates(t *testing.T) {
st := newTestStore(t)
base := time.Now().Add(-10 * time.Minute).Truncate(time.Minute)
rec(t, st, 1, Sample{BootID: "k1/fp", NetRxTotal: 1000, NetTxTotal: 1000, NetRx: 111, NetTx: 222}, base)
rec(t, st, 1, Sample{BootID: "k2/fp", NetRxTotal: 500, NetTxTotal: 500, NetRx: 333, NetTx: 444}, base.Add(time.Minute))
rows := readMetricRows(t, st, 1)
if len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
if rows[0].netRx != 333 || rows[0].netTx != 444 {
t.Fatalf("epoch break must keep agent rates: %+v", rows[0])
}
}
// Samples without a boot_id still file metric rows (hub files rows
// from any well-formed report) but never touch the traffic ledger.
func TestRecordWithoutBootFilesRowOnly(t *testing.T) {
st := newTestStore(t)
base := time.Now().Add(-10 * time.Minute).Truncate(time.Minute)
rec(t, st, 1, Sample{CPU: 5, MemUsed: 42}, base)
rec(t, st, 1, Sample{CPU: 7, MemUsed: 43}, base.Add(time.Minute))
if rows := readMetricRows(t, st, 1); len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
if _, ok, err := st.TrafficFor(1); err != nil || ok {
t.Fatalf("no traffic row expected: ok=%v err=%v", ok, err)
}
}
// The held-reading rhythm: readings inside a minute are not booked
// one by one; the minute change books the held one (dated by its own
// arrival), and BookHeld books the tail — so totals equal the full
// counter climb exactly once.
func TestHeldReadingRhythmAndBookHeld(t *testing.T) {
st := newTestStore(t)
base := time.Now().Add(-10 * time.Minute).Truncate(time.Minute)
mk := func(rx uint64) Sample { return Sample{BootID: "k/fp", NetRxTotal: rx, NetTxTotal: rx} }
rec(t, st, 1, mk(1000), base) // booked at once (first), delta 0
rec(t, st, 1, mk(1300), base.Add(20*time.Second)) // held
rec(t, st, 1, mk(1600), base.Add(40*time.Second)) // held (replaces)
rec(t, st, 1, mk(1900), base.Add(time.Minute)) // new minute: books held@40s (delta 600)
tr, ok, _ := st.TrafficFor(1)
if !ok || tr.TotalRx != 600 {
t.Fatalf("after minute change total=%d ok=%v, want 600", tr.TotalRx, ok)
}
rec(t, st, 1, mk(2200), base.Add(2*time.Minute)) // books held@60s (delta 300)
tr, _, _ = st.TrafficFor(1)
if tr.TotalRx != 900 {
t.Fatalf("total=%d, want 900", tr.TotalRx)
}
st.BookHeld() // books the @120s reading (delta 300)
tr, _, _ = st.TrafficFor(1)
if tr.TotalRx != 1200 {
t.Fatalf("after BookHeld total=%d, want 1200 (=2200-1000)", tr.TotalRx)
}
}
// A break (epoch change) books both sides at once: the held reading
// closes its span, the new one aligns the new baseline with delta 0.
func TestRecordBreakBooksBothSides(t *testing.T) {
st := newTestStore(t)
base := time.Now().Add(-10 * time.Minute).Truncate(time.Minute)
mk := func(boot string, rx uint64) Sample { return Sample{BootID: boot, NetRxTotal: rx, NetTxTotal: rx} }
rec(t, st, 1, mk("k1/fp", 1000), base)
rec(t, st, 1, mk("k1/fp", 1400), base.Add(20*time.Second)) // held, unbooked
rec(t, st, 1, mk("k2/fp", 50), base.Add(40*time.Second)) // break: books held (400) + new (0)
tr, _, _ := st.TrafficFor(1)
if tr.TotalRx != 400 {
t.Fatalf("break booking wrong: total=%d, want 400", tr.TotalRx)
}
rec(t, st, 1, mk("k2/fp", 150), base.Add(80*time.Second)) // new minute books held@40s (0), holds this
st.BookHeld()
tr, _, _ = st.TrafficFor(1)
if tr.TotalRx != 500 {
t.Fatalf("total=%d, want 500", tr.TotalRx)
}
}
+348
View File
@@ -0,0 +1,348 @@
package probe
import (
"database/sql"
"math"
"sort"
"strconv"
"time"
)
// watermark reads the roll-up watermark (unix seconds, hour-aligned):
// the next hour not yet folded into the hourly tier.
func (s *Store) watermark() (int64, bool, error) {
var v string
err := s.db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&v)
if err == sql.ErrNoRows {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
n, perr := strconv.ParseInt(v, 10, 64)
if perr != nil {
return 0, false, nil
}
return n, true, nil
}
func (s *Store) setWatermark(tx *sql.Tx, hour int64) error {
_, err := tx.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, ?)`,
rolledKey, strconv.FormatInt(hour, 10))
return err
}
func hourFloor(ts int64) int64 {
r := ts % 3600
if r < 0 {
r += 3600
}
return ts - r
}
// oldestStamp is the earliest ts across the minute-tier tables, used
// to initialize the watermark on first roll_up (hub `oldest`).
func (s *Store) oldestStamp() (int64, bool, error) {
var oldest sql.NullInt64
err := s.db.QueryRow(`
SELECT MIN(ts) FROM (
SELECT ts FROM probe_metric
UNION ALL SELECT ts FROM probe_ping_record
)`).Scan(&oldest)
if err != nil {
return 0, false, err
}
return oldest.Int64, oldest.Valid, nil
}
// RollUp folds closed hours from the minute tier into the hourly tier
// (hub roll_up): the watermark advances one hour per transaction, so a
// failure leaves the hour to be folded again rather than half folded,
// and INSERT OR REPLACE makes a second fold of the same hour a rewrite.
// Hours older than the retention floor are skipped (the watermark
// jumps to the floor), and the most recent closed hour waits out the
// late grace. Returns the number of hours folded.
func (s *Store) RollUp(now int64, keepDays int64) (int, error) {
if _, ok, err := s.watermark(); err != nil {
return 0, err
} else if !ok {
from := now
if oldest, valid, err := s.oldestStamp(); err != nil {
return 0, err
} else if valid {
from = oldest
}
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
if err := s.setWatermark(tx, hourFloor(from)); err != nil {
tx.Rollback()
return 0, err
}
if err := tx.Commit(); err != nil {
return 0, err
}
}
floor := hourFloor(now - keepDays*86400)
folded := 0
for {
ok, err := s.foldNext(floor, now-3600-lateSeconds)
if err != nil {
return folded, err
}
if !ok {
return folded, nil
}
folded++
}
}
// foldNext folds the hour at the watermark (or at floor when the
// watermark is older) and moves the watermark past it, in one
// transaction. False once the hour is past last.
func (s *Store) foldNext(floor, last int64) (bool, error) {
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
var wmVal string
err = tx.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&wmVal)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
wm, perr := strconv.ParseInt(wmVal, 10, 64)
if perr != nil {
return false, nil
}
hour := wm
if floor > hour {
hour = floor
}
if hour > last {
return false, nil
}
// Metric fold: means weighted naturally by COUNT, byte columns
// truncate their mean (a loss under one byte), the count columns
// round (hub fold_next).
if _, err := tx.Exec(`
INSERT OR REPLACE INTO probe_metric_hour
(agent_id, ts, minutes, cpu, cpu_max, mem_used, swap_used, disk_used,
net_rx, net_tx, net_rx_max, net_tx_max, tcp, udp, procs)
SELECT agent_id, ?, COUNT(*), AVG(cpu), MAX(MAX(cpu, cpu_max)),
CAST(AVG(mem_used) AS INTEGER), CAST(AVG(swap_used) AS INTEGER),
CAST(AVG(disk_used) AS INTEGER), CAST(AVG(net_rx) AS INTEGER),
CAST(AVG(net_tx) AS INTEGER), MAX(MAX(net_rx, net_rx_max)),
MAX(MAX(net_tx, net_tx_max)), CAST(ROUND(AVG(tcp)) AS INTEGER),
CAST(ROUND(AVG(udp)) AS INTEGER), CAST(ROUND(AVG(procs)) AS INTEGER)
FROM probe_metric WHERE ts >= ? AND ts < ? + 3600
GROUP BY agent_id`, hour, hour, hour); err != nil {
return false, err
}
// Ping fold: per agent and task, the hour's median/lo/hi over the
// answered results and the answered/lost counts (hub Tally).
pingRows, err := tx.Query(`
SELECT agent_id, task_id, latency FROM probe_ping_record
WHERE ts >= ? AND ts < ? + 3600 ORDER BY agent_id, task_id`, hour, hour)
if err != nil {
return false, err
}
type tallyKey struct{ agent, task int64 }
tallies := make(map[tallyKey]*pingTally)
var order []tallyKey
for pingRows.Next() {
var agent, task, latency int64
if err := pingRows.Scan(&agent, &task, &latency); err != nil {
pingRows.Close()
return false, err
}
k := tallyKey{agent, task}
t, ok := tallies[k]
if !ok {
t = &pingTally{}
tallies[k] = t
order = append(order, k)
}
t.add(latency)
}
if err := pingRows.Err(); err != nil {
pingRows.Close()
return false, err
}
pingRows.Close()
for _, k := range order {
t := tallies[k]
median, lo, hi := t.medianLoHi()
if _, err := tx.Exec(`
INSERT OR REPLACE INTO probe_ping_hour
(agent_id, ts, task_id, answered, lost, latency, lo, hi)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
k.agent, hour, k.task, t.answered, t.lost,
nullOr(median), nullOr(lo), nullOr(hi)); err != nil {
return false, err
}
}
if err := s.setWatermark(tx, hour+3600); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, err
}
return true, nil
}
func nullOr(v *int64) any {
if v == nil {
return nil
}
return *v
}
// pingTally accumulates one hour of probe results for one task
// (hub Tally over single results).
type pingTally struct {
answers []int64
lost int64
answered int64
lo, hi *int64
}
func (t *pingTally) add(latency int64) {
if latency < 0 {
t.lost++
return
}
t.answered++
t.answers = append(t.answers, latency)
if t.lo == nil || latency < *t.lo {
v := latency
t.lo = &v
}
if t.hi == nil || latency > *t.hi {
v := latency
t.hi = &v
}
}
// medianLoHi returns the median of the answers (the middle two
// averaged) and the lo/hi bounds; all nil when nothing answered.
func (t *pingTally) medianLoHi() (median, lo, hi *int64) {
if t.answered == 0 {
return nil, nil, nil
}
sort.Slice(t.answers, func(i, j int) bool { return t.answers[i] < t.answers[j] })
n := int64(len(t.answers))
mid := (t.answers[(n-1)/2] + t.answers[n/2]) / 2
return &mid, t.lo, t.hi
}
// Prune drops history beyond the retention window (hub prune): minute
// rows past DetailDays (or the window itself when shorter), hourly
// rows past keepDays. Minute rows are only deleted up to the roll-up
// watermark — a row outlives its window until its hour is folded, so a
// roll-up that has fallen behind costs disk rather than history.
// Traffic totals live in their own table precisely so history can be
// pruned freely. Deletes are per agent and chunked (a day per
// statement for the minute tier), releasing the write lock in between.
// Returns the number of rows deleted.
func (s *Store) Prune(keepDays int64) (int, error) {
now := time.Now().Unix()
detail := keepDays
if detail > DetailDays {
detail = DetailDays
}
minutesCutoff := now - detail*86400
hoursCutoff := now - keepDays*86400
ids, err := s.agentIDs()
if err != nil {
return 0, err
}
pruned := 0
type tableRule struct {
table string
cutoff int64
guarded bool
}
rules := []tableRule{
{"probe_metric", minutesCutoff, true},
{"probe_ping_record", minutesCutoff, true},
{"probe_metric_hour", hoursCutoff, false},
{"probe_ping_hour", hoursCutoff, false},
}
for _, id := range ids {
for _, rule := range rules {
for {
before := rule.cutoff
if rule.guarded {
// Guarded by the watermark: with none set, nothing
// is deletable (hub reads it as the minimum).
wm, ok, err := s.watermark()
if err != nil {
return pruned, err
}
if !ok {
before = math.MinInt64
} else if wm < before {
before = wm
}
}
var first sql.NullInt64
err := s.db.QueryRow(
`SELECT MIN(ts) FROM `+rule.table+` WHERE agent_id = ?`, id).Scan(&first)
if err != nil {
return pruned, err
}
if !first.Valid || first.Int64 >= before {
break
}
limit := before
if rule.guarded && first.Int64+86400 < limit {
limit = first.Int64 + 86400
}
res, err := s.db.Exec(
`DELETE FROM `+rule.table+` WHERE agent_id = ? AND ts < ?`, id, limit)
if err != nil {
return pruned, err
}
n, _ := res.RowsAffected()
pruned += int(n)
if n == 0 {
break
}
}
}
}
return pruned, nil
}
// agentIDs lists every agent with rows in any history table.
func (s *Store) agentIDs() ([]int64, error) {
rows, err := s.db.Query(`
SELECT agent_id FROM probe_metric
UNION SELECT agent_id FROM probe_ping_record
UNION SELECT agent_id FROM probe_metric_hour
UNION SELECT agent_id FROM probe_ping_hour
ORDER BY 1`)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
@@ -0,0 +1,199 @@
package probe
import (
"strconv"
"testing"
"time"
)
func strconvFormat(n int64) string { return strconv.FormatInt(n, 10) }
func seedMetric(t *testing.T, st *Store, agent, ts int64, cpu, cpuMax float64, mem, netRx, netRxMax, tcpC int64) {
t.Helper()
_, err := st.db.Exec(`
INSERT INTO probe_metric (agent_id, ts, cpu, mem_used, swap_used, disk_used,
net_rx, net_tx, tcp, udp, procs, net_rx_max, net_tx_max, cpu_max)
VALUES (?, ?, ?, ?, 0, 0, ?, 0, ?, 0, 0, ?, 0, ?)`,
agent, ts, cpu, mem, netRx, tcpC, netRxMax, cpuMax)
if err != nil {
t.Fatal(err)
}
}
func seedPing(t *testing.T, st *Store, agent, ts, task, latency int64) {
t.Helper()
if _, err := st.db.Exec(`INSERT INTO probe_ping_record (agent_id, ts, task_id, latency) VALUES (?, ?, ?, ?)`,
agent, ts, task, latency); err != nil {
t.Fatal(err)
}
}
// One closed hour folds into one hourly row per agent: means over the
// minute rows (byte columns truncated, counts rounded), peaks maxed.
func TestRollUpFoldsHour(t *testing.T) {
st := newTestStore(t)
now := time.Now().Unix()
hourA := hourFloor(now) - 3*3600 // closed and past the late grace
seedMetric(t, st, 1, hourA+60, 10, 15, 100, 100, 500, 1)
seedMetric(t, st, 1, hourA+120, 20, 25, 200, 100, 500, 2)
seedMetric(t, st, 1, hourA+180, 30, 35, 300, 100, 500, 4)
seedMetric(t, st, 2, hourA+60, 99, 99, 999, 9, 9, 9) // agent 2, same hour
// The most recent hour must not fold yet (late grace).
recent := hourFloor(now) - 3600
seedMetric(t, st, 1, recent+60, 77, 77, 777, 7, 7, 7)
folded, err := st.RollUp(now, 7)
if err != nil {
t.Fatal(err)
}
if folded == 0 {
t.Fatal("expected at least one folded hour")
}
var minutes, mem, netRx, netRxMax, tcpC int64
var cpu, cpuMax float64
err = st.db.QueryRow(`
SELECT minutes, cpu, cpu_max, mem_used, net_rx, net_rx_max, tcp
FROM probe_metric_hour WHERE agent_id = 1 AND ts = ?`, hourA).
Scan(&minutes, &cpu, &cpuMax, &mem, &netRx, &netRxMax, &tcpC)
if err != nil {
t.Fatal(err)
}
if minutes != 3 || cpu != 20 || cpuMax != 35 || mem != 200 || netRx != 100 || netRxMax != 500 || tcpC != 2 {
t.Fatalf("folded row wrong: minutes=%d cpu=%v cpuMax=%v mem=%d netRx=%d netRxMax=%d tcp=%d",
minutes, cpu, cpuMax, mem, netRx, netRxMax, tcpC)
}
var recentCount int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric_hour WHERE ts = ?`, recent).Scan(&recentCount); err != nil {
t.Fatal(err)
}
if recentCount != 0 {
t.Fatal("recent hour must still be unfolded")
}
wm, ok, err := st.watermark()
if err != nil || !ok {
t.Fatalf("watermark: ok=%v err=%v", ok, err)
}
if wm < hourA+3600 || wm > recent+3600 {
t.Fatalf("watermark %d must sit past hour A (%d) and no later than past the recent hour (%d)", wm, hourA, recent)
}
// Folding again is a rewrite, not a duplicate: same values.
if _, err := st.RollUp(now, 7); err != nil {
t.Fatal(err)
}
var count int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric_hour WHERE agent_id = 1 AND ts = ?`, hourA).Scan(&count); err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("hour rows for A = %d, want 1", count)
}
}
// Ping hours: answered/lost counts, the answered median, and lo/hi;
// an all-lost task keeps NULL latency fields.
func TestRollUpPingTallies(t *testing.T) {
st := newTestStore(t)
now := time.Now().Unix()
hourA := hourFloor(now) - 3*3600
seedPing(t, st, 1, hourA+10, 7, 10)
seedPing(t, st, 1, hourA+20, 7, 20)
seedPing(t, st, 1, hourA+30, 7, -1)
seedPing(t, st, 1, hourA+40, 7, 40)
seedPing(t, st, 1, hourA+10, 8, -1)
seedPing(t, st, 1, hourA+20, 8, -1)
if _, err := st.RollUp(now, 7); err != nil {
t.Fatal(err)
}
var answered, lost, latency, lo, hi int64
err := st.db.QueryRow(`
SELECT answered, lost, latency, lo, hi FROM probe_ping_hour
WHERE agent_id = 1 AND ts = ? AND task_id = 7`, hourA).Scan(&answered, &lost, &latency, &lo, &hi)
if err != nil {
t.Fatal(err)
}
if answered != 3 || lost != 1 || latency != 20 || lo != 10 || hi != 40 {
t.Fatalf("tally wrong: answered=%d lost=%d latency=%d lo=%d hi=%d", answered, lost, latency, lo, hi)
}
var latNull bool
err = st.db.QueryRow(`
SELECT latency IS NULL FROM probe_ping_hour
WHERE agent_id = 1 AND ts = ? AND task_id = 8`, hourA).Scan(&latNull)
if err != nil {
t.Fatal(err)
}
if !latNull {
t.Fatal("all-lost task must keep NULL latency")
}
}
// Prune respects the watermark: minute rows past the detail window
// survive until their hour is folded; hourly rows past the retention
// window go; the traffic ledger is never pruned.
func TestPruneBoundaries(t *testing.T) {
st := newTestStore(t)
now := time.Now()
nowU := now.Unix()
oldMinute := hourFloor(nowU) - 8*86400 // past the 7-day detail window
recentMinute := hourFloor(nowU) - 3600
seedMetric(t, st, 1, oldMinute+60, 1, 1, 1, 1, 1, 1)
seedMetric(t, st, 1, recentMinute+60, 1, 1, 1, 1, 1, 1)
// Watermark only covers up to oldMinute: the old row is NOT yet
// deletable even though it is past the window.
if _, err := st.db.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, ?)`,
rolledKey, strconvFormat(oldMinute)); err != nil {
t.Fatal(err)
}
if _, err := st.Prune(7); err != nil {
t.Fatal(err)
}
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric WHERE agent_id = 1`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 2 {
t.Fatalf("unfolded old row must survive prune, rows=%d", n)
}
// Roll forward past the old row's hour, making the watermark cover
// it; prune must now drop it (the recent row stays).
if _, err := st.db.Exec(`UPDATE probe_setting SET value = ? WHERE key = ?`,
strconvFormat(oldMinute+3600), rolledKey); err != nil {
t.Fatal(err)
}
if _, err := st.Prune(7); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric WHERE agent_id = 1`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("folded old row must be pruned, rows=%d", n)
}
// Hourly rows past retention go; within stay.
if _, err := st.db.Exec(`INSERT INTO probe_metric_hour (agent_id, ts, minutes, cpu, cpu_max, mem_used, swap_used, disk_used, net_rx, net_tx, net_rx_max, net_tx_max, tcp, udp, procs) VALUES (1, ?, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)`,
hourFloor(nowU)-8*86400); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(`INSERT INTO probe_metric_hour (agent_id, ts, minutes, cpu, cpu_max, mem_used, swap_used, disk_used, net_rx, net_tx, net_rx_max, net_tx_max, tcp, udp, procs) VALUES (1, ?, 60, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)`,
hourFloor(nowU)-6*86400); err != nil {
t.Fatal(err)
}
if _, err := st.Prune(7); err != nil {
t.Fatal(err)
}
if err := st.db.QueryRow(`SELECT COUNT(*) FROM probe_metric_hour WHERE agent_id = 1`).Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("hour rows=%d, want 1 (only the in-window one)", n)
}
// Traffic bookkeeping is never pruned.
book(t, st, 1, "k/fp", 100, 100, now.Add(-30*24*time.Hour), nil, 0)
book(t, st, 1, "k/fp", 300, 300, now.Add(-30*24*time.Hour).Add(time.Minute), nil, 0)
if _, err := st.Prune(7); err != nil {
t.Fatal(err)
}
tr, ok, _ := st.TrafficFor(1)
if !ok || tr.TotalRx != 200 {
t.Fatalf("traffic must survive prune: %+v ok=%v", tr, ok)
}
}
+161
View File
@@ -0,0 +1,161 @@
package probe
import (
"database/sql"
"strconv"
"strings"
)
// Meta is one agent's probe display/operations metadata (hub node row's
// operator-owned half, ITEM7-SCOPE §5.2): what the public page shows
// beyond the machine's own reports — price/expiry/plan figures, the
// public switch and ordering, grouping and the public remark, the
// notify opt-in and the hand-set address/country pins. Rows are
// ensured with hub defaults on the agent's first report; the editing
// surface (B3) reads and writes them here.
type Meta struct {
AgentID int64
Price float64
Currency string
BillingCycle string
ExpiresAt *string // "2006-01-02" or nil
TrafficLimit int64
TrafficMode string
TrafficResetDay int64
Public bool
Sort int64
GroupName string
PublicRemark string
Notify bool
CountryPin string
IPv4Pin string
IPv6Pin string
}
// DefaultMeta returns the metadata of an agent that has no row yet —
// the same values the row would carry once ensured (the probe_meta
// DDL defaults).
func DefaultMeta(agentID int64) Meta {
return Meta{
AgentID: agentID,
Currency: "USD",
BillingCycle: "monthly",
TrafficMode: "sum",
TrafficResetDay: 1,
Public: true,
}
}
const metaColumns = `agent_id, price, currency, billing_cycle, expires_at,
traffic_limit, traffic_mode, traffic_reset_day, public, sort,
group_name, public_remark, notify, country_pin, ipv4_pin, ipv6_pin`
func scanMeta(sc interface{ Scan(...any) error }) (Meta, error) {
var m Meta
var expires sql.NullString
var public, notify int64
if err := sc.Scan(&m.AgentID, &m.Price, &m.Currency, &m.BillingCycle, &expires,
&m.TrafficLimit, &m.TrafficMode, &m.TrafficResetDay, &public, &m.Sort,
&m.GroupName, &m.PublicRemark, &notify, &m.CountryPin, &m.IPv4Pin, &m.IPv6Pin); err != nil {
return Meta{}, err
}
if expires.Valid && strings.TrimSpace(expires.String) != "" {
v := expires.String
m.ExpiresAt = &v
}
m.Public = public != 0
m.Notify = notify != 0
return m, nil
}
// Meta reads one agent's metadata row. ok is false when the agent has
// no row yet (it never reported); callers then use DefaultMeta.
func (s *Store) Meta(agentID int64) (Meta, bool, error) {
row := s.db.QueryRow(`SELECT `+metaColumns+` FROM probe_meta WHERE agent_id = ?`, agentID)
m, err := scanMeta(row)
if err == sql.ErrNoRows {
return Meta{}, false, nil
}
if err != nil {
return Meta{}, false, err
}
return m, true, nil
}
// AllMeta reads every metadata row, keyed by agent id.
func (s *Store) AllMeta() (map[int64]Meta, error) {
rows, err := s.db.Query(`SELECT ` + metaColumns + ` FROM probe_meta`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]Meta)
for rows.Next() {
m, err := scanMeta(rows)
if err != nil {
return nil, err
}
out[m.AgentID] = m
}
return out, rows.Err()
}
// UpsertMeta writes one agent's metadata row (insert or full replace).
func (s *Store) UpsertMeta(m Meta) error {
var expires any
if m.ExpiresAt != nil && strings.TrimSpace(*m.ExpiresAt) != "" {
expires = *m.ExpiresAt
}
_, err := s.db.Exec(
`INSERT INTO probe_meta (`+metaColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(agent_id) DO UPDATE SET
price=excluded.price, currency=excluded.currency,
billing_cycle=excluded.billing_cycle, expires_at=excluded.expires_at,
traffic_limit=excluded.traffic_limit, traffic_mode=excluded.traffic_mode,
traffic_reset_day=excluded.traffic_reset_day, public=excluded.public,
sort=excluded.sort, group_name=excluded.group_name,
public_remark=excluded.public_remark, notify=excluded.notify,
country_pin=excluded.country_pin, ipv4_pin=excluded.ipv4_pin,
ipv6_pin=excluded.ipv6_pin`,
m.AgentID, m.Price, m.Currency, m.BillingCycle, expires,
m.TrafficLimit, m.TrafficMode, m.TrafficResetDay, boolToInt(m.Public), m.Sort,
m.GroupName, m.PublicRemark, boolToInt(m.Notify), m.CountryPin, m.IPv4Pin, m.IPv6Pin)
return err
}
func boolToInt(b bool) int64 {
if b {
return 1
}
return 0
}
// DeleteAgentData removes every probe row of an agent that no longer
// exists: metadata, the traffic ledger and both history tiers. The
// hub keeps history inside its own node table; here probe.db is a
// separate file, so the panel-side agent deletion calls this to keep
// the two stores in step (B2 report §6 cleanup point).
func (s *Store) DeleteAgentData(agentID int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, table := range []string{
"probe_meta", "probe_traffic", "probe_metric",
"probe_metric_hour", "probe_ping_record", "probe_ping_hour",
"probe_ping_node",
} {
if _, err := tx.Exec(`DELETE FROM `+table+` WHERE agent_id = ?`, agentID); err != nil {
return err
}
}
// The B6 offline mark is keyed per agent in probe_setting; the
// traffic steps are one shared JSON value the sweep prunes to
// live agents on its next write, so only the mark is struck here.
if _, err := tx.Exec(`DELETE FROM probe_setting WHERE key = ?`,
"notify_down:"+strconv.FormatInt(agentID, 10)); err != nil {
return err
}
return tx.Commit()
}
@@ -0,0 +1,60 @@
package probe
import (
"database/sql"
"errors"
)
// Item 7 B6: the notification engine's durable state lives in
// probe_setting, next to the roll-up watermark — the offline marks
// (one key per agent, "notify_down:<id>", the hub's down_since column
// in KV form so DeleteAgentData can strike exactly one agent's mark),
// the traffic steps already announced ("traffic_alerted", hub's key
// of the same name) and the expiry digest's last-sent date
// ("notify_expiry_sent"). The store stays the only writer of
// probe.db; the notify package reads and writes through here.
// StateGet reads one probe_setting value from the default store.
// ok is false when the key was never written; err is non-nil when no
// store is available, which callers treat as "skip this section"
// (the hub skips the traffic steps on a failed read rather than
// recording crossings it did not announce).
func StateGet(key string) (string, bool, error) {
st := Default()
if st == nil {
return "", false, errors.New("probe store unavailable")
}
return st.getState(key)
}
// StateSet writes one probe_setting value on the default store. An
// empty value deletes the key, so "cleared" and "never set" are the
// same state, as with the hub's down_since = 0.
func StateSet(key, value string) error {
st := Default()
if st == nil {
return errors.New("probe store unavailable")
}
return st.setState(key, value)
}
func (s *Store) getState(key string) (string, bool, error) {
var v string
err := s.db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, key).Scan(&v)
if err == sql.ErrNoRows {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, true, nil
}
func (s *Store) setState(key, value string) error {
if value == "" {
_, err := s.db.Exec(`DELETE FROM probe_setting WHERE key = ?`, key)
return err
}
_, err := s.db.Exec(`INSERT OR REPLACE INTO probe_setting (key, value) VALUES (?, ?)`, key, value)
return err
}
+739
View File
@@ -0,0 +1,739 @@
package probe
import (
"database/sql"
"errors"
"fmt"
"sort"
"strconv"
)
// Item 7 B4: probe (latency) tasks — the operator-maintained target
// set, its per-agent assignment, the filing of agent-reported results
// and the chart read. A port of hub db.rs's ping section and api.rs's
// validation chain (ITEM7-SCOPE §5.7): no preset targets exist, the
// operator keeps the list (grouped by the free-form region label).
//
// The one structural difference from the hub: hub nodes live in the
// same database, so its foreign keys police assignments. Here the
// agents live in the panel database and probe.db cannot reference
// them; the controller validates agent existence against the panel
// database before saving, and filing still re-checks the assignment
// inside the insert statement, exactly like hub insert_pings.
// MaxProbesPerNode bounds how many probe tasks one agent may be
// assigned; the agent enforces the same cap as a backstop (hub
// MAX_PROBES_PER_NODE). The master refuses the write that would
// exceed it, because a silent truncation would draw empty charts.
const MaxProbesPerNode = 64
// MinProbeInterval is the fastest probe cadence, in seconds; the
// editing surface refuses anything outside [MinProbeInterval, 3600]
// rather than clamping it (hub save_ping_task).
const MinProbeInterval = 5
// pingTaskIDIssuedKey is the probe_setting high-water mark of issued
// ping task ids (hub "{table}_id_issued").
const pingTaskIDIssuedKey = "ping_task_id_issued"
// PingTask is one probe target as the panel keeps it. AgentIDs is the
// assignment side (probe_ping_node), filled by reads.
type PingTask struct {
ID int64
Name string
Target string
Interval int64
AutoJoin bool
Sort int64
Region string
AgentIDs []int64
}
// PingTaskRef is the wire form of one task as pushed to an agent:
// id, target and interval, nothing else (hub ping_tasks_for).
type PingTaskRef struct {
ID int64 `json:"id"`
Target string `json:"target"`
Interval int64 `json:"interval"`
}
// PingResult is one probe sample an agent reports: the task it ran
// and the handshake latency in milliseconds (negative = the target
// did not answer; a resolution overrun produces no sample at all and
// never reaches this type).
type PingResult struct {
TaskID int64
LatencyMs int64
}
// ErrPingTaskGone reports an update against a task another session
// deleted first (hub save_ping_task's refusal).
var ErrPingTaskGone = errors.New("监控不存在,可能已被删除")
// PingCrowdedError reports a save that would leave one agent over
// MaxProbesPerNode; the controller renders the hub message with the
// agent's display name.
type PingCrowdedError struct{ AgentID int64 }
func (e *PingCrowdedError) Error() string {
return fmt.Sprintf("agent %d would exceed %d probe tasks", e.AgentID, MaxProbesPerNode)
}
// PingAutoJoinError reports a save under which the next agent to
// appear would auto-join more tasks than an agent can run.
type PingAutoJoinError struct{ Count int64 }
func (e *PingAutoJoinError) Error() string {
return fmt.Sprintf("new nodes would auto-join %d probe tasks", e.Count)
}
const pingTaskColumns = `id, name, target, interval, auto_join, sort, region`
func scanPingTask(sc interface{ Scan(...any) error }) (PingTask, error) {
var t PingTask
var autoJoin int64
if err := sc.Scan(&t.ID, &t.Name, &t.Target, &t.Interval, &autoJoin, &t.Sort, &t.Region); err != nil {
return PingTask{}, err
}
t.AutoJoin = autoJoin != 0
return t, nil
}
// PingTasks lists every probe task in panel order (sort, then id),
// each with its assigned agent ids.
func (s *Store) PingTasks() ([]PingTask, error) {
rows, err := s.db.Query(`SELECT ` + pingTaskColumns + ` FROM probe_ping_task ORDER BY sort, id`)
if err != nil {
return nil, err
}
var tasks []PingTask
for rows.Next() {
t, err := scanPingTask(rows)
if err != nil {
rows.Close()
return nil, err
}
tasks = append(tasks, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, err
}
if tasks == nil {
tasks = []PingTask{}
}
assignRows, err := s.db.Query(`SELECT task_id, agent_id FROM probe_ping_node ORDER BY agent_id`)
if err != nil {
return nil, err
}
defer assignRows.Close()
byTask := make(map[int64][]int64)
for assignRows.Next() {
var taskID, agentID int64
if err := assignRows.Scan(&taskID, &agentID); err != nil {
return nil, err
}
byTask[taskID] = append(byTask[taskID], agentID)
}
if err := assignRows.Err(); err != nil {
return nil, err
}
for i := range tasks {
if ids := byTask[tasks[i].ID]; ids != nil {
tasks[i].AgentIDs = ids
} else {
tasks[i].AgentIDs = []int64{}
}
}
return tasks, nil
}
// nextPingTaskID issues the next task id from the high-water mark,
// falling back to the table's own maximum for databases that predate
// the mark (hub next_id/issued). Runs inside the save transaction.
func nextPingTaskID(tx *sql.Tx) (int64, error) {
var issued, maxID int64
var raw sql.NullString
if err := tx.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, pingTaskIDIssuedKey).Scan(&raw); err != nil && err != sql.ErrNoRows {
return 0, err
} else if raw.Valid {
if n, perr := strconv.ParseInt(raw.String, 10, 64); perr == nil {
issued = n
}
}
if err := tx.QueryRow(`SELECT COALESCE(MAX(id), 0) FROM probe_ping_task`).Scan(&maxID); err != nil {
return 0, err
}
next := issued
if maxID > next {
next = maxID
}
next++
if _, err := tx.Exec(`INSERT INTO probe_setting (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
pingTaskIDIssuedKey, fmt.Sprint(next)); err != nil {
return 0, err
}
return next, nil
}
// SavePingTask creates (ID == 0) or replaces one probe task and its
// assignments, in one transaction (hub save_ping_task). base, when
// non-nil on an update, is the assignment set the editor started
// from: only the difference from it is applied, so an assignment
// made while the editor was open survives. Without base the
// assignment is replaced wholesale. The caller validates field
// values and agent existence beforehand (hub api layer).
func (s *Store) SavePingTask(t PingTask, base *[]int64) (int64, error) {
tx, err := s.db.Begin()
if err != nil {
return 0, err
}
defer tx.Rollback()
id := t.ID
if id > 0 {
res, err := tx.Exec(`UPDATE probe_ping_task SET name=?2, target=?3, interval=?4, auto_join=?5, region=?6 WHERE id=?1`,
id, t.Name, t.Target, t.Interval, boolToInt(t.AutoJoin), t.Region)
if err != nil {
return 0, err
}
if n, err := res.RowsAffected(); err != nil {
return 0, err
} else if n == 0 {
// Deleted from another session; without this the save
// would report success against nothing.
return 0, ErrPingTaskGone
}
} else {
next, err := nextPingTaskID(tx)
if err != nil {
return 0, err
}
if _, err := tx.Exec(`INSERT INTO probe_ping_task (id, name, target, interval, auto_join, sort, region)
VALUES (?1, ?2, ?3, ?4, ?5, (SELECT COALESCE(MAX(sort), -1) + 1 FROM probe_ping_task), ?6)`,
next, t.Name, t.Target, t.Interval, boolToInt(t.AutoJoin), t.Region); err != nil {
return 0, err
}
id = next
}
var added []int64
if base != nil && t.ID > 0 {
baseSet := int64Set(*base)
newSet := int64Set(t.AgentIDs)
for _, agentID := range *base {
if !newSet[agentID] {
if _, err := tx.Exec(`DELETE FROM probe_ping_node WHERE task_id=?1 AND agent_id=?2`, id, agentID); err != nil {
return 0, err
}
}
}
for _, agentID := range t.AgentIDs {
if !baseSet[agentID] {
added = append(added, agentID)
}
}
} else {
if _, err := tx.Exec(`DELETE FROM probe_ping_node WHERE task_id=?1`, id); err != nil {
return 0, err
}
added = t.AgentIDs
}
for _, agentID := range added {
if _, err := tx.Exec(`INSERT OR IGNORE INTO probe_ping_node (task_id, agent_id) VALUES (?1, ?2)`, id, agentID); err != nil {
return 0, err
}
}
if err := refuseCrowded(tx, nil); err != nil {
return 0, err
}
var joining int64
if err := tx.QueryRow(`SELECT COUNT(*) FROM probe_ping_task WHERE auto_join`).Scan(&joining); err != nil {
return 0, err
}
if joining > MaxProbesPerNode {
return 0, &PingAutoJoinError{Count: joining}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return id, nil
}
// refuseCrowded fails the transaction when some agent is assigned
// more tasks than an agent will run — queried from the table after
// the rows are in, inside the write's own transaction (hub
// refuse_crowded). onlyAgent, when set, scopes the check to that
// agent: another agent already over the limit is not this edit's
// problem to refuse.
func refuseCrowded(tx *sql.Tx, onlyAgent *int64) error {
var scope any
if onlyAgent != nil {
scope = *onlyAgent
}
var agentID sql.NullInt64
err := tx.QueryRow(`SELECT agent_id FROM probe_ping_node
WHERE ?1 IS NULL OR agent_id = ?1
GROUP BY agent_id HAVING COUNT(*) > ?2 LIMIT 1`, scope, MaxProbesPerNode).Scan(&agentID)
if err == sql.ErrNoRows {
return nil
}
if err != nil {
return err
}
return &PingCrowdedError{AgentID: agentID.Int64}
}
// SetNodePingTasks edits one agent's assignments from the agent's
// side (hub set_node_ping_tasks): only the difference from base is
// applied, inside one transaction. The caller checks the agent exists.
func (s *Store) SetNodePingTasks(agentID int64, tasks, base []int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
taskSet := int64Set(tasks)
baseSet := int64Set(base)
for _, taskID := range base {
if !taskSet[taskID] {
if _, err := tx.Exec(`DELETE FROM probe_ping_node WHERE task_id=?1 AND agent_id=?2`, taskID, agentID); err != nil {
return err
}
}
}
for _, taskID := range tasks {
if !baseSet[taskID] {
// A task deleted since the editor opened fails its foreign
// key on the hub; probe.db has no cross-database key, so
// existence is tested in the statement instead — naming
// the probe is all the panel needs.
res, err := tx.Exec(`INSERT OR IGNORE INTO probe_ping_node (task_id, agent_id)
SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM probe_ping_task WHERE id = ?1)`, taskID, agentID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return errors.New("有监控已被删除,刷新后重试")
}
}
}
if err := refuseCrowded(tx, &agentID); err != nil {
return err
}
return tx.Commit()
}
// DeletePingTask removes a task and everything filed under it
// (hub delete_ping_task): the record table is cleared explicitly,
// the node's assignments and the hourly tier go with it.
func (s *Store) DeletePingTask(id int64) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
for _, stmt := range []string{
`DELETE FROM probe_ping_record WHERE task_id = ?`,
`DELETE FROM probe_ping_hour WHERE task_id = ?`,
`DELETE FROM probe_ping_node WHERE task_id = ?`,
`DELETE FROM probe_ping_task WHERE id = ?`,
} {
if _, err := tx.Exec(stmt, id); err != nil {
return err
}
}
return tx.Commit()
}
// ReorderPingTasks rewrites the panel order (hub reorder): the id
// list must name every task exactly once, checked against the table
// inside the transaction, so a list edited elsewhere cannot sort
// against a stale view.
func (s *Store) ReorderPingTasks(ids []int64) error {
seen := make(map[int64]bool, len(ids))
for _, id := range ids {
if seen[id] {
return errors.New("排序里有重复的条目")
}
seen[id] = true
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var count int64
if err := tx.QueryRow(`SELECT COUNT(*) FROM probe_ping_task`).Scan(&count); err != nil {
return err
}
if count != int64(len(ids)) {
return errors.New("列表已在别处改动,刷新后再排序")
}
for i, id := range ids {
res, err := tx.Exec(`UPDATE probe_ping_task SET sort=?2 WHERE id=?1`, id, int64(i))
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return errors.New("列表已在别处改动,刷新后再排序")
}
}
return tx.Commit()
}
// PingTasksFor returns the task list pushed to one agent: id order,
// because the agent's own cap keeps the first MaxProbesPerNode and an
// unordered list at that boundary could yield a different subset on
// each push (hub ping_tasks_for).
func (s *Store) PingTasksFor(agentID int64) ([]PingTaskRef, error) {
rows, err := s.db.Query(`SELECT t.id, t.target, t.interval FROM probe_ping_task t
JOIN probe_ping_node n ON n.task_id = t.id WHERE n.agent_id = ?1 ORDER BY t.id`, agentID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []PingTaskRef{}
for rows.Next() {
var r PingTaskRef
if err := rows.Scan(&r.ID, &r.Target, &r.Interval); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
// PingTaskNames returns the chart labels of one agent's probes
// (id → name), restricted to the tasks assigned to it (hub
// ping_task_names): names are operator text and the rest of the
// table belongs to agents this caller may not be able to see.
func (s *Store) PingTaskNames(agentID int64) (map[string]string, error) {
rows, err := s.db.Query(`SELECT id, name FROM probe_ping_task
WHERE id IN (SELECT task_id FROM probe_ping_node WHERE agent_id = ?1)`, agentID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var id int64
var name string
if err := rows.Scan(&id, &name); err != nil {
return nil, err
}
out[fmt.Sprint(id)] = name
}
return out, rows.Err()
}
// InsertPings files one agent's reported results at the arrival
// stamp ts, one transaction for the batch (hub insert_pings). A
// result only lands under a task the agent is assigned: the test is
// inside the statement, the only place it is atomic with the write.
// Results for anything else — a probe deleted while its results were
// in flight, a task id the reporter invented — are dropped, not
// errors, since the agent can do nothing useful with the distinction.
func (s *Store) InsertPings(agentID int64, results []PingResult, ts int64) error {
if len(results) == 0 {
return nil
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT OR REPLACE INTO probe_ping_record (agent_id, ts, task_id, latency)
SELECT ?1, ?2, ?3, ?4
WHERE EXISTS (SELECT 1 FROM probe_ping_node WHERE task_id = ?3 AND agent_id = ?1)`)
if err != nil {
return err
}
defer stmt.Close()
for _, r := range results {
if _, err := stmt.Exec(agentID, ts, r.TaskID, r.LatencyMs); err != nil {
return err
}
}
return tx.Commit()
}
// PingRow is one thinned latency point of the contract chart (hub
// ping_records rows): the bucket's median answer, present keys only
// — band appears when the bucket actually varied, loss when it lost
// anything (absence reads as 0, the themes' documented semantics).
type PingRow struct {
TaskID int64 `json:"task_id"`
Ts int64 `json:"ts"`
Latency *int64 `json:"latency"`
Band *[2]int64 `json:"band,omitempty"`
Loss *int64 `json:"loss,omitempty"`
}
type pingWeightedMedian struct {
value int64
weight int64
}
// pingReadTally is one task's samples within one bucket of the chart
// read (hub Tally): hourly rows enter as their hour's median weighted
// by their answer count, single results as themselves.
type pingReadTally struct {
medians []pingWeightedMedian
lost int64
lo, hi *int64
}
func (t *pingReadTally) add(answered, lost int64, median, lo, hi *int64) {
if median != nil {
t.medians = append(t.medians, pingWeightedMedian{value: *median, weight: answered})
}
t.lost += lost
if lo != nil && (t.lo == nil || *lo < *t.lo) {
v := *lo
t.lo = &v
}
if hi != nil && (t.hi == nil || *hi > *t.hi) {
v := *hi
t.hi = &v
}
}
func (t *pingReadTally) answered() int64 {
var total int64
for _, m := range t.medians {
total += m.weight
}
return total
}
// median is the weighted median of the answers: the middle answer's
// value, or the average of the middle two (hub Tally::median).
func (t *pingReadTally) median() *int64 {
if len(t.medians) == 0 {
return nil
}
sorted := append([]pingWeightedMedian(nil), t.medians...)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].value != sorted[j].value {
return sorted[i].value < sorted[j].value
}
return sorted[i].weight < sorted[j].weight
})
total := t.answered()
at := func(rank int64) (int64, bool) {
var seen int64
for _, m := range sorted {
seen += m.weight
if seen >= rank {
return m.value, true
}
}
return 0, false
}
lo, ok1 := at((total + 1) / 2)
hi, ok2 := at(total/2 + 1)
if !ok1 || !ok2 {
return nil
}
v := (lo + hi) / 2
return &v
}
// PingHistory answers one agent's latency window (hub ping_records):
// one row per task per step-sized bucket holding the bucket median,
// its range and its loss, plus the per-task loss over the whole
// window (unrounded — the caller renders it). Only tasks still
// assigned to the agent contribute. Hourly windows merge the folded
// hourly tier (up to the watermark) with the minute rows after it,
// one read snapshot throughout.
func (s *Store) PingHistory(agentID int64, sp Span) ([]PingRow, map[string]float64, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, nil, err
}
defer tx.Rollback()
out := []PingRow{}
totals := make(map[int64][2]int64) // task → [lost, samples]
type openTally struct {
task int64
tally *pingReadTally
}
var open []openTally
var bucket int64
flush := func() {
for _, o := range open {
row := PingRow{TaskID: o.task, Ts: bucket * sp.Step, Latency: o.tally.median()}
if o.tally.lo != nil && o.tally.hi != nil && *o.tally.hi > *o.tally.lo {
row.Band = &[2]int64{*o.tally.lo, *o.tally.hi}
}
if o.tally.lost > 0 {
total := o.tally.answered() + o.tally.lost
loss := (100*o.tally.lost + total - 1) / total
row.Loss = &loss
}
out = append(out, row)
}
open = open[:0]
}
feed := func(b, task int64, answered, lost int64, median, lo, hi *int64) {
if b != bucket {
flush()
bucket = b
}
tot := totals[task]
tot[0] += lost
tot[1] += answered + lost
totals[task] = tot
for i := range open {
if open[i].task == task {
open[i].tally.add(answered, lost, median, lo, hi)
return
}
}
tally := &pingReadTally{}
tally.add(answered, lost, median, lo, hi)
open = append(open, openTally{task, tally})
}
feedRow := func(b, task, latency int64) {
if latency >= 0 {
feed(b, task, 1, 0, &latency, &latency, &latency)
} else {
feed(b, task, 0, 1, nil, nil, nil)
}
}
minutesFrom := sp.Since
if sp.Hourly {
rolled := int64(-1) << 62 // hub: unwrap_or(i64::MIN)
var raw sql.NullString
if qerr := tx.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw); qerr != nil && qerr != sql.ErrNoRows {
return nil, nil, qerr
} else if raw.Valid {
if n, perr := parseInt64(raw.String); perr == nil {
rolled = n
}
}
var newest sql.NullInt64
if err := tx.QueryRow(`SELECT MAX(ts) FROM probe_ping_record WHERE agent_id=?1`, agentID).Scan(&newest); err != nil {
return nil, nil, err
}
if newest.Valid && newest.Int64-DetailDays*86400 > minutesFrom {
minutesFrom = newest.Int64 - DetailDays*86400
}
if rolled > minutesFrom {
minutesFrom = rolled
}
hours, err := tx.Query(`SELECT ts/?3, task_id, answered, lost, latency, lo, hi FROM probe_ping_hour
WHERE agent_id=?1 AND ts>=?2 AND ts<?4
AND task_id IN (SELECT task_id FROM probe_ping_node WHERE agent_id=?1)
ORDER BY ts`, agentID, sp.Since, sp.Step, rolled)
if err != nil {
return nil, nil, err
}
for hours.Next() {
var b, task, answered, lost int64
var latency, lo, hi sql.NullInt64
if err := hours.Scan(&b, &task, &answered, &lost, &latency, &lo, &hi); err != nil {
hours.Close()
return nil, nil, err
}
feed(b, task, answered, lost, nullIntPtr(latency), nullIntPtr(lo), nullIntPtr(hi))
}
if err := hours.Err(); err != nil {
hours.Close()
return nil, nil, err
}
hours.Close()
}
rows, err := tx.Query(`SELECT ts/?3, task_id, latency FROM probe_ping_record
WHERE agent_id=?1 AND ts>=?2
AND task_id IN (SELECT task_id FROM probe_ping_node WHERE agent_id=?1)
ORDER BY ts`, agentID, minutesFrom, sp.Step)
if err != nil {
return nil, nil, err
}
for rows.Next() {
var b, task, latency int64
if err := rows.Scan(&b, &task, &latency); err != nil {
rows.Close()
return nil, nil, err
}
feedRow(b, task, latency)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, nil, err
}
rows.Close()
flush()
// Panel order decides each task's series order (hub: themes take
// colours and legend from first appearance across the window,
// which per-bucket feeding would leave to chance).
rank := make(map[int64]int)
rankRows, err := tx.Query(`SELECT id FROM probe_ping_task ORDER BY sort, id`)
if err != nil {
return nil, nil, err
}
for rankRows.Next() {
var id int64
if err := rankRows.Scan(&id); err != nil {
rankRows.Close()
return nil, nil, err
}
rank[id] = len(rank)
}
rankRows.Close()
if err := rankRows.Err(); err != nil {
return nil, nil, err
}
const unranked = int(^uint(0) >> 1)
sort.SliceStable(out, func(i, j int) bool {
ri, ok := rank[out[i].TaskID]
if !ok {
ri = unranked
}
rj, ok := rank[out[j].TaskID]
if !ok {
rj = unranked
}
return ri < rj
})
loss := map[string]float64{}
for task, tot := range totals {
if tot[0] > 0 && tot[1] > 0 {
loss[fmt.Sprint(task)] = 100.0 * float64(tot[0]) / float64(tot[1])
}
}
return out, loss, nil
}
func int64Set(ids []int64) map[int64]bool {
out := make(map[int64]bool, len(ids))
for _, id := range ids {
out[id] = true
}
return out
}
func nullIntPtr(v sql.NullInt64) *int64 {
if !v.Valid {
return nil
}
return &v.Int64
}
func parseInt64(s string) (int64, error) {
return strconv.ParseInt(s, 10, 64)
}
+411
View File
@@ -0,0 +1,411 @@
package probe
import (
"errors"
"strconv"
"testing"
"time"
)
func saveTask(t *testing.T, st *Store, task PingTask) int64 {
t.Helper()
id, err := st.SavePingTask(task, nil)
if err != nil {
t.Fatal(err)
}
return id
}
// Create assigns an id from the high-water mark, lists back with its
// assignments, and a deleted id is never reissued (hub next_id).
func TestPingTaskCRUDAndIDReuse(t *testing.T) {
st := newTestStore(t)
id1 := saveTask(t, st, PingTask{Name: "CF", Target: "1.1.1.1:443", Interval: 60, Region: "全球", AgentIDs: []int64{7}})
id2 := saveTask(t, st, PingTask{Name: "G", Target: "8.8.8.8:53", Interval: 30})
if id1 == 0 || id2 == 0 || id1 == id2 {
t.Fatalf("ids %d %d", id1, id2)
}
tasks, err := st.PingTasks()
if err != nil {
t.Fatal(err)
}
if len(tasks) != 2 || tasks[0].ID != id1 || tasks[1].ID != id2 {
t.Fatalf("list %+v", tasks)
}
if len(tasks[0].AgentIDs) != 1 || tasks[0].AgentIDs[0] != 7 || tasks[0].Region != "全球" {
t.Fatalf("task0 %+v", tasks[0])
}
if tasks[1].Sort <= tasks[0].Sort {
t.Fatalf("sort not appended: %d %d", tasks[0].Sort, tasks[1].Sort)
}
if err := st.DeletePingTask(id1); err != nil {
t.Fatal(err)
}
id3 := saveTask(t, st, PingTask{Name: "Q", Target: "9.9.9.9:443", Interval: 60})
if id3 == id1 || id3 == id2 {
t.Fatalf("id reissued: %d", id3)
}
// Updating a task another session deleted reports it (hub).
if _, err := st.SavePingTask(PingTask{ID: id1, Name: "x", Target: "1.1.1.1:443", Interval: 60}, nil); !errors.Is(err, ErrPingTaskGone) {
t.Fatalf("gone update: %v", err)
}
}
// Base-diff saving: an assignment made after the editor's base snapshot
// survives the save; base members removed from the new set go away.
func TestPingTaskSaveBaseDiff(t *testing.T) {
st := newTestStore(t)
id := saveTask(t, st, PingTask{Name: "A", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{1, 2}})
// Another session assigns agent 3 meanwhile.
other := saveTaskPingAssign(t, st, id, []int64{1, 2, 3})
_ = other
base := []int64{1, 2}
if _, err := st.SavePingTask(PingTask{ID: id, Name: "A", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{2}}, &base); err != nil {
t.Fatal(err)
}
tasks, _ := st.PingTasks()
got := map[int64]bool{}
for _, a := range tasks[0].AgentIDs {
got[a] = true
}
if got[1] || !got[2] || !got[3] {
t.Fatalf("base-diff result %v", tasks[0].AgentIDs)
}
// Without base the assignment is replaced wholesale.
if _, err := st.SavePingTask(PingTask{ID: id, Name: "A", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{9}}, nil); err != nil {
t.Fatal(err)
}
tasks, _ = st.PingTasks()
if len(tasks[0].AgentIDs) != 1 || tasks[0].AgentIDs[0] != 9 {
t.Fatalf("wholesale replace %v", tasks[0].AgentIDs)
}
}
func saveTaskPingAssign(t *testing.T, st *Store, taskID int64, agents []int64) error {
t.Helper()
_, err := st.db.Exec(`DELETE FROM probe_ping_node WHERE task_id = ?`, taskID)
if err != nil {
return err
}
for _, a := range agents {
if _, err := st.db.Exec(`INSERT INTO probe_ping_node (task_id, agent_id) VALUES (?, ?)`, taskID, a); err != nil {
return err
}
}
return nil
}
// Crowding: the 65th task on one agent refuses the whole save, and so
// does a 65th auto_join task (a fresh agent would be over its cap the
// moment it appears).
func TestPingTaskCrowding(t *testing.T) {
st := newTestStore(t)
for i := 0; i < MaxProbesPerNode; i++ {
saveTask(t, st, PingTask{Name: "t", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{5}})
}
_, err := st.SavePingTask(PingTask{Name: "one too many", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{5}}, nil)
var crowded *PingCrowdedError
if !errors.As(err, &crowded) || crowded.AgentID != 5 {
t.Fatalf("crowded: %v", err)
}
if tasks, _ := st.PingTasks(); len(tasks) != MaxProbesPerNode {
t.Fatalf("refused save leaked: %d tasks", len(tasks))
}
// The same count on a different agent is fine.
saveTask(t, st, PingTask{Name: "ok", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{6}})
st2 := newTestStore(t)
for i := 0; i < MaxProbesPerNode; i++ {
saveTask(t, st2, PingTask{Name: "t", Target: "1.1.1.1:443", Interval: 60, AutoJoin: true})
}
_, err = st2.SavePingTask(PingTask{Name: "join too many", Target: "1.1.1.1:443", Interval: 60, AutoJoin: true}, nil)
var aj *PingAutoJoinError
if !errors.As(err, &aj) {
t.Fatalf("autoJoin crowded: %v", err)
}
}
// SetNodePingTasks applies only the base difference and refuses when
// THIS agent would overflow; another agent's overflow is not its fault.
func TestSetNodePingTasks(t *testing.T) {
st := newTestStore(t)
a := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60})
b := saveTask(t, st, PingTask{Name: "b", Target: "1.1.1.1:443", Interval: 60})
// Assigned meanwhile by the task-side editor: survives.
saveTaskPingAssign(t, st, b, []int64{3})
if err := st.SetNodePingTasks(3, []int64{a}, []int64{}); err != nil {
t.Fatal(err)
}
if refs, _ := st.PingTasksFor(3); len(refs) != 2 {
t.Fatalf("node tasks %v", refs)
}
// A task deleted since the base makes the edit refuse.
if err := st.SetNodePingTasks(3, []int64{a, 9999}, []int64{a}); err == nil {
t.Fatal("deleted task accepted")
}
}
// Reorder: duplicates and stale lists refuse; a full list renumbers
// sort and PingTasks reflects it.
func TestReorderPingTasks(t *testing.T) {
st := newTestStore(t)
a := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60})
b := saveTask(t, st, PingTask{Name: "b", Target: "1.1.1.1:443", Interval: 60})
if err := st.ReorderPingTasks([]int64{a, a}); err == nil {
t.Fatal("duplicate accepted")
}
if err := st.ReorderPingTasks([]int64{a}); err == nil {
t.Fatal("short list accepted")
}
if err := st.ReorderPingTasks([]int64{b, a}); err != nil {
t.Fatal(err)
}
tasks, _ := st.PingTasks()
if tasks[0].ID != b || tasks[1].ID != a || tasks[0].Sort != 0 || tasks[1].Sort != 1 {
t.Fatalf("after reorder %+v", tasks)
}
}
// PingTasksFor is id-ordered (the agent's cap keeps the first N) and
// PingTaskNames covers only assigned tasks.
func TestPingTasksForAndNames(t *testing.T) {
st := newTestStore(t)
a := saveTask(t, st, PingTask{Name: "first", Target: "1.1.1.1:443", Interval: 45, AgentIDs: []int64{2}})
b := saveTask(t, st, PingTask{Name: "second", Target: "8.8.8.8:53", Interval: 90, AgentIDs: []int64{2}})
saveTask(t, st, PingTask{Name: "elsewhere", Target: "9.9.9.9:443", Interval: 60, AgentIDs: []int64{3}})
refs, err := st.PingTasksFor(2)
if err != nil {
t.Fatal(err)
}
if len(refs) != 2 || refs[0].ID != a || refs[1].ID != b || refs[0].Target != "1.1.1.1:443" || refs[0].Interval != 45 {
t.Fatalf("refs %+v", refs)
}
names, err := st.PingTaskNames(2)
if err != nil {
t.Fatal(err)
}
if len(names) != 2 || names[strconv.FormatInt(a, 10)] != "first" {
t.Fatalf("names %v", names)
}
}
// InsertPings files only under live assignments: a result for an
// unassigned or deleted task is dropped silently; a second sample at
// the same stamp replaces the first (one session, one stamp).
func TestInsertPingsAssignmentGate(t *testing.T) {
st := newTestStore(t)
id := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{4}})
ts := time.Now().Unix()
err := st.InsertPings(4, []PingResult{
{TaskID: id, LatencyMs: 12},
{TaskID: id + 100, LatencyMs: 5}, // never assigned
}, ts)
if err != nil {
t.Fatal(err)
}
// From an agent the task is not assigned to: dropped too.
if err := st.InsertPings(9, []PingResult{{TaskID: id, LatencyMs: 99}}, ts); err != nil {
t.Fatal(err)
}
var count int64
var latency int64
st.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(latency), -999) FROM probe_ping_record`).Scan(&count, &latency)
if count != 1 || latency != 12 {
t.Fatalf("rows %d latency %d", count, latency)
}
if err := st.InsertPings(4, []PingResult{{TaskID: id, LatencyMs: 20}}, ts); err != nil {
t.Fatal(err)
}
st.db.QueryRow(`SELECT COUNT(*), COALESCE(MAX(latency), -999) FROM probe_ping_record`).Scan(&count, &latency)
if count != 1 || latency != 20 {
t.Fatalf("after replace rows %d latency %d", count, latency)
}
}
// PingHistory over minute rows: per-bucket medians, band only when the
// bucket varied, bucket loss rounded up, window loss unrounded and
// only for tasks that lost something.
func TestPingHistoryMinuteRows(t *testing.T) {
st := newTestStore(t)
a := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{1}})
b := saveTask(t, st, PingTask{Name: "b", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{1}})
now := time.Now().Unix()
base := (now/60)*60 - 600 // ten minutes back, bucket aligned
span := Span{Since: base, Step: 60, Hourly: false}
// Task A: one bucket with answers 10, 20, 40 and one miss.
for i, lat := range []int64{10, 20, 40, -1} {
seedPing(t, st, 1, base+int64(i)*7, a, lat)
}
// Task B: one bucket, all misses.
seedPing(t, st, 1, base+5, b, -1)
seedPing(t, st, 1, base+15, b, -1)
rows, loss, err := st.PingHistory(1, span)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("rows %+v", rows)
}
ra, rb := rows[0], rows[1]
if ra.TaskID != a || rb.TaskID != b {
t.Fatalf("order %+v", rows)
}
if ra.Latency == nil || *ra.Latency != 20 {
t.Fatalf("median %v", ra.Latency)
}
if ra.Band == nil || (*ra.Band)[0] != 10 || (*ra.Band)[1] != 40 {
t.Fatalf("band %v", ra.Band)
}
if ra.Loss == nil || *ra.Loss != 25 { // 1 of 4, exact
t.Fatalf("bucket loss %v", ra.Loss)
}
if rb.Latency != nil || rb.Band != nil {
t.Fatalf("all-miss row %+v", rb)
}
if rb.Loss == nil || *rb.Loss != 100 {
t.Fatalf("all-miss loss %v", rb.Loss)
}
if loss[strconv.FormatInt(a, 10)] != 25.0 {
t.Fatalf("window loss a %v", loss)
}
if loss[strconv.FormatInt(b, 10)] != 100.0 {
t.Fatalf("window loss b %v", loss)
}
// An agent without the assignment sees none of it.
rows2, loss2, err := st.PingHistory(2, span)
if err != nil {
t.Fatal(err)
}
if len(rows2) != 0 || len(loss2) != 0 {
t.Fatalf("unassigned view %v %v", rows2, loss2)
}
}
// PingHistory over an hourly span merges folded hours (weighted by
// their answer counts) with the minute rows after the watermark.
func TestPingHistoryHourlyMerge(t *testing.T) {
st := newTestStore(t)
a := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{1}})
now := time.Now().Unix()
hourStart := hourFloor(now) - 3*3600
span := Span{Since: hourStart, Step: 3600, Hourly: true}
// Folded hour: 3 answers (median 30, range 10..50), 1 lost.
if _, err := st.db.Exec(`INSERT INTO probe_ping_hour (agent_id, ts, task_id, answered, lost, latency, lo, hi)
VALUES (1, ?, ?, 3, 1, 30, 10, 50)`, hourStart, a); err != nil {
t.Fatal(err)
}
// Watermark at the hour boundary: minute rows at/after it count.
if _, err := st.db.Exec(`INSERT INTO probe_setting (key, value) VALUES (?, ?)`, rolledKey, strconv.FormatInt(hourStart+3600, 10)); err != nil {
t.Fatal(err)
}
minuteTs := hourStart + 3600 + 120 // inside the NEXT hour bucket
seedPing(t, st, 1, minuteTs, a, 100)
// A stale minute row below the watermark must not double count.
seedPing(t, st, 1, hourStart+60, a, 999)
rows, loss, err := st.PingHistory(1, span)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 {
t.Fatalf("rows %+v", rows)
}
if rows[0].Ts != hourStart || rows[0].Latency == nil || *rows[0].Latency != 30 {
t.Fatalf("hour row %+v", rows[0])
}
if rows[0].Band == nil || (*rows[0].Band)[0] != 10 || (*rows[0].Band)[1] != 50 {
t.Fatalf("hour band %+v", rows[0].Band)
}
if rows[0].Loss == nil || *rows[0].Loss != 25 {
t.Fatalf("hour loss %v", rows[0].Loss)
}
if rows[1].Ts != hourStart+3600 || rows[1].Latency == nil || *rows[1].Latency != 100 {
t.Fatalf("minute row %+v", rows[1])
}
// Window loss counts the hour tally plus the minute row: 1 lost of 5.
if loss[strconv.FormatInt(a, 10)] != 20.0 {
t.Fatalf("window loss %v", loss)
}
}
// Weighted median: with answers weighted by counts, the middle ranks
// decide — two middle values average (hub Tally::median).
func TestPingWeightedMedianAveragesMiddleTwo(t *testing.T) {
st := newTestStore(t)
a := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{1}})
now := time.Now().Unix()
base := (now / 3600) * 3600
span := Span{Since: base, Step: 3600, Hourly: true}
// Two folded hours inside one bucket: medians 10 (×1) and 21 (×1)
// → total 2, ranks 1 and 2 → (10+21)/2 = 15 (integer).
for i, m := range []int64{10, 21} {
if _, err := st.db.Exec(`INSERT INTO probe_ping_hour (agent_id, ts, task_id, answered, lost, latency, lo, hi)
VALUES (1, ?, ?, 1, 0, ?, ?, ?)`, base+int64(i)*60, a, m, m, m); err != nil {
t.Fatal(err)
}
}
if _, err := st.db.Exec(`INSERT INTO probe_setting (key, value) VALUES (?, ?)`, rolledKey, strconv.FormatInt(base+3600, 10)); err != nil {
t.Fatal(err)
}
rows, _, err := st.PingHistory(1, span)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Latency == nil || *rows[0].Latency != 15 {
t.Fatalf("weighted median %+v", rows)
}
}
// Auto-join: an agent's first recorded sample assigns it every
// auto_join task; agents already known are NOT retroactively joined
// when a task is flagged later (hub semantics).
func TestAutoJoinOnFirstRecord(t *testing.T) {
st := newTestStore(t)
auto := saveTask(t, st, PingTask{Name: "auto", Target: "1.1.1.1:443", Interval: 60, AutoJoin: true})
saveTask(t, st, PingTask{Name: "manual", Target: "1.1.1.1:443", Interval: 60})
// Existing agent 1 records before any task existed... (tasks were
// created first here, so use a pre-existing agent row instead:)
st.Record(1, Sample{BootID: "b1"}, time.Now(), 0)
if refs, _ := st.PingTasksFor(1); len(refs) != 1 || refs[0].ID != auto {
t.Fatalf("first record join %v", refs)
}
// Agent 2's first record joins too.
st.Record(2, Sample{BootID: "b2"}, time.Now(), 0)
if refs, _ := st.PingTasksFor(2); len(refs) != 1 {
t.Fatalf("agent2 join %v", refs)
}
// A task flagged auto_join afterwards does not reach agent 1/2.
late := saveTask(t, st, PingTask{Name: "late", Target: "1.1.1.1:443", Interval: 60, AutoJoin: true})
st.Record(1, Sample{BootID: "b1"}, time.Now(), time.Now().UnixMilli())
if refs, _ := st.PingTasksFor(1); len(refs) != 1 {
t.Fatalf("retroactive join happened: %v", refs)
}
// But a brand-new agent 3 picks up both.
st.Record(3, Sample{BootID: "b3"}, time.Now(), 0)
if refs, _ := st.PingTasksFor(3); len(refs) != 2 || refs[1].ID != late {
t.Fatalf("agent3 join %v", refs)
}
}
// DeletePingTask clears records, hours and assignments with the task.
func TestDeletePingTaskClearsAll(t *testing.T) {
st := newTestStore(t)
id := saveTask(t, st, PingTask{Name: "a", Target: "1.1.1.1:443", Interval: 60, AgentIDs: []int64{1}})
seedPing(t, st, 1, time.Now().Unix(), id, 5)
if _, err := st.db.Exec(`INSERT INTO probe_ping_hour (agent_id, ts, task_id, answered, lost, latency, lo, hi)
VALUES (1, 0, ?, 1, 0, 5, 5, 5)`, id); err != nil {
t.Fatal(err)
}
if err := st.DeletePingTask(id); err != nil {
t.Fatal(err)
}
for _, table := range []string{"probe_ping_record", "probe_ping_hour", "probe_ping_node", "probe_ping_task"} {
var n int64
st.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&n)
if n != 0 {
t.Fatalf("%s still has %d rows", table, n)
}
}
}
+238
View File
@@ -0,0 +1,238 @@
package probe
import (
"strconv"
"strings"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
)
// RetentionDays reads the history retention window (days) from the
// panel settings (key retention_days, as in hub). Default 7 — the
// window the user settled on — clamped to [1, 365] (hub's cap).
func RetentionDays() int64 {
var row model.Setting
err := database.GetDB().Where("key = ?", "retention_days").First(&row).Error
if err != nil {
return DetailDays
}
n, perr := strconv.ParseInt(strings.TrimSpace(row.Value), 10, 64)
if perr != nil {
return DetailDays
}
if n < 1 {
return 1
}
if n > 365 {
return 365
}
return n
}
// Record files one heartbeat's probe sample into the default store.
// prevLastSeenMs is the agent row's last-seen stamp from before this
// heartbeat (milliseconds, as stored on agent_nodes). Best-effort: any
// failure is logged and swallowed so a heartbeat never fails over
// history.
func Record(agentID int64, smp Sample, prevLastSeenMs int64) {
st := Default()
if st == nil {
return
}
if err := st.Record(agentID, smp, time.Now(), prevLastSeenMs/1000); err != nil {
logger.Warningf("probe: record agent %d: %v", agentID, err)
}
}
// Maintain runs one housekeeping pass against the default store:
// roll_up first, then prune — prune runs even when roll_up fails, as
// it keeps minute rows until their hour is folded and skipping it
// would leave the database growing (hub housekeeping discipline).
func Maintain() {
st := Default()
if st == nil {
return
}
keep := RetentionDays()
if folded, err := st.RollUp(time.Now().Unix(), keep); err != nil {
logger.Warningf("probe: roll_up failed: %v", err)
} else if folded > 1 {
logger.Infof("probe: folded %d hours of history into the hourly tier", folded)
}
if _, err := st.Prune(keep); err != nil {
logger.Warningf("probe: prune failed: %v", err)
}
}
// BookHeld books every node's held, not-yet-booked reading on the
// default store; called as the master shuts down.
func BookHeld() {
if st := Default(); st != nil {
st.BookHeld()
}
}
// TrafficSnapshot is the B3 read port: every agent's gated ledger.
func TrafficSnapshot() (map[int64]Traffic, error) {
st := Default()
if st == nil {
return map[int64]Traffic{}, nil
}
return st.AllTraffic()
}
// MetaSnapshot is the B3 read port for display metadata: every agent's
// probe_meta row, keyed by agent id. Agents without a row are absent;
// callers substitute DefaultMeta.
func MetaSnapshot() (map[int64]Meta, error) {
st := Default()
if st == nil {
return map[int64]Meta{}, nil
}
return st.AllMeta()
}
// MetaFor reads one agent's metadata row from the default store.
// ok is false when the agent never reported (no row ensured yet).
func MetaFor(agentID int64) (Meta, bool, error) {
st := Default()
if st == nil {
return Meta{}, false, nil
}
return st.Meta(agentID)
}
// SaveMeta persists one agent's metadata row (B3 edit surface).
func SaveMeta(m Meta) error {
st := Default()
if st == nil {
return nil
}
return st.UpsertMeta(m)
}
// MetricHistory is the B3 read port for one agent's history window.
// With no store the window is simply empty.
func MetricHistory(agentID int64, sp Span) ([]MetricRow, error) {
st := Default()
if st == nil {
return []MetricRow{}, nil
}
return st.MetricHistory(agentID, sp)
}
// DeleteAgentData drops a removed agent's probe rows (B3 cleanup point
// called from agent deletion). Best-effort: failures are logged and
// swallowed, as a stale probe row must never block a panel operation.
func DeleteAgentData(agentID int64) {
st := Default()
if st == nil {
return
}
if err := st.DeleteAgentData(agentID); err != nil {
logger.Warningf("probe: delete agent %d data: %v", agentID, err)
}
}
// ---- B4: probe tasks, results and the latency read ----
// PingTasks lists every probe task with its assignments. With no
// store the list is empty.
func PingTasks() ([]PingTask, error) {
st := Default()
if st == nil {
return []PingTask{}, nil
}
return st.PingTasks()
}
// SavePingTask creates or replaces one probe task (see Store).
func SavePingTask(t PingTask, base *[]int64) (int64, error) {
st := Default()
if st == nil {
return 0, nil
}
return st.SavePingTask(t, base)
}
// DeletePingTask removes one probe task and its filed history.
func DeletePingTask(id int64) error {
st := Default()
if st == nil {
return nil
}
return st.DeletePingTask(id)
}
// SetNodePingTasks applies one agent's assignment difference.
func SetNodePingTasks(agentID int64, tasks, base []int64) error {
st := Default()
if st == nil {
return nil
}
return st.SetNodePingTasks(agentID, tasks, base)
}
// ReorderPingTasks rewrites the panel order of the task list.
func ReorderPingTasks(ids []int64) error {
st := Default()
if st == nil {
return nil
}
return st.ReorderPingTasks(ids)
}
// PingTasksFor reads the task list pushed to one agent on its config
// poll. Best-effort like the rest of the ingest side: a store failure
// reads as "no tasks" and is logged, so a broken probe.db never
// breaks config delivery — the agent keeps its current list until a
// poll succeeds.
func PingTasksFor(agentID int64) []PingTaskRef {
st := Default()
if st == nil {
return []PingTaskRef{}
}
refs, err := st.PingTasksFor(agentID)
if err != nil {
logger.Warningf("probe: ping tasks for agent %d: %v", agentID, err)
return []PingTaskRef{}
}
return refs
}
// PingTaskNames reads one agent's probe chart labels.
func PingTaskNames(agentID int64) (map[string]string, error) {
st := Default()
if st == nil {
return map[string]string{}, nil
}
return st.PingTaskNames(agentID)
}
// InsertPings files one agent's probe results at the arrival stamp.
// Best-effort (heartbeat path): failures are logged and swallowed.
func InsertPings(agentID int64, results []PingResult) {
if len(results) == 0 {
return
}
st := Default()
if st == nil {
return
}
if err := st.InsertPings(agentID, results, time.Now().Unix()); err != nil {
logger.Warningf("probe: insert pings agent %d: %v", agentID, err)
}
}
// PingHistory is the B4 read port for one agent's latency window.
// With no store the window is simply empty.
func PingHistory(agentID int64, sp Span) ([]PingRow, map[string]float64, error) {
st := Default()
if st == nil {
return []PingRow{}, map[string]float64{}, nil
}
return st.PingHistory(agentID, sp)
}
+173
View File
@@ -0,0 +1,173 @@
package probe
import (
"database/sql"
"strconv"
)
// Span is one chart window as the read side answers it (hub db::Span):
// the window start in unix seconds, the seconds each returned point
// covers, and whether the window reaches past the minute tier and is
// read from the hourly one.
type Span struct {
Since int64
Step int64
Hourly bool
}
// ComputeSpan reproduces hub api::span(): the point budget defaults to
// 1440 (a day of minutes) and the caller's points may only lower it,
// clamped to [60, 1440]; windows longer than DetailDays read hourly
// rows, so the step is a whole number of minutes — or of hours past the
// detail window, so no hourly row straddles two points; the window
// start is aligned down to a step boundary.
func ComputeSpan(hours int64, points *int64, now int64) Span {
const samples = 1440
budget := int64(samples)
if points != nil {
budget = *points
}
if budget < 60 {
budget = 60
}
if budget > samples {
budget = samples
}
hourly := hours > DetailDays*24
unit := int64(60)
if hourly {
unit = 3600
}
// Rounded up, or the budget would not be one.
q := (hours*3600/unit + budget - 1) / budget
if q < 1 {
q = 1
}
step := unit * q
return Span{
Since: euclidDiv(now-hours*3600, step) * step,
Step: step,
Hourly: hourly,
}
}
func euclidDiv(a, b int64) int64 {
q := a / b
if a%b != 0 && (a < 0) != (b < 0) {
q--
}
return q
}
// MetricRow is one thinned history point, in the hub's key order and
// with the hub's value semantics: means for the gauge columns (byte
// columns truncated, count columns rounded — the asymmetry is the
// hub's, kept), maxima for the peak columns, and `minutes` counting
// the minute rows folded into the point.
type MetricRow struct {
Ts int64 `json:"ts"`
Cpu float64 `json:"cpu"`
MemUsed int64 `json:"mem_used"`
DiskUsed int64 `json:"disk_used"`
NetRx int64 `json:"net_rx"`
NetTx int64 `json:"net_tx"`
NetRxMax int64 `json:"net_rx_max"`
NetTxMax int64 `json:"net_tx_max"`
CpuMax float64 `json:"cpu_max"`
Minutes int64 `json:"minutes"`
SwapUsed int64 `json:"swap_used"`
Tcp int64 `json:"tcp"`
Udp int64 `json:"udp"`
Procs int64 `json:"procs"`
}
// RolledWatermark reads the roll-up watermark (unix seconds of the
// first hour not yet folded). ok is false when no pass has run yet.
func (s *Store) RolledWatermark() (int64, bool, error) {
var raw string
err := s.db.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw)
if err == sql.ErrNoRows {
return 0, false, nil
}
if err != nil {
return 0, false, err
}
n, perr := strconv.ParseInt(raw, 10, 64)
if perr != nil {
return 0, false, nil
}
return n, true, nil
}
const metricColumns = `(MIN(ts)/?3)*?3, AVG(cpu), CAST(AVG(mem_used) AS INTEGER),
CAST(AVG(disk_used) AS INTEGER),
CAST(AVG(net_rx) AS INTEGER), CAST(AVG(net_tx) AS INTEGER),
MAX(MAX(net_rx, net_rx_max)), MAX(MAX(net_tx, net_tx_max)),
MAX(MAX(cpu, cpu_max)), COUNT(*), CAST(AVG(swap_used) AS INTEGER),
CAST(ROUND(AVG(tcp)) AS INTEGER), CAST(ROUND(AVG(udp)) AS INTEGER),
CAST(ROUND(AVG(procs)) AS INTEGER)`
// MetricHistory answers one agent's history window (hub Db::metrics).
// Minute windows group minute rows directly. Hourly windows merge the
// folded hourly tier (up to the watermark) with the not-yet-folded
// minute rows and the newest week of minute rows, weighted by each
// row's minute count. Both statements run in one read snapshot so a
// roll-up committing in between cannot double-count an hour.
func (s *Store) MetricHistory(agentID int64, sp Span) ([]MetricRow, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
var rows *sql.Rows
if !sp.Hourly {
rows, err = tx.Query(
`SELECT `+metricColumns+`
FROM probe_metric WHERE agent_id=?1 AND ts>=?2 GROUP BY ts/?3 ORDER BY ts/?3`,
agentID, sp.Since, sp.Step)
} else {
rolled := int64(-1) << 62 // hub: unwrap_or(i64::MIN)
var raw sql.NullString
if qerr := tx.QueryRow(`SELECT value FROM probe_setting WHERE key = ?`, rolledKey).Scan(&raw); qerr != nil && qerr != sql.ErrNoRows {
return nil, qerr
} else if raw.Valid {
if n, perr := strconv.ParseInt(raw.String, 10, 64); perr == nil {
rolled = n
}
}
rows, err = tx.Query(
`SELECT (MIN(ts)/?3)*?3, SUM(cpu*w)/SUM(w), SUM(mem_used*w)/SUM(w), SUM(disk_used*w)/SUM(w),
SUM(net_rx*w)/SUM(w), SUM(net_tx*w)/SUM(w), MAX(rx_max), MAX(tx_max), MAX(cpu_top), SUM(w),
SUM(swap_used*w)/SUM(w), CAST(ROUND(SUM(tcp*w)*1.0/SUM(w)) AS INTEGER),
CAST(ROUND(SUM(udp*w)*1.0/SUM(w)) AS INTEGER),
CAST(ROUND(SUM(procs*w)*1.0/SUM(w)) AS INTEGER)
FROM (SELECT ts, minutes AS w, cpu, mem_used, disk_used, net_rx, net_tx,
net_rx_max AS rx_max, net_tx_max AS tx_max, cpu_max AS cpu_top,
swap_used, tcp, udp, procs
FROM probe_metric_hour WHERE agent_id=?1 AND ts>=?2 AND ts<?4
UNION ALL
SELECT ts, 1, cpu, mem_used, disk_used, net_rx, net_tx,
MAX(net_rx, net_rx_max), MAX(net_tx, net_tx_max), MAX(cpu, cpu_max),
swap_used, tcp, udp, procs
FROM probe_metric WHERE agent_id=?1
AND ts>=MAX(?2, ?4, (SELECT MAX(ts) FROM probe_metric WHERE agent_id=?1) - ?5))
GROUP BY ts/?3 ORDER BY ts/?3`,
agentID, sp.Since, sp.Step, rolled, DetailDays*86400)
}
if err != nil {
return nil, err
}
defer rows.Close()
out := []MetricRow{}
for rows.Next() {
var r MetricRow
if err := rows.Scan(&r.Ts, &r.Cpu, &r.MemUsed, &r.DiskUsed, &r.NetRx, &r.NetTx,
&r.NetRxMax, &r.NetTxMax, &r.CpuMax, &r.Minutes, &r.SwapUsed, &r.Tcp, &r.Udp, &r.Procs); err != nil {
return nil, err
}
out = append(out, r)
}
return out, rows.Err()
}
+227
View File
@@ -0,0 +1,227 @@
package probe
import (
"testing"
"time"
)
// Span math (hub api::span): budget defaults to 1440 points, the
// caller's points may only lower it and is clamped to [60, 1440];
// windows past the 7-day detail window switch to whole-hour steps;
// since aligns down to the step.
func TestComputeSpan(t *testing.T) {
now := int64(1_800_000_000) // arbitrary fixed instant
i64 := func(v int64) *int64 { return &v }
cases := []struct {
name string
hours int64
points *int64
wantStep int64
wantHourly bool
}{
{"default 6h", 6, nil, 60, false},
{"points lower the budget", 6, i64(60), 360, false},
{"points above cap ignored", 6, i64(99999), 60, false},
{"points below floor clamped", 1, i64(1), 60, false},
{"detail edge stays minute", DetailDays * 24, nil, 420, false},
{"past detail reads hourly", DetailDays*24 + 1, nil, 3600, true},
{"30 days hourly", 720, nil, 3600, true},
{"hourly step grows past budget", 24 * 365, nil, 3600 * 7, true},
}
for _, tc := range cases {
sp := ComputeSpan(tc.hours, tc.points, now)
if sp.Step != tc.wantStep || sp.Hourly != tc.wantHourly {
t.Errorf("%s: got step=%d hourly=%v, want step=%d hourly=%v",
tc.name, sp.Step, sp.Hourly, tc.wantStep, tc.wantHourly)
}
if sp.Since%sp.Step != 0 {
t.Errorf("%s: since %d not aligned to step %d", tc.name, sp.Since, sp.Step)
}
if want := now - tc.hours*3600; sp.Since > want || sp.Since <= want-sp.Step {
t.Errorf("%s: since %d not the aligned window start of %d", tc.name, sp.Since, want)
}
}
}
func insertMinute(t *testing.T, st *Store, agent, ts int64, cpu float64, memUsed, netRx, netRxMax int64, cpuMax float64, tcp int64) {
t.Helper()
_, err := st.db.Exec(`INSERT INTO probe_metric
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max)
VALUES (?, ?, ?, ?, 0, 0, ?, 0, ?, 0, ?, 0, 0, ?)`,
agent, ts, cpu, memUsed, netRx, netRxMax, tcp, cpuMax)
if err != nil {
t.Fatalf("insert minute: %v", err)
}
}
// Minute windows fold rows per bucket with the hub's asymmetric
// aggregates: byte columns truncated means, count columns rounded,
// peaks take the max of value and stored peak.
func TestMetricHistoryMinuteFold(t *testing.T) {
st := newTestStore(t)
now := time.Now().Unix()
base := now - now%60 - 120 // a minute boundary two minutes back
insertMinute(t, st, 7, base, 10, 100, 1000, 1500, 12, 3)
insertMinute(t, st, 7, base+60, 20, 201, 2000, 500, 25, 5)
// 1:1 buckets (step 60).
sp := ComputeSpan(1, nil, now)
rows, err := st.MetricHistory(7, sp)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(rows) != 2 {
t.Fatalf("want 2 points, got %d (%+v)", len(rows), rows)
}
if rows[0].Ts != base || rows[0].Cpu != 10 || rows[0].MemUsed != 100 ||
rows[0].NetRxMax != 1500 || rows[0].CpuMax != 12 || rows[0].Minutes != 1 || rows[0].Tcp != 3 {
t.Fatalf("point 0 wrong: %+v", rows[0])
}
if rows[1].Ts != base+60 || rows[1].NetRxMax != 2000 { // max(2000, 500)
t.Fatalf("point 1 wrong: %+v", rows[1])
}
// Both minutes in one 120 s bucket: cpu mean 15, mem truncated
// mean (100+201)/2 = 150, peaks survive, minutes counts rows.
sp2 := Span{Since: base, Step: 120, Hourly: false}
rows, err = st.MetricHistory(7, sp2)
if err != nil {
t.Fatalf("history 120s: %v", err)
}
if len(rows) != 1 {
t.Fatalf("want 1 folded point, got %d", len(rows))
}
r := rows[0]
if r.Ts != base || r.Cpu != 15 || r.MemUsed != 150 || r.Minutes != 2 ||
r.NetRxMax != 2000 || r.CpuMax != 25 || r.Tcp != 4 { // ROUND(avg(3,5)) = 4
t.Fatalf("folded point wrong: %+v", r)
}
// Another agent sees nothing.
rows, err = st.MetricHistory(8, sp)
if err != nil || len(rows) != 0 {
t.Fatalf("agent isolation: rows=%v err=%v", rows, err)
}
}
// Hourly windows merge the folded hour tier (weighted by each row's
// minute count) with the minute rows not yet folded.
func TestMetricHistoryHourlyFold(t *testing.T) {
st := newTestStore(t)
now := time.Now().Unix()
hour := now - now%3600
h1 := hour - 100*3600 // folded tier
h2 := h1 + 3600
ins := func(ts int64, cpu float64, mem int64, minutes int64) {
_, err := st.db.Exec(`INSERT INTO probe_metric_hour
(agent_id, ts, cpu, mem_used, swap_used, disk_used, net_rx, net_tx,
net_rx_max, net_tx_max, tcp, udp, procs, cpu_max, minutes)
VALUES (?, ?, ?, ?, 0, 0, 0, 0, 0, 0, 0, 0, 0, ?, ?)`,
9, ts, cpu, mem, cpu, minutes)
if err != nil {
t.Fatalf("insert hour: %v", err)
}
}
ins(h1, 30, 300, 60)
ins(h2, 60, 600, 30)
if _, err := st.db.Exec(`INSERT INTO probe_setting (key, value) VALUES (?, ?)`,
rolledKey, h2+3600); err != nil {
t.Fatalf("set watermark: %v", err)
}
// One unfolded minute row in the current hour.
insertMinute(t, st, 9, hour+60, 90, 900, 5000, 5000, 90, 9)
sp := ComputeSpan(200, nil, now)
if !sp.Hourly || sp.Step != 3600 {
t.Fatalf("span: %+v", sp)
}
rows, err := st.MetricHistory(9, sp)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(rows) != 3 {
t.Fatalf("want 3 points (2 folded + 1 live), got %d (%+v)", len(rows), rows)
}
if rows[0].Ts != h1 || rows[0].Cpu != 30 || rows[0].Minutes != 60 || rows[0].MemUsed != 300 {
t.Fatalf("folded point 0 wrong: %+v", rows[0])
}
if rows[1].Ts != h2 || rows[1].Cpu != 60 || rows[1].Minutes != 30 {
t.Fatalf("folded point 1 wrong: %+v", rows[1])
}
if rows[2].Ts != hour || rows[2].Cpu != 90 || rows[2].Minutes != 1 || rows[2].NetRx != 5000 {
t.Fatalf("live point wrong: %+v", rows[2])
}
}
// Meta lifecycle: defaults for unknown agents, full round-trip,
// replace-on-upsert, and the delete cascade emptying every tier.
func TestMetaCRUDAndDelete(t *testing.T) {
st := newTestStore(t)
if _, ok, err := st.Meta(42); err != nil || ok {
t.Fatalf("unknown agent: ok=%v err=%v", ok, err)
}
d := DefaultMeta(42)
if d.Currency != "USD" || d.BillingCycle != "monthly" || !d.Public ||
d.TrafficMode != "sum" || d.TrafficResetDay != 1 || d.Notify {
t.Fatalf("defaults drifted from the DDL: %+v", d)
}
exp := "2030-01-02"
want := Meta{
AgentID: 42, Price: 12.5, Currency: "USD", BillingCycle: "yearly",
ExpiresAt: &exp, TrafficLimit: 1_000_000_000, TrafficMode: "max",
TrafficResetDay: 15, Public: false, Sort: 3, GroupName: "g1",
PublicRemark: "hello", Notify: true, CountryPin: "JP",
IPv4Pin: "203.0.113.7", IPv6Pin: "2001:db8::7",
}
if err := st.UpsertMeta(want); err != nil {
t.Fatalf("upsert: %v", err)
}
got, ok, err := st.Meta(42)
if err != nil || !ok {
t.Fatalf("read back: ok=%v err=%v", ok, err)
}
gotExp, wantExp := "", ""
if got.ExpiresAt != nil {
gotExp = *got.ExpiresAt
}
if want.ExpiresAt != nil {
wantExp = *want.ExpiresAt
}
got.ExpiresAt, want.ExpiresAt = nil, nil
if got != want || gotExp != wantExp {
t.Fatalf("round trip mismatch:\n got %+v exp=%q\nwant %+v exp=%q", got, gotExp, want, wantExp)
}
want.ExpiresAt = &exp
want.Price = 99
if err := st.UpsertMeta(want); err != nil {
t.Fatalf("replace: %v", err)
}
all, err := st.AllMeta()
if err != nil || len(all) != 1 || all[42].Price != 99 {
t.Fatalf("all meta: %v %+v", err, all)
}
insertMinute(t, st, 42, 1000, 1, 1, 1, 1, 1, 1)
if _, err := st.db.Exec(`INSERT INTO probe_traffic (agent_id) VALUES (42)`); err != nil {
t.Fatalf("seed traffic: %v", err)
}
if err := st.DeleteAgentData(42); err != nil {
t.Fatalf("delete: %v", err)
}
if _, ok, _ := st.Meta(42); ok {
t.Fatal("meta survived delete")
}
for _, table := range []string{"probe_metric", "probe_traffic"} {
var n int
if err := st.db.QueryRow(`SELECT COUNT(*) FROM ` + table + ` WHERE agent_id = 42`).Scan(&n); err != nil || n != 0 {
t.Fatalf("%s survived delete: n=%d err=%v", table, n, err)
}
}
}
+254
View File
@@ -0,0 +1,254 @@
// Package probe is the master-side history store of item 7 (probe merge):
// it persists the per-minute metric rows and the whole-machine traffic
// ledger reported by agents (B1) into a dedicated SQLite file, probe.db,
// beside the panel database. The model, the booking rules and the
// roll-up/prune discipline are a port of monitor-probe's hub (src/db.rs,
// src/agent_ws.rs); see ITEM7-SCOPE.md §3.2/§3.3.
//
// probe.db is deliberately a separate file from x-ui.db: history writes,
// roll-ups and prunes then never contend for the panel database's write
// lock (ITEM7-SCOPE §7 risk 4). Everything here is best-effort by design:
// a broken or missing probe.db must never fail a heartbeat or a panel
// request, so the facade entry points log and swallow errors.
package probe
import (
"database/sql"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
_ "github.com/mattn/go-sqlite3"
)
// DetailDays is how long minute rows are kept before they may be pruned;
// it only ever bounds the minute tier — the hourly tier and the traffic
// ledger live by the retention window itself. Mirrors hub DETAIL_DAYS.
const DetailDays = 7
// lateSeconds is the grace a just-closed hour gets before roll_up folds
// it, so rows landing slightly late still make it into their hour.
// Mirrors hub LATE (3600 + 60).
const lateSeconds = 3600 + 60
// rolledKey is the probe_setting key holding the roll-up watermark: the
// start (unix seconds) of the next hour not yet folded into the hourly
// tier. Mirrors hub ROLLED ("history_rolled").
const rolledKey = "history_rolled"
const schema = `
CREATE TABLE IF NOT EXISTS probe_setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Per-node probe metadata (ITEM7-SCOPE §5.2). B2 creates the table and
-- reads traffic_reset_day; the editing surface lands in B3.
CREATE TABLE IF NOT EXISTS probe_meta (
agent_id INTEGER PRIMARY KEY,
price REAL NOT NULL DEFAULT 0,
currency TEXT NOT NULL DEFAULT 'USD',
billing_cycle TEXT NOT NULL DEFAULT 'monthly',
expires_at TEXT,
traffic_limit INTEGER NOT NULL DEFAULT 0,
traffic_mode TEXT NOT NULL DEFAULT 'sum',
traffic_reset_day INTEGER NOT NULL DEFAULT 1,
public INTEGER NOT NULL DEFAULT 1,
sort INTEGER NOT NULL DEFAULT 0,
group_name TEXT NOT NULL DEFAULT '',
public_remark TEXT NOT NULL DEFAULT '',
notify INTEGER NOT NULL DEFAULT 0,
country_pin TEXT NOT NULL DEFAULT '',
ipv4_pin TEXT NOT NULL DEFAULT '',
ipv6_pin TEXT NOT NULL DEFAULT ''
);
-- Monotonic byte counters that survive agent reboots and master
-- restarts; the whole-machine ledger (hub "traffic"). One row per agent.
CREATE TABLE IF NOT EXISTS probe_traffic (
agent_id INTEGER PRIMARY KEY,
boot_id TEXT NOT NULL DEFAULT '',
last_rx INTEGER NOT NULL DEFAULT 0,
last_tx INTEGER NOT NULL DEFAULT 0,
total_rx INTEGER NOT NULL DEFAULT 0,
total_tx INTEGER NOT NULL DEFAULT 0,
month_rx INTEGER NOT NULL DEFAULT 0,
month_tx INTEGER NOT NULL DEFAULT 0,
month_start TEXT NOT NULL DEFAULT '',
day_rx INTEGER NOT NULL DEFAULT 0,
day_tx INTEGER NOT NULL DEFAULT 0,
day_start TEXT NOT NULL DEFAULT ''
);
-- One row per agent per minute (hub "metric"). net_rx/net_tx are bytes
-- per second derived from the kernel counters, the *_max columns are the
-- minute's peaks of the agent-reported figures.
CREATE TABLE IF NOT EXISTS probe_metric (
agent_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
cpu REAL NOT NULL,
mem_used INTEGER NOT NULL,
swap_used INTEGER NOT NULL,
disk_used INTEGER NOT NULL,
net_rx INTEGER NOT NULL,
net_tx INTEGER NOT NULL,
tcp INTEGER NOT NULL,
udp INTEGER NOT NULL,
procs INTEGER NOT NULL,
net_rx_max INTEGER NOT NULL DEFAULT 0,
net_tx_max INTEGER NOT NULL DEFAULT 0,
cpu_max REAL NOT NULL DEFAULT 0,
PRIMARY KEY (agent_id, ts)
) WITHOUT ROWID;
-- The hourly tier (hub "metric_hour"): every metric column plus the
-- count of minute rows folded in, which weights later merges.
CREATE TABLE IF NOT EXISTS probe_metric_hour (
agent_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
minutes INTEGER NOT NULL,
cpu REAL NOT NULL,
cpu_max REAL NOT NULL,
mem_used INTEGER NOT NULL,
swap_used INTEGER NOT NULL,
disk_used INTEGER NOT NULL,
net_rx INTEGER NOT NULL,
net_tx INTEGER NOT NULL,
net_rx_max INTEGER NOT NULL,
net_tx_max INTEGER NOT NULL,
tcp INTEGER NOT NULL,
udp INTEGER NOT NULL,
procs INTEGER NOT NULL,
PRIMARY KEY (agent_id, ts)
) WITHOUT ROWID;
-- Probe results (hub "ping_record" / "ping_hour"). B2 creates the
-- tables so roll_up/prune cover them uniformly; B4 writes the rows.
-- latency < 0 is a lost probe; the hourly latency is the median of the
-- hour's answers and NULL when none arrived.
CREATE TABLE IF NOT EXISTS probe_ping_record (
agent_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
task_id INTEGER NOT NULL,
latency INTEGER NOT NULL,
PRIMARY KEY (agent_id, ts, task_id)
) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS probe_ping_hour (
agent_id INTEGER NOT NULL,
ts INTEGER NOT NULL,
task_id INTEGER NOT NULL,
answered INTEGER NOT NULL,
lost INTEGER NOT NULL,
latency INTEGER,
lo INTEGER,
hi INTEGER,
PRIMARY KEY (agent_id, ts, task_id)
) WITHOUT ROWID;
-- Probe tasks and their per-agent assignment (hub "ping_task" /
-- "ping_node"). B4 adds both. A task is one operator-maintained
-- target — there is no preset list; region is the operator's own
-- grouping label (empty = ungrouped). Ids come from a high-water
-- mark in probe_setting so a deleted task's id is never reissued
-- (hub next_id discipline): in-flight results and an editor opened
-- before the delete must not land on a different task.
CREATE TABLE IF NOT EXISTS probe_ping_task (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
target TEXT NOT NULL,
interval INTEGER NOT NULL DEFAULT 60,
auto_join INTEGER NOT NULL DEFAULT 0,
sort INTEGER NOT NULL DEFAULT 0,
region TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS probe_ping_node (
task_id INTEGER NOT NULL,
agent_id INTEGER NOT NULL,
PRIMARY KEY (task_id, agent_id)
);
`
// Store is one open probe.db. The zero value is not usable; call Open.
// A Store is safe for concurrent use: the single connection serializes
// writes, and the ingest state is mutex-guarded.
type Store struct {
db *sql.DB
mu sync.Mutex // guards nodes
nodes map[int64]*nodeState
}
// Open creates (if needed) and initializes the probe database at path.
func Open(path string) (*Store, error) {
if dir := filepath.Dir(path); dir != "" {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
}
dsn := path + "?_journal_mode=WAL&_busy_timeout=10000&_synchronous=NORMAL&_txlock=immediate"
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
// One connection: writes serialize on it (hub's single write
// connection), and WAL still lets the read side (B3) proceed.
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("probe schema: %w", err)
}
return &Store{db: db, nodes: make(map[int64]*nodeState)}, nil
}
// Close releases the database handle.
func (s *Store) Close() error {
if s == nil || s.db == nil {
return nil
}
return s.db.Close()
}
// DB exposes the raw handle for the read side (B3) and tests.
func (s *Store) DB() *sql.DB { return s.db }
// --- default store (lazy, failure-tolerant) ---
var def struct {
mu sync.Mutex
store *Store
retryAt time.Time
}
// DefaultPath is where probe.db lives: beside the panel database.
func DefaultPath() string {
return filepath.Join(config.GetDBFolderPath(), "probe.db")
}
// Default returns the process-wide store, opening it on first use. A
// failed open is retried at most once a minute and reported as nil, so
// callers degrade to "no history" instead of failing.
func Default() *Store {
def.mu.Lock()
defer def.mu.Unlock()
if def.store != nil {
return def.store
}
if time.Now().Before(def.retryAt) {
return nil
}
st, err := Open(DefaultPath())
if err != nil {
logger.Warningf("probe: open %s failed (history disabled until retry): %v", DefaultPath(), err)
def.retryAt = time.Now().Add(time.Minute)
return nil
}
def.store = st
return st
}
+359
View File
@@ -0,0 +1,359 @@
package probe
import (
"database/sql"
"math"
"strings"
"time"
)
// Traffic is one agent's whole-machine ledger row (hub Traffic): the
// running totals booked from the kernel counters of its counted
// interfaces. This is the machine-wide account (close to what providers
// bill), separate from the panel's per-inbound/per-client xray traffic.
type Traffic struct {
TotalRx int64 `json:"totalRx"`
TotalTx int64 `json:"totalTx"`
MonthRx int64 `json:"monthRx"`
MonthTx int64 `json:"monthTx"`
MonthStart string `json:"monthStart"`
DayRx int64 `json:"dayRx"`
DayTx int64 `json:"dayTx"`
DayStart string `json:"-"` // read-side gating only, as in hub
}
// MonthUsed is this period's usage as the node's plan meters it
// (hub month_used): "up" counts tx only, "down" rx only, "max" the
// larger direction, anything else (incl. "sum") both directions.
func (t Traffic) MonthUsed(mode string) int64 {
switch mode {
case "up":
return t.MonthTx
case "down":
return t.MonthRx
case "max":
if t.MonthRx > t.MonthTx {
return t.MonthRx
}
return t.MonthTx
default:
return satAdd(t.MonthRx, t.MonthTx)
}
}
func satAdd(a, b int64) int64 {
if b > 0 && a > math.MaxInt64-b {
return math.MaxInt64
}
if b < 0 && a < math.MinInt64-b {
return math.MinInt64
}
return a + b
}
// PeriodStart is the start of the billing period containing the given
// date for a reset day of month (hub period_start). A reset day past
// the end of a short month lands on that month's last day.
func PeriodStart(today time.Time, resetDay int64) time.Time {
if resetDay < 1 {
resetDay = 1
}
if resetDay > 31 {
resetDay = 31
}
clamped := func(y int, m time.Month) time.Time {
last := time.Date(y, m+1, 1, 0, 0, 0, 0, time.Local).AddDate(0, 0, -1).Day()
d := int(resetDay)
if d > last {
d = last
}
return time.Date(y, m, d, 0, 0, 0, 0, time.Local)
}
today = time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.Local)
this := clamped(today.Year(), today.Month())
if !today.Before(this) {
return this
}
if today.Month() == time.January {
return clamped(today.Year()-1, time.December)
}
return clamped(today.Year(), today.Month()-1)
}
const dateLayout = "2006-01-02"
func dateOnly(t time.Time) time.Time {
return time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.Local)
}
// ensureRows lazily creates the agent's meta + traffic rows so booking
// and reading never special-case a first report. A meta row created
// here is also the moment the agent joins the probe system, so the
// auto-join probe tasks are assigned then — and only then, matching
// the hub, where auto_join applies to nodes created after the task
// was flagged, never retroactively to existing ones (B4).
func (s *Store) ensureRows(agentID int64) error {
res, err := s.db.Exec(`INSERT OR IGNORE INTO probe_meta (agent_id) VALUES (?)`, agentID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err == nil && n > 0 {
if _, err := s.db.Exec(`INSERT OR IGNORE INTO probe_ping_node (task_id, agent_id)
SELECT id, ? FROM probe_ping_task WHERE auto_join`, agentID); err != nil {
return err
}
}
_, err = s.db.Exec(`INSERT OR IGNORE INTO probe_traffic (agent_id) VALUES (?)`, agentID)
return err
}
// ResetDay reads the agent's billing reset day from probe_meta
// (default 1 when unset, hub's node default).
func (s *Store) ResetDay(agentID int64) (int64, error) {
var day int64
err := s.db.QueryRow(`SELECT traffic_reset_day FROM probe_meta WHERE agent_id = ?`, agentID).Scan(&day)
if err == sql.ErrNoRows {
return 1, nil
}
if err != nil {
return 1, err
}
if day < 1 {
return 1, nil
}
if day > 31 {
return 31, nil
}
return day, nil
}
// accumulate books one reading of an agent's raw kernel counters into
// its running totals — a faithful port of hub db.rs accumulate().
//
// Only bytes the master observed a counter climb through are booked.
// Without a baseline under this exact boot there is nothing to subtract
// from: a first reading, a counter that shrank under the same boot, and
// a changed boot_id all book a zero delta and merely re-align the
// baseline (booking the bare reading would charge the machine's whole
// history again). The one exception is a machine that booted after the
// master last heard from it (booted > prevLastSeenSec): its counters
// started from zero while unobserved, so the reading is exactly the
// bytes since boot and is booked whole — a reboot during a master
// outage would otherwise lose the whole outage.
//
// at dates the reading for the day and billing period (hub local
// calendar); month/day boundaries never move a stored stamp backwards,
// so a panel correction stamped with the current period is not undone
// by a held-back reading.
func (s *Store) accumulate(agentID int64, bootID string, rx, tx int64, at time.Time, booted *int64, prevLastSeenSec int64) (Traffic, error) {
if err := s.ensureRows(agentID); err != nil {
return Traffic{}, err
}
resetDay, err := s.ResetDay(agentID)
if err != nil {
return Traffic{}, err
}
var (
prevBoot, monthStart, dayStart string
lastRx, lastTx int64
totalRx, totalTx, monthRx, monthTx, dayRx, dayTx int64
)
err = s.db.QueryRow(`
SELECT boot_id, last_rx, last_tx, total_rx, total_tx, month_rx, month_tx,
month_start, day_rx, day_tx, day_start
FROM probe_traffic WHERE agent_id = ?`, agentID).
Scan(&prevBoot, &lastRx, &lastTx, &totalRx, &totalTx, &monthRx, &monthTx,
&monthStart, &dayRx, &dayTx, &dayStart)
if err != nil {
return Traffic{}, err
}
kernelPart := func(b string) string {
if i := strings.IndexByte(b, '/'); i >= 0 {
return b[:i]
}
return b
}
unseenBoot := prevBoot != "" &&
kernelPart(prevBoot) != kernelPart(bootID) &&
booted != nil && prevLastSeenSec > 0 && *booted > prevLastSeenSec
var dRx, dTx int64
switch {
case unseenBoot:
dRx, dTx = rx, tx
case prevBoot == "" || prevBoot != bootID:
dRx, dTx = 0, 0
default:
if rx > lastRx {
dRx = rx - lastRx
}
if tx > lastTx {
dTx = tx - lastTx
}
}
// Saturating adds: a total near MaxInt64 must not wrap negative.
totalRx = satAdd(totalRx, dRx)
totalTx = satAdd(totalTx, dTx)
monthRx = satAdd(monthRx, dRx)
monthTx = satAdd(monthTx, dTx)
dayRx = satAdd(dayRx, dRx)
dayTx = satAdd(dayTx, dTx)
date := dateOnly(at)
today := dateOnly(time.Now())
dated := func(stored string) time.Time {
if stamp, err := time.ParseInLocation(dateLayout, stored, time.Local); err == nil && !stamp.After(today) {
if stamp.After(date) {
return stamp
}
return date
}
return date
}
period := PeriodStart(dated(monthStart), resetDay)
if monthStart != period.Format(dateLayout) {
// A new billing period restarts the month counter, not the total.
monthRx, monthTx = dRx, dTx
}
day := dated(dayStart)
if dayStart != day.Format(dateLayout) {
dayRx, dayTx = dRx, dTx
}
_, err = s.db.Exec(`
UPDATE probe_traffic SET boot_id = ?, last_rx = ?, last_tx = ?,
total_rx = ?, total_tx = ?, month_rx = ?, month_tx = ?, month_start = ?,
day_rx = ?, day_tx = ?, day_start = ?
WHERE agent_id = ?`,
bootID, rx, tx, totalRx, totalTx, monthRx, monthTx, period.Format(dateLayout),
dayRx, dayTx, day.Format(dateLayout), agentID)
if err != nil {
return Traffic{}, err
}
return Traffic{
TotalRx: totalRx, TotalTx: totalTx,
MonthRx: monthRx, MonthTx: monthTx, MonthStart: period.Format(dateLayout),
DayRx: dayRx, DayTx: dayTx, DayStart: day.Format(dateLayout),
}, nil
}
// SetTraffic corrects a ledger row from the panel side (hub
// set_traffic): month figures are re-stamped with the current period so
// the correction is not read back as belonging to an old period and
// discarded by the next booking. Nil fields keep their stored values.
// False when the agent has no meta row yet.
func (s *Store) SetTraffic(agentID int64, totalRx, totalTx, monthRx, monthTx *int64) (bool, error) {
resetDay, err := s.ResetDay(agentID)
if err != nil {
return false, err
}
var exists int
if err := s.db.QueryRow(`SELECT COUNT(*) FROM probe_meta WHERE agent_id = ?`, agentID).Scan(&exists); err != nil {
return false, err
}
if exists == 0 {
return false, nil
}
if err := s.ensureRows(agentID); err != nil {
return false, err
}
period := PeriodStart(dateOnly(time.Now()), resetDay).Format(dateLayout)
// COALESCE keeps stored values for nil fields; month figures only
// move when the stored stamp already is the current period, and the
// stamp always advances to the current period (hub semantics).
_, err = s.db.Exec(`
UPDATE probe_traffic SET
total_rx = COALESCE(?, total_rx), total_tx = COALESCE(?, total_tx),
month_rx = COALESCE(?, CASE WHEN month_start = ? THEN month_rx ELSE 0 END),
month_tx = COALESCE(?, CASE WHEN month_start = ? THEN month_tx ELSE 0 END),
month_start = ?
WHERE agent_id = ?`,
ptrOrNil(totalRx), ptrOrNil(totalTx), ptrOrNil(monthRx), period,
ptrOrNil(monthTx), period, period, agentID)
if err != nil {
return false, err
}
return true, nil
}
func ptrOrNil(v *int64) any {
if v == nil {
return nil
}
return *v
}
// gate applies the read-side period gating of hub all_traffic: the
// period counters restart lazily in accumulate on the node's next
// report, so a node offline since before a boundary still holds the
// previous period's bytes on disk; readers see them as zero unless the
// stored stamp is the current period.
func gate(t Traffic, dayStart string, resetDay int64, now time.Time) Traffic {
period := PeriodStart(dateOnly(now), resetDay).Format(dateLayout)
if t.MonthStart != period {
t.MonthRx, t.MonthTx = 0, 0
}
if dayStart != dateOnly(now).Format(dateLayout) {
t.DayRx, t.DayTx = 0, 0
}
return t
}
// TrafficFor returns one agent's ledger with read-side period gating —
// the read port B3's /api/nodes injects total/month figures from.
func (s *Store) TrafficFor(agentID int64) (Traffic, bool, error) {
var t Traffic
var dayStart string
err := s.db.QueryRow(`
SELECT total_rx, total_tx, month_rx, month_tx, month_start, day_rx, day_tx, day_start
FROM probe_traffic WHERE agent_id = ?`, agentID).
Scan(&t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart, &t.DayRx, &t.DayTx, &dayStart)
if err == sql.ErrNoRows {
return Traffic{}, false, nil
}
if err != nil {
return Traffic{}, false, err
}
resetDay, err := s.ResetDay(agentID)
if err != nil {
return Traffic{}, false, err
}
return gate(t, dayStart, resetDay, time.Now()), true, nil
}
// AllTraffic returns every agent's ledger in one query, gated as in
// TrafficFor — the bulk read port for B3's node list.
func (s *Store) AllTraffic() (map[int64]Traffic, error) {
rows, err := s.db.Query(`
SELECT t.agent_id, t.total_rx, t.total_tx, t.month_rx, t.month_tx, t.month_start,
t.day_rx, t.day_tx, t.day_start, COALESCE(m.traffic_reset_day, 1)
FROM probe_traffic t LEFT JOIN probe_meta m ON m.agent_id = t.agent_id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[int64]Traffic)
now := time.Now()
for rows.Next() {
var id, resetDay int64
var t Traffic
var dayStart string
if err := rows.Scan(&id, &t.TotalRx, &t.TotalTx, &t.MonthRx, &t.MonthTx, &t.MonthStart,
&t.DayRx, &t.DayTx, &dayStart, &resetDay); err != nil {
return nil, err
}
out[id] = gate(t, dayStart, resetDay, now)
}
return out, rows.Err()
}
// parseDay parses a stored "2006-01-02" stamp (used by tests).
func parseDay(s string) (time.Time, bool) {
t, err := time.ParseInLocation(dateLayout, s, time.Local)
if err != nil {
return time.Time{}, false
}
return t, true
}
+270
View File
@@ -0,0 +1,270 @@
package probe
import (
"path/filepath"
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open(filepath.Join(t.TempDir(), "probe.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { st.Close() })
return st
}
func book(t *testing.T, st *Store, agent int64, boot string, rx, tx int64, at time.Time, booted *int64, lastSeen int64) Traffic {
t.Helper()
tr, err := st.accumulate(agent, boot, rx, tx, at, booted, lastSeen)
if err != nil {
t.Fatalf("accumulate: %v", err)
}
return tr
}
func i64p(v int64) *int64 { return &v }
func TestPeriodStart(t *testing.T) {
d := func(y int, m time.Month, day int) time.Time {
return time.Date(y, m, day, 0, 0, 0, 0, time.Local)
}
cases := []struct {
today time.Time
reset int64
want time.Time
}{
{d(2026, 10, 23), 1, d(2026, 10, 1)},
{d(2026, 10, 23), 23, d(2026, 10, 23)},
{d(2026, 10, 23), 24, d(2026, 9, 24)},
{d(2026, 2, 15), 31, d(2026, 1, 31)}, // Jan has 31
{d(2026, 2, 28), 31, d(2026, 2, 28)}, // short month: reset lands on last day
{d(2026, 3, 1), 31, d(2026, 2, 28)}, // Mar 1 with reset 31 is still in Feb's period
{d(2026, 3, 31), 31, d(2026, 3, 31)},
{d(2026, 1, 1), 15, d(2025, 12, 15)}, // wraparound into previous year
{d(2026, 10, 23), 0, d(2026, 10, 1)}, // clamp low
{d(2026, 10, 23), 99, d(2026, 9, 30)}, // clamp high: Sep has 30
}
for _, c := range cases {
if got := PeriodStart(c.today, c.reset); !got.Equal(c.want) {
t.Errorf("PeriodStart(%v, %d) = %v, want %v", c.today, c.reset, got, c.want)
}
}
}
func TestAccumulateFirstReadingAlignsBaseline(t *testing.T) {
st := newTestStore(t)
now := time.Now()
tr := book(t, st, 1, "k1/fp", 1_000_000, 2_000_000, now, nil, 0)
if tr.TotalRx != 0 || tr.TotalTx != 0 || tr.MonthRx != 0 || tr.DayRx != 0 {
t.Fatalf("first reading must book 0, got %+v", tr)
}
// Same boot, counters climb: the delta books.
tr = book(t, st, 1, "k1/fp", 1_000_600, 2_000_900, now.Add(time.Minute), nil, now.Unix())
if tr.TotalRx != 600 || tr.TotalTx != 900 || tr.MonthRx != 600 || tr.DayTx != 900 {
t.Fatalf("delta not booked: %+v", tr)
}
}
func TestAccumulateCounterShrinkSameBoot(t *testing.T) {
st := newTestStore(t)
now := time.Now()
book(t, st, 1, "k1/fp", 5000, 5000, now, nil, 0)
book(t, st, 1, "k1/fp", 6000, 6000, now.Add(time.Minute), nil, now.Unix())
// A counted interface vanishes: the sum shrinks. Booking it again
// would double-count the remainder, so the delta is 0 and the
// baseline re-aligns.
tr := book(t, st, 1, "k1/fp", 2000, 2000, now.Add(2*time.Minute), nil, now.Unix())
if tr.TotalRx != 1000 {
t.Fatalf("shrink must book 0, total=%d", tr.TotalRx)
}
tr = book(t, st, 1, "k1/fp", 2500, 2600, now.Add(3*time.Minute), nil, now.Unix())
if tr.TotalRx != 1500 || tr.TotalTx != 1600 {
t.Fatalf("post-shrink delta wrong: %+v", tr)
}
}
func TestAccumulateBootChangeZeroDelta(t *testing.T) {
st := newTestStore(t)
now := time.Now()
book(t, st, 1, "k1/fpA", 1000, 1000, now, nil, 0)
book(t, st, 1, "k1/fpA", 2000, 2000, now.Add(time.Minute), nil, now.Unix())
// Same kernel, different interface digest (a counted iface joined):
// epoch changed, delta must be 0 even though booted looks recent.
booted := now.Add(30 * time.Second).Unix()
tr := book(t, st, 1, "k1/fpB", 9000, 9000, now.Add(2*time.Minute), &booted, now.Add(time.Minute).Unix())
if tr.TotalRx != 1000 {
t.Fatalf("epoch change must book 0, total=%d", tr.TotalRx)
}
}
func TestAccumulateUnseenBootBooksWholeReading(t *testing.T) {
st := newTestStore(t)
t0 := time.Now().Add(-2 * time.Hour)
book(t, st, 1, "k1/fp", 500, 500, t0, nil, 0)
tr := book(t, st, 1, "k1/fp", 800, 900, t0.Add(time.Minute), nil, t0.Unix())
if tr.TotalRx != 300 {
t.Fatalf("setup delta wrong: %+v", tr)
}
// The machine reboots while the master is not hearing from it:
// new kernel boot, booted after the master's last-seen stamp, so
// the whole counters-since-boot book.
booted := t0.Add(30 * time.Minute).Unix()
tr = book(t, st, 1, "k2/fp", 200, 100, t0.Add(31*time.Minute), &booted, t0.Add(time.Minute).Unix())
if tr.TotalRx != 500 || tr.TotalTx != 500 {
t.Fatalf("unseen boot must book whole reading: %+v", tr)
}
// And it continues from the new baseline.
tr = book(t, st, 1, "k2/fp", 350, 100, t0.Add(32*time.Minute), &booted, t0.Add(31*time.Minute).Unix())
if tr.TotalRx != 650 {
t.Fatalf("post-reboot delta wrong: %+v", tr)
}
}
func TestAccumulateBootChangeSeenRecentlyBooksZero(t *testing.T) {
st := newTestStore(t)
t0 := time.Now().Add(-time.Hour)
book(t, st, 1, "k1/fp", 1000, 1000, t0, nil, 0)
book(t, st, 1, "k1/fp", 2000, 2000, t0.Add(time.Minute), nil, t0.Unix())
// A different kernel boot that started BEFORE the master last saw
// the node (e.g. a second machine sharing the token): not the
// unseen-boot exception; books 0.
booted := t0.Add(30 * time.Second).Unix() // < lastSeen t0+60s
tr := book(t, st, 1, "k2/fp", 5000, 5000, t0.Add(2*time.Minute), &booted, t0.Add(time.Minute).Unix())
if tr.TotalRx != 1000 {
t.Fatalf("seen boot change must book 0, total=%d", tr.TotalRx)
}
}
func TestAccumulateCrossDay(t *testing.T) {
st := newTestStore(t)
day1 := dateOnly(time.Now()).Add(-48 * time.Hour) // midnight, two days ago
r1 := day1.Add(23*time.Hour + 58*time.Minute) // day D 23:58
r2 := day1.Add(23*time.Hour + 59*time.Minute) // day D 23:59
r3 := day1.Add(24*time.Hour + time.Minute) // day D+1 00:01
book(t, st, 1, "k/fp", 1000, 1000, r1, nil, 0)
tr := book(t, st, 1, "k/fp", 1100, 1100, r2, nil, r1.Unix())
if tr.DayRx != 100 {
t.Fatalf("day1 day_rx=%d, want 100", tr.DayRx)
}
tr = book(t, st, 1, "k/fp", 1150, 1150, r3, nil, r2.Unix())
if tr.DayRx != 50 || tr.TotalRx != 150 || tr.MonthRx != 150 {
t.Fatalf("cross-day split wrong: %+v", tr)
}
if tr.DayStart != dateOnly(r3).Format(dateLayout) {
t.Fatalf("day stamp=%s", tr.DayStart)
}
}
func TestAccumulateCrossPeriodWithResetDay(t *testing.T) {
st := newTestStore(t)
// reset day 15 on the meta row.
if err := st.ensureRows(7); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(`UPDATE probe_meta SET traffic_reset_day = 15 WHERE agent_id = 7`); err != nil {
t.Fatal(err)
}
now := time.Now()
// Find a date in the current period and one in the next, both <= now
// when possible: use period arithmetic from PeriodStart itself.
periodStart := PeriodStart(dateOnly(now), 15)
inPeriod := periodStart.Add(24 * time.Hour)
if inPeriod.After(now) {
inPeriod = periodStart
}
book(t, st, 7, "k/fp", 1000, 1000, inPeriod, nil, 0)
tr := book(t, st, 7, "k/fp", 1300, 1300, inPeriod.Add(time.Minute), nil, inPeriod.Unix())
if tr.MonthRx != 300 {
t.Fatalf("month before boundary=%d, want 300", tr.MonthRx)
}
nextPeriod := PeriodStart(periodStart.AddDate(0, 2, 0), 15)
at := nextPeriod.Add(time.Minute)
tr = book(t, st, 7, "k/fp", 1500, 1500, at, nil, inPeriod.Unix())
// Delta 200 lands in the new period; the month counter restarts at
// the delta alone, the total keeps everything. (When the booking is
// future-dated relative to a held-back stamp, accumulate's dated()
// guard applies; both stamps here precede the booking date.)
if tr.TotalRx != 500 {
t.Fatalf("total=%d, want 500", tr.TotalRx)
}
if tr.MonthRx != 200 {
t.Fatalf("month after boundary=%d, want 200", tr.MonthRx)
}
if tr.MonthStart != nextPeriod.Format(dateLayout) {
t.Fatalf("month stamp=%s, want %s", tr.MonthStart, nextPeriod.Format(dateLayout))
}
}
func TestMonthUsedModes(t *testing.T) {
tr := Traffic{MonthRx: 100, MonthTx: 250}
if got := tr.MonthUsed("sum"); got != 350 {
t.Errorf("sum=%d", got)
}
if got := tr.MonthUsed("up"); got != 250 {
t.Errorf("up=%d", got)
}
if got := tr.MonthUsed("down"); got != 100 {
t.Errorf("down=%d", got)
}
if got := tr.MonthUsed("max"); got != 250 {
t.Errorf("max=%d", got)
}
if got := tr.MonthUsed(""); got != 350 {
t.Errorf("default=%d", got)
}
}
func TestTrafficReadGating(t *testing.T) {
st := newTestStore(t)
// Book in an old period (two months ago): month figures belong to
// that period and must read back as zero, totals intact.
old := dateOnly(time.Now()).AddDate(0, -2, 0)
book(t, st, 3, "k/fp", 1000, 1000, old, nil, 0)
book(t, st, 3, "k/fp", 1400, 1500, old.Add(time.Minute), nil, old.Unix())
got, ok, err := st.TrafficFor(3)
if err != nil || !ok {
t.Fatalf("TrafficFor: ok=%v err=%v", ok, err)
}
if got.TotalRx != 400 || got.TotalTx != 500 {
t.Fatalf("totals must survive gating: %+v", got)
}
if got.MonthRx != 0 || got.MonthTx != 0 || got.DayRx != 0 {
t.Fatalf("stale period must gate to zero: %+v", got)
}
all, err := st.AllTraffic()
if err != nil {
t.Fatal(err)
}
if all[3].TotalRx != 400 || all[3].MonthRx != 0 {
t.Fatalf("AllTraffic gating wrong: %+v", all[3])
}
if _, ok, _ := st.TrafficFor(999); ok {
t.Fatal("unknown agent must report not-found")
}
}
func TestSetTrafficStampsCurrentPeriod(t *testing.T) {
st := newTestStore(t)
now := time.Now()
book(t, st, 5, "k/fp", 1000, 1000, now, nil, 0)
ok, err := st.SetTraffic(5, i64p(10_000), nil, i64p(700), nil)
if err != nil || !ok {
t.Fatalf("SetTraffic: ok=%v err=%v", ok, err)
}
got, _, _ := st.TrafficFor(5)
if got.TotalRx != 10_000 || got.MonthRx != 700 {
t.Fatalf("correction not visible: %+v", got)
}
// The next booking must not discard the correction: month is
// stamped with the current period, so the delta adds onto it.
tr := book(t, st, 5, "k/fp", 1100, 1000, now.Add(time.Minute), nil, now.Unix())
if tr.MonthRx != 800 || tr.TotalRx != 10_100 {
t.Fatalf("booking after correction wrong: %+v", tr)
}
if ok, _ := st.SetTraffic(4242, i64p(1), nil, nil, nil); ok {
t.Fatal("SetTraffic on unknown agent must be false")
}
}
+57
View File
@@ -0,0 +1,57 @@
package service
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// ProbeCoverageRow is one agent's cf-probe retirement readiness
// (item 7 B6): whether its heartbeats carry the probe block the
// public page is fed from. The retirement checklist in the settings
// tab renders from these rows — the operator verifies every node is
// covered by the in-panel probe before standing the old cf-probe
// fleet down, node by node.
type ProbeCoverageRow struct {
Name string `json:"name"`
Online bool `json:"online"`
HasProbe bool `json:"hasProbe"`
ProbeAt int64 `json:"probeAt"` // unix ms of the last probe-carrying heartbeat, 0 = never
}
// ProbeAgentCoverage lists every agent with its probe coverage. The
// display name follows the public view's rule (the bound node's name
// first, the agent's own name otherwise); online is the public
// view's 150s freshness window.
func ProbeAgentCoverage() []ProbeCoverageRow {
db := database.GetDB()
var agents []model.AgentNode
if err := db.Find(&agents).Error; err != nil {
return []ProbeCoverageRow{}
}
nodeNames := map[int]string{}
var nodes []model.Node
if err := db.Find(&nodes).Error; err == nil {
for _, n := range nodes {
nodeNames[n.Id] = n.Name
}
}
nowMs := time.Now().UnixMilli()
out := make([]ProbeCoverageRow, 0, len(agents))
for _, a := range agents {
name := a.Name
if a.NodeID != nil {
if bound, ok := nodeNames[*a.NodeID]; ok && bound != "" {
name = bound
}
}
out = append(out, ProbeCoverageRow{
Name: name,
Online: a.LastSeen > 0 && nowMs-a.LastSeen <= 150_000,
HasProbe: a.ProbeAt > 0,
ProbeAt: a.ProbeAt,
})
}
return out
}
+539
View File
@@ -0,0 +1,539 @@
package service
import (
"bytes"
"compress/gzip"
"encoding/json"
"net"
"sort"
"strings"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/probe"
)
// Item 7 B3: the public status surface (monitor hub contract). The
// node views are assembled whitelist-style from three sources — the
// agent_nodes snapshot rows (facts + latest metrics, B1), the probe
// ledger/metadata (B2) and the bound panel node's name/remark — and
// rendered into per-audience snapshot frames shared by /api/nodes and
// /api/ws, exactly as the hub renders one frame per audience per tick.
// ProbeOnlineThresholdMs is how stale an agent's last heartbeat may be
// before the public page calls it offline. Heartbeats land every 60 s;
// 150 s absorbs one lost beat plus jitter.
const ProbeOnlineThresholdMs = 150_000
// ProbeSnapshotTTL mirrors the hub's 1900 ms frame reuse: just under
// the 2 s push interval, so a frame is built at most once per tick.
const ProbeSnapshotTTL = 1900 * time.Millisecond
// ---- public-surface settings (main settings KV, hub key names) ----
func probeSetting(key string) (string, bool) {
var row model.Setting
if err := database.GetDB().Where("key = ?", key).First(&row).Error; err != nil {
return "", false
}
return row.Value, true
}
func setProbeSetting(key, value string) error {
db := database.GetDB()
var row model.Setting
err := db.Where("key = ?", key).First(&row).Error
if database.IsNotFound(err) {
return db.Create(&model.Setting{Key: key, Value: value}).Error
}
if err != nil {
return err
}
row.Value = value
return db.Save(&row).Error
}
// ProbePublicPageEnabled reports the public-page master switch. Per
// the item-7 scope decision the page ships OFF until the operator
// turns it on (setting public_page = "on"); the hub's own default is
// on, the deviation is deliberate and recorded in the B3 report.
func ProbePublicPageEnabled() bool {
v, _ := probeSetting("public_page")
return v == "on"
}
// SetProbePublicPageEnabled flips the public-page master switch.
func SetProbePublicPageEnabled(on bool) error {
if on {
return setProbeSetting("public_page", "on")
}
return setProbeSetting("public_page", "off")
}
// ProbeSiteName is the status page's site name (hub default "Monitor").
func ProbeSiteName() string {
if v, ok := probeSetting("site_name"); ok && v != "" {
return v
}
return "Monitor"
}
// ProbeSiteURL is the hub's public URL setting ("site"), "" when unset.
func ProbeSiteURL() string {
v, _ := probeSetting("site")
return v
}
// ProbeGitHubConfigured mirrors /api/me's github flag: whether a
// github_client_id is configured. (GitHub login itself is not ported;
// the panel's own session is the only login.)
func ProbeGitHubConfigured() bool {
v, _ := probeSetting("github_client_id")
return strings.TrimSpace(v) != ""
}
// ProbeCurrentTheme is the selected theme short ("" = built-in default).
func ProbeCurrentTheme() string {
v, _ := probeSetting("theme")
return v
}
// ProbeThemeConfigValue reads a theme's saved config JSON verbatim
// (setting key theme_config:{short}), "{}" when never saved. A read
// failure is an error, not an empty config: a caller saving on top of
// what it read would otherwise erase every override (hub discipline).
func ProbeThemeConfigValue(short string) (string, error) {
var row model.Setting
err := database.GetDB().Where("key = ?", "theme_config:"+short).First(&row).Error
if database.IsNotFound(err) {
return "{}", nil
}
if err != nil {
return "", err
}
if row.Value == "" {
return "{}", nil
}
return row.Value, nil
}
// SaveProbeThemeConfigRaw stores a theme's config JSON verbatim.
func SaveProbeThemeConfigRaw(short, raw string) error {
return setProbeSetting("theme_config:"+short, raw)
}
// ---- node view assembly ----
type probeNodeSource struct {
agent model.AgentNode
name string
remark string
meta probe.Meta
traffic probe.Traffic
nowMs int64
now time.Time
online bool
lastSeen int64 // unix seconds, 0 = never seen
metricsOn bool
metricsObj map[string]any
}
func loadProbeNodeSources() []probeNodeSource {
now := time.Now()
nowMs := now.UnixMilli()
var agents []model.AgentNode
if err := database.GetDB().Order("id ASC").Find(&agents).Error; err != nil {
logger.Warningf("probe public: load agents: %v", err)
return nil
}
nodesByID := map[int]model.Node{}
var nodes []model.Node
if err := database.GetDB().Find(&nodes).Error; err == nil {
for _, n := range nodes {
nodesByID[n.Id] = n
}
}
metas, err := probe.MetaSnapshot()
if err != nil {
logger.Warningf("probe public: load meta: %v", err)
metas = map[int64]probe.Meta{}
}
traffic, err := probe.TrafficSnapshot()
if err != nil {
logger.Warningf("probe public: load traffic: %v", err)
traffic = map[int64]probe.Traffic{}
}
out := make([]probeNodeSource, 0, len(agents))
for i := range agents {
a := agents[i]
src := probeNodeSource{agent: a, name: a.Name, nowMs: nowMs, now: now}
if a.NodeID != nil {
if n, ok := nodesByID[*a.NodeID]; ok {
if n.Name != "" {
src.name = n.Name
}
src.remark = n.Remark
}
}
if m, ok := metas[int64(a.Id)]; ok {
src.meta = m
} else {
src.meta = probe.DefaultMeta(int64(a.Id))
}
src.traffic = traffic[int64(a.Id)]
src.online = a.LastSeen > 0 && nowMs-a.LastSeen <= ProbeOnlineThresholdMs
src.lastSeen = a.LastSeen / 1000
if src.online && a.ProbeAt > 0 {
src.metricsOn = true
src.metricsObj = probeLiveMetrics(&a, src.traffic)
}
out = append(out, src)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].meta.Sort != out[j].meta.Sort {
return out[i].meta.Sort < out[j].meta.Sort
}
return out[i].agent.Id < out[j].agent.Id
})
return out
}
// probeLiveMetrics builds the whitelisted live metrics object
// (hub PUBLIC_METRICS) from the latest snapshot, with the four ledger
// figures injected (hub INJECTED — they never arrive from an agent).
func probeLiveMetrics(a *model.AgentNode, t probe.Traffic) map[string]any {
return map[string]any{
"uptime": a.UptimeSecs,
"cpu": float64(a.ProbeCPU),
"load": []float64{float64(a.ProbeLoad1), float64(a.ProbeLoad5), float64(a.ProbeLoad15)},
"mem_total": a.ProbeMemTotal,
"mem_used": a.ProbeMemUsed,
"swap_total": a.ProbeSwapTotal,
"swap_used": a.ProbeSwapUsed,
"disk_total": a.ProbeDiskTotal,
"disk_used": a.ProbeDiskUsed,
"net_rx": a.ProbeNetRx,
"net_tx": a.ProbeNetTx,
"tcp": a.ProbeTCP,
"udp": a.ProbeUDP,
"procs": a.ProbeProcs,
"total_rx": t.TotalRx,
"total_tx": t.TotalTx,
"month_rx": t.MonthRx,
"month_tx": t.MonthTx,
}
}
// expiresInDays counts whole calendar days from the local today to
// the expires date, as the hub does on its own calendar.
func expiresInDays(expires *string, now time.Time) any {
if expires == nil {
return nil
}
d, err := time.ParseInLocation("2006-01-02", *expires, time.Local)
if err != nil {
return nil
}
ny, nm, nd := now.Date()
ey, em, ed := d.Date()
days := int(time.Date(ey, em, ed, 0, 0, 0, 0, time.UTC).Unix()/86400 -
time.Date(ny, nm, nd, 0, 0, 0, 0, time.UTC).Unix()/86400)
return days
}
// buildProbeNodeView renders one node for one audience. The public
// shape is built by whitelist (hub node_view): address, hostname and
// the private remark exist only in the admin (full) rendering.
func buildProbeNodeView(src probeNodeSource, full bool) map[string]any {
a := src.agent
m := src.meta
t := src.traffic
var lastSeenAgo any
if src.lastSeen > 0 {
ago := src.now.Unix() - src.lastSeen
if ago < 0 {
ago = 0
}
lastSeenAgo = ago
}
var metrics any
if src.metricsOn {
obj := make(map[string]any, len(src.metricsObj)+1)
for k, v := range src.metricsObj {
obj[k] = v
}
if full {
obj["iface"] = a.ProbeIface
}
metrics = obj
}
view := map[string]any{
"id": a.Id,
"name": src.name,
"country": m.CountryPin,
"group": m.GroupName,
"public_remark": m.PublicRemark,
"sort": m.Sort,
"public": m.Public,
"online": src.online,
"last_seen": src.lastSeen,
"last_seen_ago": lastSeenAgo,
"metrics": metrics,
"os": a.ProbeOS,
"kernel": a.ProbeKernel,
"arch": a.ProbeArch,
"virt": a.ProbeVirt,
"cpu_name": a.ProbeCPUName,
"cpu_cores": a.ProbeCPUCores,
"mem_total": a.ProbeMemTotal,
"swap_total": a.ProbeSwapTotal,
"disk_total": a.ProbeDiskTotal,
"agent_version": a.AgentVersion,
"price": m.Price,
"currency": m.Currency,
"billing_cycle": m.BillingCycle,
"expires_at": m.ExpiresAt,
"expires_in": expiresInDays(m.ExpiresAt, src.now),
"traffic_limit": m.TrafficLimit,
"traffic_mode": m.TrafficMode,
"traffic_reset_day": m.TrafficResetDay,
"total_rx": t.TotalRx,
"total_tx": t.TotalTx,
"month_rx": t.MonthRx,
"month_tx": t.MonthTx,
"month_used": t.MonthUsed(m.TrafficMode),
"month_start": t.MonthStart,
"day_rx": t.DayRx,
"day_tx": t.DayTx,
}
if !full {
return view
}
// Admin-only half: identity and addresses never leave the panel
// in the anonymous rendering (they are simply never built there).
held4, held6 := a.IPv4, a.IPv6
exits := []string{a.SeenIPv4, a.SeenIPv6}
shown := probeAddresses(exits, held4, held6, m.IPv4Pin, m.IPv6Pin)
addrList := make([]map[string]string, 0, len(shown))
for _, s := range shown {
addrList = append(addrList, map[string]string{"address": s.addr, "source": s.source})
}
ip := ""
for _, e := range exits {
if e != "" {
ip = e
break
}
}
var interval any
if src.online {
interval = 60 // heartbeat cadence is fixed at 60 s by design
}
view["hostname"] = a.ProbeHostname
view["ip"] = ip
view["ipv4"] = held4
view["ipv6"] = held6
view["ipv4_pin"] = m.IPv4Pin
view["ipv6_pin"] = m.IPv6Pin
view["addresses"] = addrList
view["ipv4_auto"] = probeAutoAddress(exits, held4, held6, "", m.IPv6Pin, false)
view["ipv6_auto"] = probeAutoAddress(exits, held4, held6, m.IPv4Pin, "", true)
view["interval"] = interval
view["country_pin"] = m.CountryPin
view["country_auto"] = ""
view["remark"] = src.remark
view["notify"] = m.Notify
return view
}
type probeShownAddr struct {
addr string
source string
}
// probeAddresses is the hub's addresses() port: per family a manual
// pin wins, then a public interface address, then — when the interface
// holds only a private address of that family — the public exit the
// master observed. With nothing public anywhere it falls back to the
// held interface addresses, then to the connection source.
func probeAddresses(exits []string, held4, held6, pin4, pin6 string) []probeShownAddr {
public := func(s string, v6 bool) bool {
ip := net.ParseIP(s)
if ip == nil || (ip.To4() == nil) != v6 {
return false
}
return IsPublicIPLiteral(s)
}
family := func(pin, held string, v6 bool) *probeShownAddr {
if pin != "" {
return &probeShownAddr{pin, "manual"}
}
if public(held, v6) {
return &probeShownAddr{held, "interface"}
}
if held == "" {
return nil
}
for _, e := range exits {
if public(e, v6) {
return &probeShownAddr{e, "exit"}
}
}
return nil
}
var shown []probeShownAddr
if s := family(pin4, held4, false); s != nil {
shown = append(shown, *s)
}
if s := family(pin6, held6, true); s != nil {
shown = append(shown, *s)
}
if len(shown) > 0 {
return shown
}
for _, h := range []string{held4, held6} {
if h != "" {
shown = append(shown, probeShownAddr{h, "interface"})
}
}
if len(shown) > 0 {
return shown
}
for _, e := range exits {
if e != "" {
return []probeShownAddr{{e, "connection"}}
}
}
return nil
}
// probeAutoAddress recomputes one family's shown address with its own
// pin cleared (the edit form's fallback hint, hub ipv4_auto/ipv6_auto).
func probeAutoAddress(exits []string, held4, held6, pin4, pin6 string, v6 bool) string {
for _, s := range probeAddresses(exits, held4, held6, pin4, pin6) {
if strings.Contains(s.addr, ":") == v6 {
return s.addr
}
}
return ""
}
// BuildProbeNodeViews renders every node for one audience; anonymous
// renders only public=1 nodes.
func BuildProbeNodeViews(full bool) []map[string]any {
sources := loadProbeNodeSources()
views := make([]map[string]any, 0, len(sources))
for _, src := range sources {
if !full && !src.meta.Public {
continue
}
views = append(views, buildProbeNodeView(src, full))
}
return views
}
// ---- snapshot frames (shared by /api/nodes and /api/ws) ----
type probeFrame struct {
at time.Time
body []byte
gz []byte
}
var probeSnapshots struct {
mu sync.Mutex
frames [2]probeFrame
}
// ProbeSnapshotJSON returns the audience's rendered snapshot frame,
// rebuilding it at most once per ProbeSnapshotTTL.
func ProbeSnapshotJSON(full bool) []byte {
probeSnapshots.mu.Lock()
defer probeSnapshots.mu.Unlock()
idx := 0
if full {
idx = 1
}
f := &probeSnapshots.frames[idx]
if f.body == nil || time.Since(f.at) < 0 || time.Since(f.at) >= ProbeSnapshotTTL {
frame := struct {
Nodes []map[string]any `json:"nodes"`
Admin bool `json:"admin"`
}{Nodes: BuildProbeNodeViews(full), Admin: full}
body, err := json.Marshal(frame)
if err != nil {
body = []byte(`{"nodes":[],"admin":false}`)
}
f.at = time.Now()
f.body = body
f.gz = nil
}
return f.body
}
// ProbeSnapshotGzip is the anonymous frame gzipped, built lazily once
// per frame (hub live_gzip) for /api/ws ?gzip binary frames and for
// pre-compressed /api/nodes answers.
func ProbeSnapshotGzip() []byte {
body := ProbeSnapshotJSON(false)
probeSnapshots.mu.Lock()
defer probeSnapshots.mu.Unlock()
f := &probeSnapshots.frames[0]
if f.gz == nil {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
_, _ = w.Write(body)
_ = w.Close()
f.gz = buf.Bytes()
}
return f.gz
}
// InvalidateProbeSnapshot drops both cached frames so the next read
// rebuilds (called after metadata edits, as the hub does on node
// writes).
func InvalidateProbeSnapshot() {
probeSnapshots.mu.Lock()
defer probeSnapshots.mu.Unlock()
probeSnapshots.frames = [2]probeFrame{}
}
// ProbeNodeReadable mirrors the hub's readable(): the panel sees
// every node; an anonymous caller only public=1 nodes, and only while
// the public page is on. As on the hub, an authed caller may read a
// window for a node that does not exist (it comes back empty); the
// anonymous side needs the agent to exist with the public flag set,
// consistent with what /api/nodes lists.
func ProbeNodeReadable(full bool, agentID int64) bool {
if full {
return true
}
if !ProbePublicPageEnabled() || !ProbeAgentExists(agentID) {
return false
}
meta, ok, err := probe.MetaFor(agentID)
if err != nil {
return false
}
if !ok {
return probe.DefaultMeta(agentID).Public
}
return meta.Public
}
// ProbeAgentExists reports whether an agent row exists.
func ProbeAgentExists(agentID int64) bool {
var count int64
if err := database.GetDB().Model(&model.AgentNode{}).Where("id = ?", agentID).Count(&count).Error; err != nil {
return false
}
return count > 0
}
+286
View File
@@ -0,0 +1,286 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/gin-gonic/gin"
)
// Item 7 B3+B5: public theme hosting (hub frontend.rs
// serve/disk/asset port). Themes are static directory packages on
// disk — <db folder>/themes/<short>/dist — served by the panel
// process itself at the engine root, next to the contract API. A
// theme's files are only ever read as static bytes, never executed.
// What an installed theme does not answer falls through to the
// embedded fallback theme (B5: monitor-theme-default's release dist,
// vendored under probe_theme_default/), so the public page answers
// even with nothing installed. Site icons (the favicon / touch_icon
// settings) answer ahead of any theme, so the icon follows the site
// across theme switches.
// ProbeThemesRoot is the on-disk themes directory.
func ProbeThemesRoot() string {
return filepath.Join(config.GetDBFolderPath(), "themes")
}
// ValidThemeShort mirrors the hub's valid_short: the name of a theme
// directory, so no separators and nothing empty ([A-Za-z0-9_-]+).
func ValidThemeShort(short string) bool {
if short == "" {
return false
}
for _, r := range short {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_') {
return false
}
}
return true
}
// probeExternalTheme resolves the dist directory of an installed
// theme (hub external_theme): the theme counts only when dist and
// its index.html are actually there.
func probeExternalTheme(short string) (string, bool) {
if short == "" {
short = "default"
}
if !ValidThemeShort(short) {
return "", false
}
themes, err := filepath.EvalSymlinks(ProbeThemesRoot())
if err != nil {
return "", false
}
root, err := filepath.EvalSymlinks(filepath.Join(themes, short))
if err != nil || !strings.HasPrefix(root, themes+string(os.PathSeparator)) {
return "", false
}
dist, err := filepath.EvalSymlinks(filepath.Join(root, "dist"))
if err != nil || !strings.HasPrefix(dist, root+string(os.PathSeparator)) {
return "", false
}
info, err := os.Stat(filepath.Join(dist, "index.html"))
if err != nil || info.IsDir() {
return "", false
}
return dist, true
}
// ProbeThemeInstalled reports whether a theme short is selectable
// (hub selectable): an installed external theme.
func ProbeThemeInstalled(short string) bool {
_, ok := probeExternalTheme(short)
return ok
}
// readInsideTheme reads a regular file whose canonical path stays
// below root (hub read_inside), rejecting escapes and symlinks out.
func readInsideTheme(root, relative string) ([]byte, bool) {
canonRoot, err := filepath.EvalSymlinks(root)
if err != nil {
return nil, false
}
file, err := filepath.EvalSymlinks(filepath.Join(canonRoot, relative))
if err != nil || !strings.HasPrefix(file, canonRoot+string(os.PathSeparator)) {
return nil, false
}
info, err := os.Stat(file)
if err != nil || !info.Mode().IsRegular() {
return nil, false
}
data, err := os.ReadFile(file)
if err != nil {
return nil, false
}
return data, true
}
func isThemeAssetPath(p string) bool { return strings.HasPrefix(p, "assets/") }
// probeThemeETag is the strong validator of a served body: half a
// SHA-256 of it, quoted (hub asset()).
func probeThemeETag(data []byte) string {
sum := sha256.Sum256(data)
return "\"" + hex.EncodeToString(sum[:])[:32] + "\""
}
// serveThemeAsset writes one theme file with the caching policy its
// path warrants (hub asset()): hashed files under assets/ are
// immutable for a year; everything else is the SPA shell — no-cache
// plus a strong ETag (half the SHA-256 of the body), so a theme
// switch lands on the next request at the cost of a 304.
func serveThemeAsset(c *gin.Context, path string, data []byte) {
contentType := mime.TypeByExtension(filepath.Ext(path))
if contentType == "" {
contentType = "application/octet-stream"
}
if isThemeAssetPath(path) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, contentType, data)
return
}
etag := probeThemeETag(data)
c.Header("Content-Type", contentType)
c.Header("Cache-Control", "no-cache")
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
c.Data(http.StatusOK, contentType, data)
}
// probeThemeChainBytes resolves the bytes a path would be answered
// with, ahead of writing any response: the current theme's own file,
// then the embedded fallback's, then (for the shell paths the stamp
// pass asks about) the index.html either would fall back to.
func probeThemeChainBytes(relative string) ([]byte, bool) {
if dist, ok := probeExternalTheme(ProbeCurrentTheme()); ok {
if data, found := readInsideTheme(dist, relative); found {
return data, true
}
}
if data, found := probeEmbeddedFile(relative); found {
return data, true
}
if !isThemeAssetPath(relative) {
if dist, ok := probeExternalTheme(ProbeCurrentTheme()); ok {
if data, found := readInsideTheme(dist, "index.html"); found {
return data, true
}
}
if data, found := probeEmbeddedFile("index.html"); found {
return data, true
}
}
return nil, false
}
// probeStampIconPaths are the icon URLs the shell stamp names after
// the bytes served for them (hub stamp_icons).
var probeStampIconPaths = []string{
"favicon.svg", "admin/favicon.svg", "apple-touch-icon.png", "admin/apple-touch-icon.png",
}
// stampProbeIcons rewrites each icon URL in the HTML shell to carry
// the ETag of what that URL serves (hub stamp_icons): the URL a theme
// writes is fixed while what it serves is not — a site icon set or
// cleared, a theme switched — and browsers hold a tab's icon by its
// URL without re-asking, so a changed icon must become a new URL.
func stampProbeIcons(html []byte) []byte {
text := string(html)
for _, p := range probeStampIconPaths {
quoted := "\"/" + p + "\""
if !strings.Contains(text, quoted) {
continue
}
var (
data []byte
ok bool
)
if key, isIcon := ProbeIconSettingKey(p); isIcon {
if v := ProbeIconSetting(key); v != "" {
if _, iconBytes, err := ProbeSiteIcon(v); err == nil {
data, ok = iconBytes, true
}
}
}
if !ok {
data, ok = probeThemeChainBytes(p)
}
if !ok {
continue
}
etag := strings.Trim(probeThemeETag(data), "\"")
if len(etag) > 8 {
etag = etag[:8]
}
text = strings.ReplaceAll(text, quoted, "\"/"+p+"?v="+etag+"\"")
}
return []byte(text)
}
// serveProbeThemeFile serves one resolved theme file, stamping the
// shell's icon URLs on the way out.
func serveProbeThemeFile(c *gin.Context, path string, data []byte) {
if path == "index.html" {
data = stampProbeIcons(data)
}
serveThemeAsset(c, path, data)
}
// serveProbeIcon answers a site icon under the shell's caching
// policy (hub icon()): the URL stays while the bytes change, and an
// SVG opened directly is a document at this origin, so the sandbox
// keeps any script in it from running here.
func serveProbeIcon(c *gin.Context, mimeType string, data []byte) {
etag := probeThemeETag(data)
c.Header("Content-Type", mimeType)
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
c.Header("Cache-Control", "no-cache")
c.Header("ETag", etag)
if c.GetHeader("If-None-Match") == etag {
c.AbortWithStatus(http.StatusNotModified)
return
}
c.Data(http.StatusOK, mimeType, data)
}
// ServeProbeThemePath answers one non-API root path (hub respond):
// a set site icon first (unless the request asks for the theme's own
// with ?theme), then the current theme's file, its index.html as the
// SPA fallback for non-asset paths, then the embedded fallback theme
// the same way — a missing hashed asset is a true 404 from the
// fallback, never HTML. Returns false only when nothing at all can
// answer (the embedded theme is part of the binary, so in practice
// the fallback always answers).
func ServeProbeThemePath(c *gin.Context, path string) bool {
if key, isIcon := ProbeIconSettingKey(path); isIcon && c.Request.URL.RawQuery != "theme" {
if v := ProbeIconSetting(key); v != "" {
if mimeType, data, err := ProbeSiteIcon(v); err == nil {
serveProbeIcon(c, mimeType, data)
return true
}
}
}
rel := path
if rel == "" {
rel = "index.html"
}
if dist, ok := probeExternalTheme(ProbeCurrentTheme()); ok {
if data, found := readInsideTheme(dist, rel); found {
serveProbeThemeFile(c, rel, data)
return true
}
if !isThemeAssetPath(rel) {
if data, found := readInsideTheme(dist, "index.html"); found {
serveProbeThemeFile(c, "index.html", data)
return true
}
}
// A missing asset defers to the embedded theme, which issues
// the refusal (hub disk() returning None).
}
if data, found := probeEmbeddedFile(rel); found {
serveProbeThemeFile(c, rel, data)
return true
}
if isThemeAssetPath(rel) {
c.String(http.StatusNotFound, "没有这个文件:/%s", rel)
return true
}
if data, found := probeEmbeddedFile("index.html"); found {
serveProbeThemeFile(c, "index.html", data)
return true
}
return false
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 stqfdyr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#012169" d="M0 0h513v342H0z"/><path fill="#FFF" d="M440.1 181.1c-.1 39.2-6.4 81.4-57.4 101.5-51.1-20.1-57.3-62.3-57.4-101.5h114.8z"/><path fill="#1B81B6" d="M439.6 197.7c-2.8 34.9-12.4 67.4-57 85-44.4-17.6-54.5-51.2-56.9-84.9"/><path fill="#FFF" d="M437.8 214.1c-3.2 24.3-16.7 53.5-55.1 68.6-38.4-15.1-50.5-42.5-55.1-68.4"/><path fill="#1B81B6" d="M434.2 230.3c-5.7 17.7-19.3 39.4-51.3 52.8-32-12.6-45.2-33.8-51.4-53"/><path fill="#FFF" d="M426.7 246.9c-6.5 11.3-17.7 25.4-44 35.9-27.5-11.5-37.4-25.3-44-36.1"/><path fill="#1B81B6" d="M412.4 265.1c-8.1 7.2-12 11.2-29.6 17.9-20.1-7.9-22.6-11.6-29.2-17.5"/><path fill="#288549" d="M383.3 231.6c-.2-.2-27.9 35.7-27.9 35.7-1.8-1.3-10-9.5-13.3-15l41.3-50.1 40.2 49.7c-3.9 6.5-11.4 13.6-13.2 15M382.6 85.3l-26.5 45h53z"/><g fill="#F0955D"><path d="M275.9 223.6c3.6-26.7 13.5-47.3 22.1-46.2s12.6 23.7 9 50.4-13.5 47.3-22.1 46.2c-8.6-1.2-12.6-23.7-9-50.4zM467.1 177.7c8.6-1.2 18.5 19.5 22.1 46.2s-.4 49.2-9 50.4-18.5-19.6-22.2-46.3c-3.6-26.6.5-49.2 9.1-50.3z"/></g><g fill="#EADFD5"><path d="M296.4 162c2.4-6 7.4-9.7 11-8.2 3.7 1.5 4.7 7.5 2.3 13.5s-7.4 9.7-11 8.2c-3.6-1.5-4.7-7.5-2.3-13.5zM288.4 271.2c.9-6.2 3.1-11.1 4.9-10.8s2.5 5.5 1.6 11.8c-.9 6.2-3.1 11.1-4.9 10.8s-2.5-5.5-1.6-11.8zM299 258.4c1.8-.1 3.6 4.7 3.9 10.8s-.9 11.1-2.7 11.2c-1.8.1-3.6-4.7-3.9-10.8-.3-6.1.9-11.1 2.7-11.2zM309.5 188.4c2.1-.8 6.1 4.2 8.8 11.2 2.8 7 3.3 13.4 1.2 14.2-2.1.8-6.1-4.2-8.8-11.2-2.8-7-3.3-13.4-1.2-14.2zM316 174.3c7-2.8 13.4-3.3 14.2-1.2.8 2.1-4.2 6.1-11.2 8.8-7 2.8-13.4 3.3-14.2 1.2-.9-2.1 4.2-6 11.2-8.8zM457.6 154.1c3.7-1.5 8.6 2.2 11 8.2 2.4 6 1.4 12.1-2.3 13.5-3.7 1.5-8.6-2.2-11-8.2-2.4-6-1.3-12.1 2.3-13.5zM471.8 260.6c1.8-.3 4 4.6 4.9 10.8.9 6.2.2 11.5-1.6 11.8-1.8.3-4-4.6-4.9-10.8-1-6.3-.2-11.5 1.6-11.8zM462.2 269.4c.3-6.1 2.1-10.9 3.9-10.8 1.8.1 3 5.1 2.7 11.2-.3 6.1-2.1 10.9-3.9 10.8-1.8-.1-3-5.1-2.7-11.2zM446.7 199.7c2.8-7 6.7-12.1 8.8-11.2 2.1.8 1.6 7.2-1.2 14.2-2.8 7-6.7 12.1-8.8 11.2s-1.6-7.2 1.2-14.2zM434.9 173.2c.8-2.1 7.2-1.6 14.2 1.2 7 2.8 12.1 6.7 11.2 8.8-.8 2.1-7.2 1.6-14.2-1.2-7-2.7-12.1-6.7-11.2-8.8z"/></g><path fill="#B0C6CC" d="M373.3 130.3 356.1 155l17.8 26.1H396l4.7-25.4-5.4-25.4z"/><path fill="#012169" d="M0 0h256.5v171H0z"/><g fill="#FFF"><path d="M256.5 0v30.6l-45.3 25.2h45.3v59.4h-59.2l59.2 32.9V171h-26.7l-73.7-40.9V171h-55.7v-48.7L12.8 171H0v-30.6l45.3-25.2H0V55.8h59.2L0 22.9V0h26.7l73.7 40.9V0h55.7v48.7L243.7 0z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM100.4 55.8 0 0v15.8l72 40z"/></g><g fill="#D80027"><path d="M144.3 0h-32.1v69.5H0v32h112.2V171h32.1v-69.5h112.2v-32H144.3z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM72 115.2l-72 40V171l100.4-55.8zM100.4 55.8 0 0v15.8l72 40zM184.5 55.8l72-40V0L156.1 55.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#fedd00" d="M0 0h513v342H0z"/><path fill="#10069f" d="M0 0h171v342H0z"/><path fill="#d50032" d="M342 0h171v342H342z"/><path fill="#D80027" d="M198.9 113.6H256v64.8h-57.1z"/><g stroke="#D80027" stroke-width="7"><path d="M267.6 113.6v64.7M284.2 113.6v64.7M300.8 113.6v64.7"/></g><g stroke="#D80027" stroke-width="5"><path d="M247.4 178.4v64.7M234.1 178.4v64.7M220.8 178.5v53.9M207.1 178.5v47.1"/></g><path fill="#FFDA44" d="m199.9 146 27.6-32.4 28.5 32.3-28.1 32.4z"/><path fill="#BC8B00" d="M182.2 95.9v92.2c0 34.3 27.3 54.2 48.6 64.5-.8 1.4 25.1 8.3 25.1 8.3s25.9-6.9 25.1-8.3c21.4-10.3 48.6-30.1 48.6-64.5V95.9H182.2zm129.9 92.2c0 16.9-10 29.4-32.8 43.9-8.6 5.5-17.5 9-23.3 11-5.8-2-14.7-5.5-23.3-11-22.8-14.5-32.8-28-32.8-43.9v-74.5h112.2v74.5z"/><path fill="#D80027" d="M264.4 188.9h29.5v8.6h-29.5zM264.4 206.5h29.5v8.6h-29.5z"/></svg>

After

Width:  |  Height:  |  Size: 912 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#00843d" d="M0 0h513v114H0z"/><path d="M0 228h513v114H0z"/><path fill="#c8102e" d="M0 0h171v342H0z"/></svg>

After

Width:  |  Height:  |  Size: 220 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#D32011" d="M0 0h513v342H0z"/><path fill="#007A36" d="M342 0h171v342H342z"/><path d="M0 0h171v342H0z"/><path fill="#FFF" d="M256.5 131.3c-17.5 0-31.8 14.3-31.8 31.8v47.7h63.6v-47.7c0-17.7-14.3-31.8-31.8-31.8z"/><path fill="#FFF" d="M256.5 93c-43.1 0-78 34.9-78 78s34.9 77.9 78 77.9 78-34.9 78-78-34.9-77.9-78-77.9zm0 142.4c-35.6 0-64.5-28.9-64.5-64.5s28.9-64.5 64.5-64.5 64.5 29 64.5 64.6c0 35.6-28.9 64.4-64.5 64.4z"/></svg>

After

Width:  |  Height:  |  Size: 499 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path d="M0 0h513v342H0z"/><path fill="#0072c6" d="M88.8 136.5c-2.2 12.9-3.4 26.2-3.4 39.8 0 13.6 1.2 27 3.4 39.8L256 228.3l167.2-12.1c2.2-12.9 3.4-26.2 3.4-39.8s-1.2-27-3.4-39.8"/><path fill="#FFF" d="M423.2 219H88.8c15.8 69.8 84.7 122.3 167.2 122.3S407.4 288.8 423.2 219z"/><path fill="#FFDA44" d="M365.9 136.5H146.1l44.9-21.1-23.9-43.5 48.8 9.4L222 32l34 36.2L290 32l6.1 49.3 48.8-9.4-23.9 43.5z"/><g fill="#ce1126"><path d="M256.5 342 0 0v342z"/><path d="M513 342V0L256 342z"/></g></svg>

After

Width:  |  Height:  |  Size: 553 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#012169" d="M0 0h513v342H0z"/><path fill="#FFF" d="M454.8 265.38c7.94-10.93 13.24-24.27 13.24-40.42V104.89a59.754 59.754 0 0 1-36.02 12.01c-19.64 0-37.07-9.43-48.03-24.01-10.95 14.58-28.39 24.01-48.03 24.01-13.52 0-25.99-4.47-36.02-12v120.06c0 16.16 5.3 29.5 13.24 40.42H454.8z"/><path fill="#9cf" d="M310.23 260.98C332.65 296.96 384 309 384 309s51.35-12.04 73.77-48.02H310.23z"/><g fill="#f90"><path d="M396.66 172.21c.32 1.42 8.72 10.17 17.56 20.15 1.39 1.57-8.74 12.63-6.91 12.59 4.75-.12 19.27-17.26 19.34 6.65.05 15.94-30 27.51-30 27.51h17.47l.65 14.96s4.97-13.34 7.59-16.57c8.1-9.97 20.18-30.07 34.59-27.1 14.41 2.97-13.66-13.92-13.66-13.92s-5.2-15.19-16.63-16.97c-10.06-1.57-14.29-2.51-26.64-7.3-1.68-.65-3.88-2.32-3.36 0z"/><path d="M359.69 198.69c1.07-.99 4.46-12.63 8.69-25.28.67-1.99 15.31 1.27 14.35-.3-2.47-4.06-24.58-8.08-3.9-20.08 13.79-8 38.82 12.27 38.82 12.27l-8.72-15.14 12.64-8.03s-14.05 2.35-18.14 1.69c-12.68-2.04-36.13-2.48-40.75-16.45-4.62-13.97-5.25 18.79-5.25 18.79s-10.56 12.08-6.4 22.88c3.66 9.5 4.96 13.64 6.97 26.73.29 1.79-.06 4.53 1.69 2.92z"/><path d="M395.67 219.87c-1.39-.43-13.16 2.49-26.22 5.18-2.06.42-6.59-13.88-7.47-12.26-2.27 4.17 5.36 25.31-15.41 13.45-13.84-7.91-8.89-39.73-8.89-39.73l-8.71 15.14-13.29-6.9s9.09 10.96 10.57 14.84c4.6 11.99 16 32.49 6.24 43.49-9.76 11.01 18.88-4.9 18.88-4.9s15.76 3.07 23-5.96c6.38-7.94 9.3-11.14 19.61-19.45 1.43-1.12 3.97-2.19 1.69-2.9z"/></g><path fill="#012169" d="M0 0h256.5v171H0z"/><g fill="#FFF"><path d="M256.5 0v30.6l-45.3 25.2h45.3v59.4h-59.2l59.2 32.9V171h-26.7l-73.7-40.9V171h-55.7v-48.7L12.8 171H0v-30.6l45.3-25.2H0V55.8h59.2L0 22.9V0h26.7l73.7 40.9V0h55.7v48.7L243.7 0z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM100.4 55.8 0 0v15.8l72 40z"/></g><g fill="#D80027"><path d="M144.3 0h-32.1v69.5H0v32h112.2V171h32.1v-69.5h112.2v-32H144.3z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM72 115.2l-72 40V171l100.4-55.8zM100.4 55.8 0 0v15.8l72 40zM184.5 55.8l72-40V0L156.1 55.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 90 60"><path fill="#ED2024" d="M0 0h90v60H0z"/><path fill="#212121" d="M32.5 12.2v16l7.1 2.5-5 4.9 2.4 3 5.3-5.5 1.2 2.3-3 4.9 3.6 5.6-1.8 2.6 2.8 3.6 2.7-3.7-1.5-2.6 3-5.8-2.7-4.6 1.2-2.3 5.2 5.5 2.5-2.9-5.2-5.1 7.2-2.6V12.2l-5.2 1.9-.1 3.8-3.3.3v-2.6l1.7-2.3 5.6-2.2-2.3-.4 1.4-1.2.9.4-.8-1.4L54 9l-1-1.1L47.5 9l1.4 1.1-3.8 4.9-3.8-4.8 1.3-1.1-5-1.2L36.2 9l-1.5-.4-.8 1.4.9-.6 1.6 1.1-2.3.5 5.5 2 1.6 2.3v2.9l-3.3-.3v-3.6z"/><g fill="#212121"><path d="M26.2 25h6.4v2.5h-6.4zM26.2 20.9h6.4v2.7h-6.4zM26.2 16.7h6.4v2.6h-6.4zM26.2 12.7h6.4v2.5h-6.4z"/></g><g fill="#212121"><path d="M57.4 25h6.4v2.5h-6.4zM57.4 20.9h6.4v2.7h-6.4zM57.4 16.7h6.4v2.6h-6.4zM57.4 12.7h6.4v2.5h-6.4z"/></g><path fill="#212121" d="M53.4 36.1h6.4V38h-6.4zM30.2 36.1h6.4V38h-6.4z"/></svg>

After

Width:  |  Height:  |  Size: 815 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#0052B4" d="M0 0h513v342H0z"/><path fill="#D80027" d="M0 0h513v114H0z"/><path fill="#FF9811" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 196 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path d="M0 171h513v171H0z"/><path fill="#D80027" d="M0 0h513v171H0z"/><g fill="#FFDA44"><path d="m332.7 135.6 17.5-8.6-7.4-19-18.7 6.2zM287.1 79.6l5.5-18.8-19.1-7.2-8 18zM316.5 102.8l12.2-15.3-14.9-13.9-14.2 13.6zM335.9 171.7l19.5-.8v-21.3l-22.9.2zM323.3 209.8l18.8 5.5 7.3-19-18-8zM294.3 242.2l14.9 12.7 14.4-14.4-13.1-14.7zM247.3 94.5l9.8 19.9 21.9 3.3-15.9 15.5 3.6 21.9-19.6-10.4-19.7 10.2 3.8-21.9-15.8-15.5 22-3.1z"/><path d="m250.8 61.3-2.7 18.3c43.1 3.2 77 39.2 77 83.1 0 46-37.3 82.9-83.4 83.4-29.2.3-51.3-14.8-67-33.7l-13.9 12.3c15 19 40.8 39.9 80.9 39.9 56.3 0 101.9-45.6 101.9-101.9.1-53.2-40.7-96.9-92.8-101.4z"/><path fill="#000" d="m291.9 223.4-11.8 14s51.9 38.9 53.6 40.4c1.7 1.5 5.2 2 9.1-2.7 3.7-4.5 2.8-8.1.9-9.7-2-1.5-51.8-42-51.8-42z"/><path d="M206.1 157.2c-7.7 10.3-7.5 23.1 2.8 30.9 0 0 135.2 101.5 136.9 103 1.7 1.5 5.2 2 9.1-2.7 3.7-4.5 2.8-8.1.9-9.7-1.9-1.5-149.7-121.5-149.7-121.5zM253.8 256.3l7.3 18.2 19.5-6-4.8-19.1zM213.4 252.6l-2 19.5 20.1 3.6 4.5-19.1zM177.7 231.4 167.4 248l16.4 12.1 12.5-15.2z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 744 496"><path fill="#3A7DCE" d="M0 0h744v496H0V0z"/><path fill="#fff" d="m120 125 90 63 54-14 23-81 61-36 92 14 105 55 6 90 34 12v92l-65 115-78 24-72-17 18-30-8-32-10 9-162-25-49-85 20-46-49-62z"/></svg>

After

Width:  |  Height:  |  Size: 257 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h512v342H0z"/><path fill="#338AF3" d="M0 0h512v114H0zM0 228h512v114H0z"/><circle fill="#FFDA44" stroke="#d6ab00" stroke-width="5" cx="256.5" cy="171" r="40"/></svg>

After

Width:  |  Height:  |  Size: 251 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#10338c" d="M0 0h513v342H0z"/><path fill="#D80027" d="M513 33 95.3 171 513 310.76V342L0 171 513 0z"/><path fill="#FFF" d="M513 287.18v24.58L81.72 171 513 30v24.16"/><path fill="#A2001D" d="m476.98 140.21-21.89 10.68-3.18-15.32 31.19-29.77s-9.42-40.65-13.75-44.98l-112.32 55.82-6.84 36.76-31.9 28.59-.4 34.2 34.29-22.76 67.23-2.66-1.51 38.11h22.23l11.9-44.64 31.55-24.61-6.6-19.42z"/><path fill="#EFC100" stroke="#231F20" stroke-miterlimit="10" d="m317.89 238.41-22.24-11.11 22.24-11.11h144.46v22.22z"/></svg>

After

Width:  |  Height:  |  Size: 582 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 114h513v114H0z"/><path fill="#D80027" d="M0 0h513v114H0zM0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 168 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#00008b" d="M0 0h513v342H0z"/><g fill="#FFF"><path d="m188 212.6 11 22.9 24.7-5.7-11 22.8 19.9 15.8-24.8 5.6.1 25.4-19.9-15.9-19.8 15.9.1-25.4-24.8-5.6 19.9-15.8-11.1-22.8 24.8 5.7zM385.9 241.1l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.6v12.2l-9.4-7.6-9.5 7.6.1-12.2-11.8-2.6 9.5-7.5-5.3-10.9 11.8 2.7zM337.3 125.1l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.7v12.1l-9.4-7.6-9.5 7.6.1-12.1-11.9-2.7 9.5-7.5-5.3-10.9L332 136zM385.9 58.9l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.7v12.1l-9.4-7.6-9.5 7.6.1-12.1-11.8-2.7 9.5-7.5-5.3-10.9 11.8 2.7zM428.4 108.6l5.2 10.9 11.8-2.7-5.3 10.9 9.5 7.5-11.8 2.6V150l-9.4-7.6-9.5 7.6v-12.2l-11.8-2.6 9.5-7.5-5.3-10.9 11.8 2.7zM398 166.5l4.1 12.7h13.3l-10.8 7.8 4.2 12.7-10.8-7.9-10.8 7.9 4.1-12.7-10.7-7.8h13.3z"/></g><path fill="#00008b" d="M0 0h256.5v171H0z"/><g fill="#FFF"><path d="M256.5 0v30.6l-45.3 25.2h45.3v59.4h-59.2l59.2 32.9V171h-26.7l-73.7-40.9V171h-55.7v-48.7L12.8 171H0v-30.6l45.3-25.2H0V55.8h59.2L0 22.9V0h26.7l73.7 40.9V0h55.7v48.7L243.7 0z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM100.4 55.8 0 0v15.8l72 40z"/></g><g fill="red"><path d="M144.3 0h-32.1v69.5H0v32h112.2V171h32.1v-69.5h112.2v-32H144.3z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM72 115.2l-72 40V171l100.4-55.8zM100.4 55.8 0 0v15.8l72 40zM184.5 55.8l72-40V0L156.1 55.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 363 242"><path fill="#338AF3" d="M0 0h363v242H0z"/><path fill="#FFF" d="M57 96.9 14.7 78.2 57 59.6l18.6-42.2 18.6 42.2 42.3 18.6-42.3 18.7-18.6 42.2z"/><path fill="#f30028" d="m75.6 40.6 11.5 26.1 26.1 11.5-26.1 11.5-11.5 26.1-11.5-26.1L38 78.2l26.1-11.5z"/><g fill="#FFDA44"><path d="M0 152.2h363v15.7H0zM0 183.6h363v15.7H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 391 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#0052B4" d="M0 0h513v342H0z"/><path fill="#FFDA44" d="M513 210.9H202.2V342h-79.8V210.9H0V131.1h122.4V0h79.8v131.1H513v61.2z"/><path fill="#D80027" d="M513 149.7v42.6H183.7V342H141V192.3H0v-42.6h141V0h42.7v149.7z"/></svg>

After

Width:  |  Height:  |  Size: 294 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#ef3340" d="M0 0h513v342H0z"/><path fill="#00b5e2" d="M0 0h513v114H0z"/><path fill="#509e2f" d="M0 228h513v114H0z"/><g fill="#FFF"><path d="M265.6 212.6c-23 0-41.6-18.6-41.6-41.6s18.6-41.6 41.6-41.6c7.2 0 13.9 1.8 19.8 5-9.2-9-21.9-14.6-35.8-14.6-28.3 0-51.2 22.9-51.2 51.2s22.9 51.2 51.2 51.2c13.9 0 26.6-5.6 35.8-14.6-5.9 3.2-12.6 5-19.8 5z"/><path d="m297.6 142.2 5.5 15.5 14.9-7.1-7.1 14.9 15.5 5.5-15.5 5.5 7.1 14.9-14.9-7.1-5.5 15.5-5.5-15.5-14.9 7.1 7.1-14.9-15.5-5.5 15.5-5.5-7.1-14.9 14.9 7.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 589 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#10338C" d="M0 0h513v342H0z"/><path fill="#F6C500" d="M99.9 0h342v342"/><g fill="#FFF"><path d="M19.9 21.1 40.3 6.2 60.9 21 54 0H26.7z"/><path d="M92.3 25.5 84.4 1.3l-7.8 24.2H51.3l20.5 14.8-7.8 24 20.4-14.8L105 64.3l-7.9-24 20.5-14.9z"/><path d="m136.3 69.2-7.8-24-7.8 24H95.5l20.4 14.9-7.8 24 20.4-14.8 20.6 14.8-7.8-24 20.4-14.9z"/><path d="m179.4 112.3-7.8-24.1-7.8 24.1h-25.3l20.5 14.9-7.8 24 20.4-14.8 20.6 14.8-7.9-24 20.5-15z"/><path d="m222.5 155.3-7.8-24-7.8 24h-25.4l20.6 15-7.8 24 20.4-14.9 20.4 14.9-7.8-24 20.6-15z"/><path d="m265.6 198.4-7.8-24-7.8 24h-25.4l20.6 14.9-7.8 24.1 20.4-14.9 20.4 14.9-7.8-24.2 20.5-14.8z"/><path d="m308.7 241.5-7.9-24-7.8 24h-25.3l20.5 14.8-7.9 24.2 20.6-14.9 20.4 14.8-7.8-24.1 20.5-14.8z"/><path d="m351.7 284.6-7.8-24.2-7.8 24.2h-25.3l20.4 14.8-7.8 24.1 20.5-14.9 20.5 14.9-7.8-24.1 20.4-14.8z"/><path d="m387 303.5-7.9 24.1-25.3.1 19.8 14.3h26.6l19.9-14.4h-25.3z"/></g></svg>

After

Width:  |  Height:  |  Size: 998 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#ffc726" d="M0 0h513v342H0z"/><g fill="#00267f"><path d="M0 0h171v342H0zM342 0h171v342H342z"/></g><path d="m325.74 101.02-31.97 12.4c-.68 1.35-5.79 7.54-8.18 53.06h-17.05v-60.42L256 78.68l-12.54 27v60.8H226.4c-2.39-45.53-7.8-52.48-8.47-53.84l-31.68-11.63c.15.31 15.4 31.34 15.4 78.01v12.54h41.81v71.07h25.08v-71.07h41.81v-12.54c0-24.13 4.17-44.02 7.68-56.46 3.82-13.57 7.7-21.49 7.74-21.57l-.03.03z"/></svg>

After

Width:  |  Height:  |  Size: 481 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 85.333 513 342"><path fill="#2d6e41" d="M0 85.331h513v342H0z"/><circle fill="#F40B32" cx="218.902" cy="256.5" r="115"/></svg>

After

Width:  |  Height:  |  Size: 176 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#fdda25" d="M0 0h513v342H0z"/><path d="M0 0h171v342H0z"/><path fill="#ef3340" d="M342 0h171v342H342z"/></svg>

After

Width:  |  Height:  |  Size: 183 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#3d944f" d="M0 0h513v342H0z"/><path fill="#ef2b2d" d="M0 0h513v171H0z"/><path fill="#FFDA44" d="m256 102.6 16.9 52h54.7l-44.2 32.2 16.8 52-44.2-32.1-44.2 32.1 16.8-52-44.2-32.2h54.7z"/></svg>

After

Width:  |  Height:  |  Size: 265 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#00966e" d="M0 0h513v342H0z"/><path fill="#FFF" d="M0 0h513v114H0z"/><path fill="#d62612" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 193 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#D80027" d="M0 0h513v342H0z"/><path fill="#FFF" d="m222.8 34.3-85.2 34.2 85.2 34.1-85.2 34.1 85.2 34.1-85.2 34.1 85.2 34.1-85.2 34.1 85.2 34.1-85.2 34.8H0V0h137.6z"/></svg>

After

Width:  |  Height:  |  Size: 246 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#D80027" d="M0 0h513v342H0z"/><g fill="#47a644"><path d="M215.9 170.7 0 314.6V26.8zM513 26.8v287.8L297.1 170.7z"/></g><path fill="#0052B4" d="M513 26.8 296.1 170.7 513 314.6V342h-41.1L256 197.4 40.1 342H0v-27.4l215.9-143.9L0 26.8V0h40.1L256 143.9 471.9 0H513z"/><path fill="#FFF" d="M513 26.8 297.1 170.7 513 314.6V342h-40L256 197.4 39 342H0v-27.4l215.9-143.9L0 26.8V0h40.1L256 143.9 471.9 0H513z"/><circle fill="#FFF" cx="251.6" cy="170.7" r="100.2"/><g fill="#D80027" stroke="#47a644" stroke-width="3"><path d="m251.4 103.6 7.4 12.9h14.8l-7.4 12.8 7.4 12.8h-14.8l-7.4 12.9-7.4-12.9h-14.8l7.4-12.8-7.4-12.8H244zM290.2 170.3l7.4 12.9h14.8L305 196l7.4 12.8h-14.8l-7.4 12.9-7.4-12.9h-14.9l7.4-12.8-7.4-12.8h14.9zM213 170.3l7.4 12.9h14.9l-7.4 12.8 7.4 12.8h-14.9l-7.4 12.9-7.4-12.9h-14.8l7.4-12.8-7.4-12.8h14.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 895 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#008751" d="M0 0h513v342H0z"/><path fill="#fcd116" d="M197 0h316v171H197z"/><path fill="#e8112d" d="M197 171h316v171H197z"/></svg>

After

Width:  |  Height:  |  Size: 204 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#E6BC00"><path d="M157.3 199.8s-9 43.9-19.3 40.4c-10.6-3.5-17.5-18.8-17.5-18.8L106 242.6s38.2 8.5 41 5.5c1.2-1.1 8.2-15.1 10.3-26.1v-22.2zm-56.1 60.5s-2.5 20.2-1.1 22.7c1.2 2.3 39.5 11.3 39.5 11.3s-25 16.5-29.7 20.5l-18.1-29.9-.7-24.6h10.1zM112.5 94.6s39.7 41.6 40.4 45.5c.7 3.7-4.2 3.2-4.2 3.2s-44.8-37.5-49.9-41.1c-5.4-3.5 13.7-7.6 13.7-7.6zM359.2 201.3s9 42.4 19.2 38.9c10.6-3.5 17.5-18.8 17.5-18.8l14.5 21.2s-37 8.5-39.8 5.5c-1.2-1.1-9.3-13.8-11.5-24.7l.1-22.1zm56 59s2.5 20.2 1.1 22.7c-1.2 2.3-39.5 11.3-39.5 11.3s25 16.5 29.7 20.5l18.1-29.9.7-24.6h-10.1zM403.9 94.6s-39.6 41.6-40.4 45.5c-.7 3.7 4.2 3.2 4.2 3.2s44.8-37.5 49.9-41.1c5.2-3.5-13.7-7.6-13.7-7.6z"/></g><g fill="#ADADAD"><path d="M99.4 104.7s17-.7 16.6-11.5c-.4-10.6-11.2-14.9-19.1-12.4-8 2.3-23 13.4-13.6 35.1 9.4 21.4 23.4 29.4 26.2 47.8 0 0-5.5 9.9-19.1.4-16.9-11.8-6.2-38.2-10.4-45-4.2-7.1-41-38.9-41-38.9s-30.2 39.4-18 74.9c17 49.2 51.7 32 64.6 36.8 0 0-42.1 62-49.6 67.1 0 0 24.1 8.3 47.8-18.8 0 0 5.8 9.7 7.1 20.2h10.3c4.8-26.2 51.5-64.3 49.9-82.3-1.8-21.3-49.2-49.5-51.7-73.4zM365.1 178c-1.6 18.1 45.1 56.1 49.9 82.3h10.3c1.2-10.4 7.1-20.2 7.1-20.2 23.7 27.1 47.8 18.8 47.8 18.8-7.4-5.1-49.6-67.1-49.6-67.1 12.9-4.8 47.7 12.4 64.6-36.8 12.2-35.6-18-74.9-18-74.9s-36.7 31.8-41 38.9c-4.2 6.7 6.5 33.2-10.4 45-13.7 9.5-19.1-.4-19.1-.4 2.8-18.4 16.8-26.4 26.2-47.8 9.4-21.6-5.7-32.8-13.6-35.1-8-2.5-18.8 1.8-19.1 12.4-.4 10.8 16.6 11.5 16.6 11.5-2.5 24-49.9 52.2-51.7 73.4z"/></g><path fill="#125ECC" d="M157.3 87.1h201.8v145.5c0 16.1-9.7 21.4-21.9 31.8l-45.9 39.4c-18.2 15.6-47.9 15.6-66.1 0l-46-39.4c-12.1-10.4-21.9-15.7-21.9-31.8V87.1z"/><path fill="#FFE600" d="M200.6 147.7c-8 0-14.4-6.4-14.4-14.4s6.4-14.4 14.4-14.4 14.4 6.4 14.4 14.4-6.5 14.4-14.4 14.4zm57.6-3c-8 0-14.4-6.4-14.4-14.4s6.4-14.4 14.4-14.4 14.4 6.4 14.4 14.4-6.4 14.4-14.4 14.4zm57.7 3c-8 0-14.4-6.4-14.4-14.4s6.5-14.4 14.4-14.4 14.4 6.4 14.4 14.4-6.5 14.4-14.4 14.4zM215 274.4c-8 0-14.4-6.5-14.4-14.4 0-8 6.4-14.4 14.4-14.4s14.4 6.4 14.4 14.4-6.5 14.4-14.4 14.4zm43.2 14.5c-8 0-14.4-6.5-14.4-14.4s6.4-14.4 14.4-14.4 14.4 6.5 14.4 14.4-6.4 14.4-14.4 14.4zm43.2-14.5c-8 0-14.4-6.5-14.4-14.4 0-8 6.5-14.4 14.4-14.4s14.4 6.4 14.4 14.4c.1 8-6.4 14.4-14.4 14.4z"/><path fill="#D60537" d="M157.3 175.4h201.8V233H157.3v-57.6z"/><path fill="#EACE00" d="M142.9 43.8s57.6-14.4 115.3-14.4 115.3 14.4 115.3 14.4l-14.4 57.7s-50.5-14.4-100.9-14.4-100.9 14.4-100.9 14.4l-14.4-57.7z"/><path fill="#7A6920" d="M258.2 72.7c-8 0-14.4-6.4-14.4-14.4s6.4-14.4 14.4-14.4 14.4 6.5 14.4 14.4-6.4 14.4-14.4 14.4zm57.7 5c-8 0-14.4-6.4-14.4-14.4s6.5-14.4 14.4-14.4 14.4 6.5 14.4 14.4-6.5 14.4-14.4 14.4zm-115.3 0c-8 0-14.4-6.4-14.4-14.4s6.4-14.4 14.4-14.4S215 55.3 215 63.2s-6.5 14.5-14.4 14.5z"/><circle fill="#FFF" cx="258.2" cy="204.2" r="16.9"/></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#c8102e" d="M0 0h513v342H0z"/><path fill="#fff" stroke="#000" stroke-width="2" d="M303.9 94.94v99.69c0 59.81 79.75 99.69 79.75 99.69s79.75-39.88 79.75-99.69V94.94H303.9z"/><path fill="#2F8F22" d="M436.37 254.44H330.93c23.13 25.08 52.72 39.88 52.72 39.88s29.59-14.8 52.72-39.88z"/><circle fill="#65B5D2" cx="383.65" cy="214.56" r="39.88"/><circle fill="#c8102e" cx="343.77" cy="194.63" r="19.94"/><circle fill="#c8102e" cx="423.52" cy="194.63" r="19.94"/><circle fill="#c8102e" cx="383.65" cy="154.75" r="19.94"/><path fill="#012169" d="M0 0h256.5v171H0z"/><g fill="#FFF"><path d="M256.5 0v30.6l-45.3 25.2h45.3v59.4h-59.2l59.2 32.9V171h-26.7l-73.7-40.9V171h-55.7v-48.7L12.8 171H0v-30.6l45.3-25.2H0V55.8h59.2L0 22.9V0h26.7l73.7 40.9V0h55.7v48.7L243.7 0z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM100.4 55.8 0 0v15.8l72 40z"/></g><g fill="#c8102e"><path d="M144.3 0h-32.1v69.5H0v32h112.2V171h32.1v-69.5h112.2v-32H144.3z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM72 115.2l-72 40V171l100.4-55.8zM100.4 55.8 0 0v15.8l72 40zM184.5 55.8l72-40V0L156.1 55.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFDA44" d="M0 0h513v342H0z"/><path d="m0 193.74 513 127.19V214.26L0 97.08z"/><path fill="#FFF" d="M513 234.26v-86.67L0 20.41v86.67"/><g fill="#D80027" stroke="#231F20" stroke-width="3" stroke-miterlimit="10"><path d="M306.4 134.01a50.28 50.28 0 0 1 6.07 24.02c0 21.55-13.51 39.94-32.52 47.19v-59.51c5.7-1.19 10-6.37 10-12.59 6.9 0 12.5-5.75 12.5-12.85h-.42c6.9 0 12.91-5.75 12.91-12.85h-34.99V76.58l-14.99-27.71-15 27.71v30.84h-34.99c0 7.1 6.01 12.85 12.91 12.85h-.42c0 7.1 5.6 12.85 12.5 12.85 0 6.22 4.29 11.4 9.99 12.59v61.37c-22.09-5.39-38.48-25.3-38.48-49.05 0-8.7 2.2-16.88 6.07-24.02-10.96 11.21-17.72 26.53-17.72 43.44 0 34.32 27.82 62.15 62.15 62.15s62.15-27.83 62.15-62.15c0-16.91-6.77-32.24-17.72-43.44z"/><path d="M198.46 226.81s20.69 27.71 66.5 27.71 66.5-27.71 66.5-27.71l13.47 28.37s-21.03 27.71-79.97 27.71-79.97-27.71-79.97-27.71l13.47-28.37zM129.24 133.18h20.88l24.93 27.26v73.45h-26.28v-68.37zM400 133.18h-20.88l-24.93 27.26v73.45h26.27v-68.37z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#d52b1e" d="M0 0h513v114H0z"/><path fill="#f9e300" d="M0 114h513v114H0z"/><path fill="#007934" d="M0 228h513v114H0z"/></svg>

After

Width:  |  Height:  |  Size: 198 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#012a87" d="M0 342h513V0z"/><path fill="#f9d90f" d="M250.4 0H0v166.9z"/><path fill="#dc171d" d="m140.4 118.3 12.2 21.2h24.5l-12.3 21.1 12.3 21.2h-24.5L140.4 203l-12.2-21.2h-24.5l12.3-21.2-12.3-21.1h24.5z"/><circle fill="none" stroke="#000" stroke-width="9" stroke-miterlimit="10" cx="140.4" cy="160.6" r="57.7"/></svg>

After

Width:  |  Height:  |  Size: 431 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#DC171D" d="M512 342H0V.7h512V342z"/><path fill="#012A87" d="M512 342H0V171.3h512V342z"/><path fill="#FFF" d="M256 342 0 171.3 256 .7l256 170.7L256 342z"/><path fill="#F9D90F" d="m199.2 249.5 21.7-66.8-56.8-41.2h70.5L256 74.7l21.7 66.8h70.2l-56.8 41.2 21.7 66.8-56.8-41.3z"/></svg>

After

Width:  |  Height:  |  Size: 355 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600"><path d="M0 0h900v600H0z" fill="#dc171d"/><path d="M450 168.787 215.691 300 450 431.213 684.309 300z" fill="#fff"/><path d="M281.477 336.852V376h69.961l290.727-52.4V376h-93.668z" fill="#dc171f"/><g fill="#012a87"><path d="M188 288H20V20h418v128zM712 288h168V20H462v128zM188 312H20v268h418V452zM712 312h168v268H462V452z"/></g><path d="m450 199.5 8.068 24.827h26.105l-21.12 15.346 8.067 24.827L450 249.157 428.88 264.5l8.066-24.827-21.119-15.345 26.105-.001z" fill="#f9d90f"/><path d="M281.477 336.852c2.712-8.848 8.614-12.77 16.594-10.995 4.94-18.73 27.739-22.07 36.786-12.857l22.273 22.37c4.837 2.251 12.207 3.095 17.326 3.095 6.33.1 5.621 3.005 10.91 3.106 14.096.27 51.143.458 57.857 0 2.75-.187 64.134-67.857 64.134-67.857 5.12-6.31 18.453-8.333 25.714-3.214 6.786 6.429 13.929 7.143 18.929 2.857 4.405-6.547 14.392-7.309 20.714-3.571l69.45 53.815L548.497 376H351.438l-69.96-39.148z" fill="#008830"/></svg>

After

Width:  |  Height:  |  Size: 971 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#009b3a" d="M0 0h513v342H0z"/><path fill="#fedf00" d="m256.5 19.3 204.9 151.4L256.5 322 50.6 170.7z"/><circle fill="#FFF" cx="256.5" cy="171" r="80.4"/><path fill="#002776" d="M215.9 165.7c-13.9 0-27.4 2.1-40.1 6 .6 43.9 36.3 79.3 80.3 79.3 27.2 0 51.3-13.6 65.8-34.3-24.9-31-63.2-51-106-51zM334.9 186c.9-5 1.5-10.1 1.5-15.4 0-44.4-36-80.4-80.4-80.4-33.1 0-61.5 20.1-73.9 48.6 10.9-2.2 22.1-3.4 33.6-3.4 46.8.1 89 19.5 119.2 50.6z"/></svg>

After

Width:  |  Height:  |  Size: 513 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#ffc72c" d="M0 0h513v342H0z"/><g fill="#00778b"><path d="M0 0h513v114H0zM0 228h513v114H0z"/></g><path d="M256 171 0 342V0z"/></svg>

After

Width:  |  Height:  |  Size: 205 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FF7415" d="M0 0h513v342H0z"/><path fill="#FFDA44" d="M513 0H0v342"/><path fill="none" stroke="#FFF" stroke-width="42" stroke-miterlimit="10" d="M128.7 255.5s35 54 67.3 32.4c56.9-37.9-68.9-108.6-2.9-152.6 58.3-38.8 76.6 103.5 137.6 62.8 59-39.3-64.7-111.4-9.2-148.4 33.4-22.2 67.1 32.6 67.1 32.6"/></svg>

After

Width:  |  Height:  |  Size: 378 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#EF2B2D" d="M0 0h513v342H0z"/><path fill="#FFF" class="st1" d="M139.9 0h93.3v342h-93.3z"/><path fill="#FFF" class="st1" d="M0 119h513v104.1H0z"/><path fill="#002868" d="M163.2 0h46.6v342h-46.6z"/><path fill="#002868" d="M0 145h513v52H0z"/></svg>

After

Width:  |  Height:  |  Size: 319 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="#6da9d2"><path d="M0 238h513v104H0zM0 0h513v104H0z"/></g><path d="M0 125.5h513v89.656H0z"/></svg>

After

Width:  |  Height:  |  Size: 207 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#007C30" d="M0 0h513v342H0z"/><path fill="#CE1720" d="M0 230h513V0H0"/><path fill="#FFF" d="M100 230V0H0v342h513-413z"/><g fill="#CE1720"><path d="M28 159.6 8.6 128.2 28 97.4l19.3 30.8zM72.6 159.6l-19.3-31.4 19.3-30.8L92 128.2zM28 241.2 8.6 209.8 28 179l19.3 30.8zM72.6 241.2l-19.3-31.4L72.6 179 92 209.8z"/></g><g fill="none" stroke="#CE1720" stroke-width="7"><path d="m28 73.8-16.1-26L28 22.2l16 25.6zM72.6 73.8l-16-26 16-25.6 16.1 25.6zM28 318l-16.1-26.1L28 266.3l16 25.6zM72.6 318l-16-26.1 16-25.6 16.1 25.6z"/></g></svg>

After

Width:  |  Height:  |  Size: 599 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#003e87" d="M0 0h513v342H0z"/><circle fill="#FFF" cx="260.9" cy="170.9" r="118.9"/><circle fill="none" stroke="#6DA544" stroke-width="18" stroke-miterlimit="10" cx="261.9" cy="173.1" r="94.5"/><g stroke="#000"><path fill="#003e87" d="m261.9 151.5-50.6 23.4v20c0 11.8 6.1 22.8 16.2 28.9L262 239l34.5-15.2c10-6.2 16.2-17.1 16.2-28.9v-20l-50.8-23.4z"/><path fill="#FFDA44" d="M211.3 128.1h101.3v46.7H211.3z"/></g><g fill="#ce1127"><path d="M0 0h513v35H0zM0 306h513v35H0z"/></g></svg>

After

Width:  |  Height:  |  Size: 554 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><g fill="red"><path d="M0 0h142v342H0zM371 0h142v342H371zM306.5 206l50.4-25.2-25.2-12.6V143l-50.4 25.2 25.2-50.4h-25.2L256.1 80l-25.2 37.8h-25.2l25.2 50.4-50.4-25.2v25.2l-25.2 12.6 50.4 25.2-12.6 25.2h50.4V269h25.2v-37.8h50.4z"/></g></svg>

After

Width:  |  Height:  |  Size: 340 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="green" d="M0 0h513v342H0z"/><g fill="#FFDA44"><path d="m422.7 252.4 6.2 12.8 13.8-3.2-6.2 12.8 11.2 8.8-13.9 3.2V301l-11.1-8.9-11.1 8.9v-14.2l-13.8-3.2 11.1-8.8-6.2-12.8 13.9 3.2zM376.1 116.1l6.2 12.9 13.8-3.2-6.2 12.8 11.2 8.8-13.9 3.1v14.3l-11.1-8.9-11.1 8.9.1-14.3-13.9-3.1 11.1-8.8-6.2-12.8L370 129zM413.1 38.3l6.2 12.8 13.8-3.2-6.2 12.8 11.2 8.9-13.9 3.1v14.2L413.1 78 402 86.9l.1-14.2-13.9-3.1 11.1-8.9-6.2-12.8 13.9 3.2zM464.9 96.7l6.2 12.8 13.9-3.2-6.3 12.8 11.2 8.9-13.9 3.1.1 14.2-11.2-8.9-11.1 8.9.1-14.2L440 128l11.2-8.9-6.3-12.8 13.9 3.2zM436.9 164.8l4.9 14.8h15.6l-12.6 9.2 4.8 14.9-12.7-9.2-12.6 9.2 4.8-14.9-12.6-9.2h15.6z"/></g><path fill="#FFDA44" d="M306.8 254.7c-49.2 0-89.1-39.9-89.1-89.1s39.9-89.1 89.1-89.1c15.3 0 29.8 3.9 42.4 10.7C329.4 67.9 302.3 56 272.5 56c-60.5 0-109.6 49.1-109.6 109.6S212 275.3 272.5 275.3c29.8 0 56.9-11.9 76.6-31.3-12.6 6.8-27 10.7-42.3 10.7zM140.4 59.5C129.7 41 109.7 28.6 86.8 28.6S44 41 33.3 59.5h107.1zM140.6 59.9l-53.8 53.8-53.7-53.8C28 68.9 25 79.3 25 90.5c0 34.2 27.7 61.9 61.9 61.9s61.9-27.7 61.9-61.9c-.1-11.2-3.1-21.6-8.2-30.6z"/><path fill="#A2001D" d="M71.4 98.2v52.2c4.9 1.3 10.1 1.9 15.5 1.9s10.5-.7 15.5-1.9V98.2h-31z"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#338AF3" d="M0 0h513v342H0z"/><path fill="#FFDA44" d="M513 66.9V0H411.7L0 274.4V342h100.3z"/><path fill="#D80027" d="M513 0v40.1L60.2 342H0v-40.8L451.8 0z"/><path fill="#FFDA44" d="m93.6 31.2 16.3 50.4H163l-42.9 31.2 16.4 50.5-42.9-31.2-43 31.2L67 112.8 24.1 81.6h53.1z"/></svg>

After

Width:  |  Height:  |  Size: 352 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#d21034" d="M0 0h513v342H0z"/><g fill="#3a9927"><path d="M0 171h211.5v85.3H0zM300.5 171H512v85.3H300.5z"/></g><path fill="#003082" d="M0 0v85.3h211.5V0h89v85.3H513V0z"/><g fill="#FFF"><path d="M300.5 85.3H512v85.3H300.5zM0 85.3h211.5v85.3H0z"/></g><g fill="#ffce00"><path d="M300.5 342h-89v-86H0v86h513v-86H300.5zM105.7 8.6l8.6 26.2h27.5L119.5 51l8.6 26.2L105.7 61 83.4 77.2 91.9 51 69.6 34.8h27.6z"/></g></svg>

After

Width:  |  Height:  |  Size: 485 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 600"><path fill="#009739" d="M0 0h900v600H0z"/><path d="M0 600 600 0h300v600z" fill="#ffd100"/><path d="M900 0v600H300z" fill="#dc241f"/></svg>

After

Width:  |  Height:  |  Size: 200 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 768 512"><path fill="red" d="M0 0h768v512H0V0z"/><path fill="#FFF" d="M336 96h96v112h112v96H432v112h-96V304H224v-96h112V96z"/></svg>

After

Width:  |  Height:  |  Size: 185 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#FFF" d="M0 0h513v342H0z"/><path fill="#009e60" d="M342 0h171v342H342z"/><path fill="#f77f00" d="M0 0h171v342H0z"/></svg>

After

Width:  |  Height:  |  Size: 195 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 513 342"><path fill="#012169" d="M0 0h513v342H0z"/><g fill="#FFF"><path d="m384 71.4 5.3 16.2h17.1l-13.9 10.1 5.3 16.2-13.8-10-13.8 10 5.3-16.2-13.9-10.1h17.1zM313.8 100.5l15.2 7.7 12.1-12.1-2.7 16.9 15.3 7.8-16.9 2.6-2.7 16.9-7.8-15.2-16.8 2.7 12.1-12.1zM284.7 170.7l16.3-5.3v-17.1l10 13.8 16.3-5.3-10.1 13.9 10.1 13.8-16.3-5.3-10 13.8v-17.1zM313.8 240.9l7.7-15.3-12-12 16.8 2.6 7.8-15.2 2.7 16.9 16.9 2.7-15.3 7.7 2.7 16.9-12.1-12.1zM384 270l-5.3-16.3h-17.1l13.9-10-5.3-16.3 13.8 10 13.8-10-5.3 16.3 13.9 10h-17.1zM454.2 240.9l-15.2-7.8-12.1 12.1 2.7-16.9-15.3-7.7 16.9-2.7 2.7-16.9 7.8 15.2 16.8-2.6-12.1 12zM483.3 170.7l-16.3 5.2V193l-10-13.8-16.3 5.3 10.1-13.8-10.1-13.9 16.3 5.3 10-13.8v17.1zM454.2 100.5l-7.7 15.2 12 12.1-16.8-2.7-7.8 15.2-2.7-16.9-16.9-2.6 15.3-7.8-2.7-16.9 12.1 12.1z"/></g><path fill="#012169" d="M0 0h256.5v171H0z"/><g fill="#FFF"><path d="M256.5 0v30.6l-45.3 25.2h45.3v59.4h-59.2l59.2 32.9V171h-26.7l-73.7-40.9V171h-55.7v-48.7L12.8 171H0v-30.6l45.3-25.2H0V55.8h59.2L0 22.9V0h26.7l73.7 40.9V0h55.7v48.7L243.7 0z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM100.4 55.8 0 0v15.8l72 40z"/></g><g fill="#D80027"><path d="M144.3 0h-32.1v69.5H0v32h112.2V171h32.1v-69.5h112.2v-32H144.3z"/><path d="M156.1 115.2 256.5 171v-15.8l-72-40zM72 115.2l-72 40V171l100.4-55.8zM100.4 55.8 0 0v15.8l72 40zM184.5 55.8l72-40V0L156.1 55.8z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More