merge feat/probe-b6: item 7 B1-B6 (probe collector, history, contract API, ping, themes, notify)
# Conflicts: # internal/agent/agent.go
@@ -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/
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, ¬ify, &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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
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 |