/* 流量统计 v2 — collection core */ const PATH = 'data/third/traffic-statistics-vibe' const TAGS_FILE = PATH + '/tags.json' const DataVersion = '-v2' const LegacyVersion = '-v1' const SCHEMA = 2 const LIMITS = { big: 3000, mid: 500, pivot: 200, top: 20 } const DIMS = [ 'domains', 'roots', 'nodes', 'processes', 'process_names', 'rules', 'rule_payloads', 'groups', 'chains', 'ports', 'networks', 'conn_types', 'inbounds', 'dest_ips', 'src_ports_kind', 'tags' ] const PIVOTS = ['pivot_node_domain', 'pivot_node_process', 'pivot_domain_node', 'pivot_process_domain', 'pivot_tag_node'] window[Plugin.id] = window[Plugin.id] || { state: {} } const store = window[Plugin.id].state Object.assign(store, { currentMonth: store.currentMonth || '', data: store.data || null, tagsConfig: store.tagsConfig || {}, lastConnections: store.lastConnections || {}, lastTotals: store.lastTotals || null, unregs: store.unregs || [], realtime: store.realtime || [], autosaveTimer: store.autosaveTimer || null, nodeMetaAt: store.nodeMetaAt || 0 }) const pad2 = (n) => String(n).padStart(2, '0') const monthKey = (d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}` const getRootDomain = (host) => { if (!host) return host let hostname = host .split(':')[0] .replace(/[\[\]]/g, '') .replace(/\.$/, '') if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname) || !hostname.includes('.')) return hostname const parts = hostname.split('.') if (parts.length <= 2) return hostname const secondToLast = parts[parts.length - 2].toLowerCase() const last = parts[parts.length - 1].toLowerCase() if (last.length === 2 && ['com', 'co', 'net', 'org', 'gov', 'edu', 'ac'].includes(secondToLast)) return parts.slice(-3).join('.') return parts.slice(-2).join('.') } const createHourly = () => Array.from({ length: 24 }, () => ({ up: 0, down: 0, hits: 0, peakUp: 0, peakDown: 0 })) const createEmptyStats = () => { const details = {} DIMS.forEach((k) => (details[k] = {})) PIVOTS.forEach((k) => (details[k] = {})) Object.assign(details, { log_levels: {}, dns_types: {}, dns_domains: {}, dns_ip_kinds: { 'fake-ip': 0, 'real-ip': 0 }, failed_hosts: {}, clients: {} }) return { summary: { up: 0, down: 0 }, details, hourly: createHourly(), conn: { count: 0, closed: 0, durTotal: 0, durMax: 0, rejected: 0, direct: { up: 0, down: 0, hits: 0 }, proxy: { up: 0, down: 0, hits: 0 } }, peak: { up: 0, down: 0, upAt: 0, downAt: 0 }, memory: { peak: 0, peakAt: 0 }, top_conns: [], long_conns: [] } } const createMonth = () => ({ version: SCHEMA, ...createEmptyStats(), node_meta: {}, residual: { up: 0, down: 0 }, daily: {} }) // fill missing fields of v1/partial data with defaults (non-destructive) const fillDefaults = (obj, def) => { for (const k in def) { if (obj[k] === undefined || obj[k] === null) obj[k] = def[k] else if (def[k] && typeof def[k] === 'object' && !Array.isArray(def[k]) && typeof obj[k] === 'object') fillDefaults(obj[k], def[k]) } return obj } const LEGACY = '历史(v1)' // v1 stats lack the v2 dimensions: put their totals into a "历史(v1)" bucket so every view sums to the same total const backfillV1 = (t) => { const d = t.details const up = t.summary.up || 0 const down = t.summary.down || 0 if (!up && !down) return const sumHits = (map) => Object.values(map || {}).reduce((s, v) => s + ((v && v.hits) || 0), 0) const hits = sumHits(d.rules) ;['process_names', 'rule_payloads', 'groups', 'chains', 'ports', 'networks', 'conn_types', 'inbounds', 'dest_ips'].forEach((k) => { d[k][LEGACY] = { up, down, hits } }) const direct = d.nodes?.DIRECT || { up: 0, down: 0 } t.conn.direct = { up: direct.up || 0, down: direct.down || 0, hits: 0 } t.conn.proxy = { up: Math.max(0, up - (direct.up || 0)), down: Math.max(0, down - (direct.down || 0)), hits: 0 } t.conn.count = hits t.legacy = { up, down } } const migrate = (raw) => { const isV1 = !raw || raw.version !== SCHEMA const m = raw || {} const hadConn = !!m.conn fillDefaults(m, createMonth()) if (!Array.isArray(m.hourly) || m.hourly.length !== 24) m.hourly = createHourly() if (isV1 && !hadConn) backfillV1(m) for (const k in m.daily) { const day = m.daily[k] || (m.daily[k] = {}) const dayHadConn = !!day.conn fillDefaults(day, createEmptyStats()) if (!Array.isArray(day.hourly) || day.hourly.length !== 24) day.hourly = createHourly() for (const ip in day.details.clients) fillDefaults(day.details.clients[ip], { up: 0, down: 0, hits: 0, domains: {}, nodes: {}, processes: {}, rules: {}, tags: {} }) if (isV1 && !dayHadConn) backfillV1(day) } for (const ip in m.details.clients) fillDefaults(m.details.clients[ip], { up: 0, down: 0, hits: 0, domains: {}, nodes: {}, processes: {}, rules: {}, tags: {} }) if (isV1 && !m.v2Since) m.v2Since = Date.now() m.version = SCHEMA return m } const readMonth = async (month) => { try { return migrate(JSON.parse(await Plugins.ReadFile(`${PATH}/${month}${DataVersion}.json`))) } catch (e) {} try { return migrate(JSON.parse(await Plugins.ReadFile(`${PATH}/${month}${LegacyVersion}.json`))) } catch (e) {} return null } const initMonthlyData = async (now = new Date()) => { const month = monthKey(now) store.data = (await readMonth(month)) || createMonth() store.currentMonth = month } const ensureData = async () => { if (!store.data || !store.currentMonth) await initMonthlyData() else if (store.data.version !== SCHEMA) store.data = migrate(store.data) // live v1 state after plugin reload } // merge long tail of a map into "其他" const capMap = (map, max) => { if (!map || typeof map !== 'object') return const keys = Object.keys(map) if (keys.length <= max) return const score = (v) => (typeof v === 'number' ? v : (v.up || 0) + (v.down || 0) + (v.hits || 0)) keys.sort((a, b) => score(map[b]) - score(map[a])) const other = map['其他'] && typeof map['其他'] === 'object' ? map['其他'] : { up: 0, down: 0, hits: 0 } for (const k of keys.slice(max)) { if (k === '其他') continue const v = map[k] if (typeof v === 'object') { other.up = (other.up || 0) + (v.up || 0) other.down = (other.down || 0) + (v.down || 0) other.hits = (other.hits || 0) + (v.hits || 0) } delete map[k] } if (typeof map[keys[0]] === 'object') map['其他'] = other } const pruneStats = (s) => { const d = s && s.details if (!d) return ;['domains', 'dest_ips', 'dns_domains', 'failed_hosts'].forEach((k) => capMap(d[k], LIMITS.big)) ;['roots', 'processes', 'process_names', 'rule_payloads', 'chains', 'ports', 'rules'].forEach((k) => capMap(d[k], LIMITS.mid)) PIVOTS.forEach((p) => { capMap(d[p], LIMITS.mid) for (const k in d[p] || {}) capMap(d[p][k], LIMITS.pivot) }) for (const ip in d.clients || {}) ['domains', 'nodes', 'processes', 'rules', 'tags'].forEach((k) => d.clients[ip][k] && capMap(d.clients[ip][k], LIMITS.pivot)) } const pruneAll = () => { if (!store.data) return pruneStats(store.data) for (const d in store.data.daily) pruneStats(store.data.daily[d]) } const saveMonthlyData = async () => { if (!store.data || !/^\d{4}-\d{2}$/.test(store.currentMonth)) return false // guards the old "-v1.json" bug if (store.data.version !== SCHEMA) store.data = migrate(store.data) pruneAll() await Plugins.WriteFile(`${PATH}/${store.currentMonth}${DataVersion}.json`, JSON.stringify(store.data)) return true } const bump = (map, key, up, down, isNew) => { if (key === undefined || key === null || key === '') return const e = map[key] || (map[key] = { up: 0, down: 0, hits: 0 }) e.up += up e.down += down if (isNew) e.hits = (e.hits || 0) + 1 } const bumpPivot = (pivot, a, b, up, down, isNew) => { if (!a || !b) return bump(pivot[a] || (pivot[a] = {}), b, up, down, isNew) } const updateStats = (target, diffUp, diffDown, isNew, info) => { if (!target) return target.summary.up += diffUp target.summary.down += diffDown const d = target.details const pairs = [ ['domains', info.fqdn], ['roots', info.root], ['nodes', info.node], ['processes', info.process], ['process_names', info.processName], ['rules', info.rule], ['rule_payloads', info.rulePayload], ['groups', info.group], ['chains', info.chain], ['ports', info.port], ['networks', info.network], ['conn_types', info.type], ['inbounds', info.inbound], ['dest_ips', info.destIP], ['src_ports_kind', info.portKind] ] pairs.forEach(([k, v]) => bump(d[k] || (d[k] = {}), v, diffUp, diffDown, isNew)) ;(info.tags || []).forEach((tag) => { bump(d.tags, tag, diffUp, diffDown, isNew) bumpPivot(d.pivot_tag_node, tag, info.node, diffUp, diffDown, isNew) }) bumpPivot(d.pivot_node_domain, info.node, info.fqdn, diffUp, diffDown, isNew) bumpPivot(d.pivot_node_process, info.node, info.process, diffUp, diffDown, isNew) bumpPivot(d.pivot_domain_node, info.root, info.node, diffUp, diffDown, isNew) bumpPivot(d.pivot_process_domain, info.process, info.root, diffUp, diffDown, isNew) if (info.clientIP) { const c = d.clients[info.clientIP] || (d.clients[info.clientIP] = { up: 0, down: 0, hits: 0, domains: {}, nodes: {}, processes: {}, rules: {}, tags: {} }) c.up += diffUp c.down += diffDown if (isNew) c.hits++ bump(c.domains, info.fqdn, diffUp, diffDown, isNew) bump(c.nodes, info.node, diffUp, diffDown, isNew) bump(c.processes, info.process, diffUp, diffDown, isNew) bump(c.rules, info.rule, diffUp, diffDown, isNew) ;(info.tags || []).forEach((t) => bump(c.tags, t, diffUp, diffDown, isNew)) } if (target.hourly && info.hour !== undefined) { const h = target.hourly[info.hour] h.up += diffUp h.down += diffDown if (isNew) h.hits++ } if (target.conn) { const bucket = info.isDirect ? target.conn.direct : target.conn.proxy bucket.up += diffUp bucket.down += diffDown if (isNew) { bucket.hits++ target.conn.count++ if (info.isReject) target.conn.rejected++ } } } // connection closed: durations + top lists const recordClosed = (target, rec, endTs) => { if (!target?.conn) return const dur = Math.max(0, (endTs - rec.start) / 1000) target.conn.closed++ target.conn.durTotal += dur if (dur > target.conn.durMax) target.conn.durMax = dur const item = { host: rec.info.fqdn, node: rec.info.node, chain: rec.info.chain, process: rec.info.processName || rec.info.process, rule: rec.info.rulePayload, network: rec.info.network, client: rec.info.clientIP, up: rec.up, down: rec.down, start: rec.start, duration: Math.round(dur) } const push = (list, key) => { list.push(item) list.sort((a, b) => key(b) - key(a)) if (list.length > LIMITS.top) list.length = LIMITS.top } push(target.top_conns, (x) => x.up + x.down) push(target.long_conns, (x) => x.duration) } // sing-box reports `rule` as " => ", e.g. "rule_set=GeoLocation-!CN => route(Proxy)", // "final => route(Proxy)", "protocol=dns => hijack-dns", "domain=x => reject"; rulePayload is empty. // mihomo reports `rule` = "RuleSet" and `rulePayload` = "GeoLocation". Normalize both to { rule, payload, action }. const parseRule = (conn) => { const raw = String(conn.rule || '') const i = raw.indexOf(' => ') if (i < 0) return { rule: raw || 'Match', payload: conn.rulePayload || '', action: '' } const matcher = raw.slice(0, i).trim() const action = raw.slice(i + 4).trim() const eq = matcher.indexOf('=') return eq > 0 ? { rule: matcher.slice(0, eq), payload: matcher.slice(eq + 1), action } : { rule: matcher || 'final', payload: '', action } } const buildInfo = (conn, now) => { const m = conn.metadata || {} const r = parseRule(conn) const actionKind = (r.action.match(/^[a-z-]+/i) || [''])[0].toLowerCase() // sing-box rule actions (reject / hijack-dns ...) produce connections without an outbound chain const fallback = actionKind === 'reject' ? 'REJECT' : actionKind && actionKind !== 'route' ? actionKind.toUpperCase() : 'DIRECT' const chains = Array.isArray(conn.chains) && conn.chains.length ? conn.chains : [fallback] const host = m.host || m.sniffHost || m.destinationIP || 'unknown' const root = getRootDomain(host) let clientIP = m.sourceIP || 'unknown' if (clientIP.startsWith('fdfe') || clientIP.startsWith('172.18.') || clientIP === '::1') clientIP = '127.0.0.1' const node = chains[0] const processPath = m.processPath || m.process || 'system' // mihomo: type "HTTP" + inboundName; sing-box: type "/" (e.g. "mixed/mixed-in") const rawType = m.type || 'unknown' const slash = rawType.indexOf('/') const type = slash > 0 ? rawType.slice(0, slash) : rawType const inboundTag = m.inboundName || (slash > 0 ? rawType.slice(slash + 1) : '') const meta = store.data?.node_meta?.[node] return { node, group: chains[chains.length - 1], chain: chains.slice().reverse().join(' → '), fqdn: host, root, process: processPath, processName: m.process || processPath.split(/[\\/]/).pop() || 'system', rule: r.rule, rulePayload: r.payload ? `${r.rule}(${r.payload})` : r.rule, tags: store.tagsConfig[root] || store.tagsConfig[host] || [], clientIP, port: m.destinationPort ? String(m.destinationPort) : '', network: (m.network || 'unknown').toUpperCase(), type, inbound: inboundTag || m.inboundUser || (m.inboundPort ? `${type}:${m.inboundPort}` : type), destIP: m.remoteDestination || m.destinationIP || '', portKind: m.dnsMode ? `dns:${m.dnsMode}` : '', // sing-box direct outbounds can have any tag ("direct", "🎯 Direct" ...): also trust the outbound type isDirect: node === 'DIRECT' || /^direct$/i.test(node) || meta?.type === 'Direct', isReject: chains.some((c) => /^REJECT/i.test(c)) || actionKind === 'reject' || meta?.type === 'Reject' || meta?.type === 'Block', hour: now.getHours() } } const dayBucket = (now) => { const day = String(now.getDate()) if (!store.data.daily[day]) store.data.daily[day] = createEmptyStats() return store.data.daily[day] } const checkRollover = async (now) => { const month = monthKey(now) if (month !== store.currentMonth) { if (store.currentMonth) await saveMonthlyData() store.data = (await readMonth(month)) || createMonth() store.currentMonth = month } } const handleConnections = async (data, nowArg) => { await ensureData() const now = nowArg ? new Date(nowArg) : new Date() await checkRollover(now) const dayData = dayBucket(now) const connections = data.connections || [] const totals = { up: Number(data.uploadTotal) || 0, down: Number(data.downloadTotal) || 0, has: data.downloadTotal !== undefined } // core restart / counter reset: totals went backwards → drop baselines if (store.lastTotals && totals.has && (totals.down < store.lastTotals.down || totals.up < store.lastTotals.up)) { store.lastConnections = {} store.lastTotals = null } const seen = new Set() let attributedUp = 0 let attributedDown = 0 for (const conn of connections) { if (!conn || !conn.id || seen.has(conn.id)) continue seen.add(conn.id) const prev = store.lastConnections[conn.id] const isNew = !prev || !prev.info let pDown = prev ? prev.download || 0 : 0 let pUp = prev ? prev.upload || 0 : 0 if ((conn.download || 0) < pDown || (conn.upload || 0) < pUp) pDown = pUp = 0 // per-conn reset let diffDown = Math.max(0, (conn.download || 0) - pDown) let diffUp = Math.max(0, (conn.upload || 0) - pUp) const info = !isNew ? prev.info : buildInfo(conn, now) info.hour = now.getHours() if (info.isReject) diffUp = diffDown = 0 // REJECT / REJECT-DROP: no payload is relayed if (isNew || diffDown > 0 || diffUp > 0) { updateStats(store.data, diffUp, diffDown, isNew, info) updateStats(dayData, diffUp, diffDown, isNew, info) } attributedUp += diffUp attributedDown += diffDown const start = prev && prev.start ? prev.start : Date.parse(conn.start) || now.getTime() store.lastConnections[conn.id] = { download: conn.download || 0, upload: conn.upload || 0, info, start, lastDiff: diffUp + diffDown, up: (isNew ? 0 : prev.up || 0) + diffUp, down: (isNew ? 0 : prev.down || 0) + diffDown } } // closed connections const closed = [] for (const id in store.lastConnections) { if (!seen.has(id)) { if (store.lastConnections[id].info) closed.push(store.lastConnections[id]) else delete store.lastConnections[id] delete store.lastConnections[id] } } // bytes moved by connections that closed between snapshots: totals delta − attributed if (store.lastTotals && totals.has) { const resUp = totals.up - store.lastTotals.up - attributedUp const resDown = totals.down - store.lastTotals.down - attributedDown if (resUp > 0 || resDown > 0) { const payers = closed.filter((x) => !x.info.isReject) if (payers.length) { const closed = payers const raw = closed.reduce((s, c) => s + (c.lastDiff || 0), 0) const weight = (c) => (raw ? (c.lastDiff || 0) / raw : 1 / closed.length) // by last observed speed let leftUp = Math.max(0, resUp) let leftDown = Math.max(0, resDown) closed.forEach((c, i) => { const w = weight(c) const u = i === closed.length - 1 ? leftUp : Math.round(Math.max(0, resUp) * w) const dn = i === closed.length - 1 ? leftDown : Math.round(Math.max(0, resDown) * w) leftUp -= u leftDown -= dn c.info.hour = now.getHours() updateStats(store.data, u, dn, false, c.info) updateStats(dayData, u, dn, false, c.info) c.up += u c.down += dn }) } else { store.data.residual.up += Math.max(0, resUp) store.data.residual.down += Math.max(0, resDown) } } } closed.forEach((c) => { recordClosed(store.data, c, now.getTime()) recordClosed(dayData, c, now.getTime()) }) if (totals.has) store.lastTotals = { up: totals.up, down: totals.down } } const handleTraffic = (data, nowArg) => { const now = nowArg ? new Date(nowArg) : new Date() const up = Number(data.up) || 0 const down = Number(data.down) || 0 store.realtime.push({ t: now.getTime(), up, down }) if (store.realtime.length > 180) store.realtime.splice(0, store.realtime.length - 180) if (!store.data || !store.data.peak || monthKey(now) !== store.currentMonth) return const dayData = dayBucket(now) for (const t of [store.data, dayData]) { if (up > t.peak.up) ((t.peak.up = up), (t.peak.upAt = now.getTime())) if (down > t.peak.down) ((t.peak.down = down), (t.peak.downAt = now.getTime())) const h = t.hourly[now.getHours()] if (up > h.peakUp) h.peakUp = up if (down > h.peakDown) h.peakDown = down } } const handleMemory = (data, nowArg) => { if (!store.data || !store.data.memory) return const now = nowArg ? new Date(nowArg) : new Date() const inuse = Number(data.inuse) || 0 if (inuse > store.data.memory.peak) store.data.memory = { peak: inuse, peakAt: now.getTime() } } // node protocol type + latest delay from kernel /proxies const refreshNodeMeta = () => { try { const proxies = Plugins.useKernelApiStore().proxies || {} const list = Array.isArray(proxies) ? proxies : Object.values(proxies) for (const p of list) { if (!p?.name) continue const hist = Array.isArray(p.history) ? p.history : [] const last = hist[hist.length - 1] store.data.node_meta[p.name] = { type: p.type, udp: !!p.udp, delay: last ? last.delay : (store.data.node_meta[p.name]?.delay ?? null), group: Array.isArray(p.all) } } } catch (e) {} } const handleLogs = async (data) => { if (!store.data || store.data.version !== SCHEMA) return const now = new Date() const dayData = dayBucket(now) const type = data.type || 'unknown' const payload = data.payload || '' for (const t of [store.data, dayData]) t.details.log_levels[type] = (t.details.log_levels[type] || 0) + 1 let dnsInfo = null const dnsMatch = payload.match(/\[DNS\](?:\s+cache\s+hit)?\s+([^\s]+)\s+-->\s+\[(.*?)\]\s+([A-Z0-9]+)/i) if (dnsMatch) { dnsInfo = { domain: dnsMatch[1].replace(/\.$/, ''), result: dnsMatch[2].trim().split(/\s+/)[0] || '', dnsType: dnsMatch[3].toUpperCase() } } else if (payload.includes('dns: exchanged')) { const lm = payload.match(/dns: exchanged\s+([A-Z0-9]+)\s+([^\s]+)\s+\d+\s+IN\s+[A-Z0-9]+\s+([^\s]+)/i) if (lm) dnsInfo = { dnsType: lm[1].toUpperCase(), domain: lm[2].replace(/\.$/, ''), result: lm[3] } } if (dnsInfo) { const { dnsType, domain, result } = dnsInfo for (const t of [store.data, dayData]) { const d = t.details d.dns_types[dnsType] = (d.dns_types[dnsType] || 0) + 1 const e = d.dns_domains[domain] || (d.dns_domains[domain] = { hits: 0, types: {} }) e.hits++ e.types[dnsType] = (e.types[dnsType] || 0) + 1 if ((dnsType === 'A' || dnsType === 'AAAA') && result) { const r = result.toLowerCase() const kind = r.startsWith('198.18.') || r.startsWith('fc00') || r.startsWith('fd00') ? 'fake-ip' : 'real-ip' d.dns_ip_kinds[kind] = (d.dns_ip_kinds[kind] || 0) + 1 } } } // failed dials: "... --> host:port error: ..." / "dial ... failed" if (type === 'warning' || type === 'error') { const fm = payload.match(/-->\s+([^\s]+?)(?::\d+)?\s+(?:error|match)/i) || payload.match(/dial\s+[^\s]+\s+.*?([a-z0-9.-]+\.[a-z]{2,})/i) if (fm && /error|fail|timeout|refused/i.test(payload)) { for (const t of [store.data, dayData]) { const e = t.details.failed_hosts[fm[1]] || (t.details.failed_hosts[fm[1]] = { hits: 0, last: 0 }) e.hits++ e.last = now.getTime() } } } } const autosaveMinutes = () => { const v = Number(Plugin.AutoSaveMinutes) return v > 0 ? v : 5 } const Start = async (params = Plugin) => { console.log(`[${Plugin.name}] Start()`) await ensureData() try { store.tagsConfig = JSON.parse(await Plugins.ReadFile(TAGS_FILE)) } catch (e) {} await Plugins.StopServer(Plugin.id).catch(() => {}) const router = new Router() registerStatsApi(router) registerTagsApi(router) router.get('/v1/docs/json', {}, (req, res) => res.json( 200, router.routes.map((r) => ({ method: r.method, path: r.path, metadata: r.metadata })) ) ) await Plugins.StartServer(params.ApiAddress || '127.0.0.1:23328', Plugin.id, async (req, res) => router.match(req, res)) registerHandler() if (store.autosaveTimer) clearInterval(store.autosaveTimer) store.autosaveTimer = setInterval(() => saveMonthlyData().catch((e) => console.log(`[${Plugin.name}] autosave`, e)), autosaveMinutes() * 60000) return 1 } const Stop = async () => { console.log(`[${Plugin.name}] Stop()`) if (store.autosaveTimer) clearInterval(store.autosaveTimer) store.autosaveTimer = null await saveMonthlyData() await Plugins.StopServer(Plugin.id) unRegisterHandler() return 2 } const registerHandler = () => { unRegisterHandler() // prevent double subscription (double counting) when Start runs twice const kernel = Plugins.useKernelApiStore() store.unregs.push(kernel.onConnections((d) => handleConnections(d).catch((e) => console.log(`[${Plugin.name}] conn`, e)))) store.unregs.push(kernel.onLogs((d) => handleLogs(d).catch(() => {}))) store.unregs.push(kernel.onTraffic((d) => handleTraffic(d))) store.unregs.push(kernel.onMemory((d) => handleMemory(d))) } const unRegisterHandler = () => { store.unregs.forEach((u) => u?.()) store.unregs = [] } const onBeforeCoreStart = async (config, profile) => { if (Plugins.APP_TITLE.includes('SingBox')) config.log.level = 'debug' return config } const onReady = async () => { await initMonthlyData() await Stop().catch((err) => console.log(`[${Plugin.name}] onReady: Stop()`, err)) await Start().catch((err) => console.log(`[${Plugin.name}] onReady: Start()`, err)) return 1 } const onShutdown = async () => { await Stop().catch((err) => console.log(`[${Plugin.name}] onShutdown: Stop()`, err)) return 2 } const onReload = async () => { await saveMonthlyData() } const Utils = { paginate(data, pageNum, pageSize) { pageNum = Math.max(1, Number(pageNum)) pageSize = Math.max(1, Number(pageSize)) const startIndex = (pageNum - 1) * pageSize return { pageNum, pageSize, total: data.length, list: data.slice(startIndex, startIndex + pageSize) } }, sortByField(arr, field, order = 'desc') { return arr.sort((a, b) => { const valA = a[field] const valB = b[field] if (valA == null && valB == null) return 0 if (valA == null) return 1 if (valB == null) return -1 if (typeof valA === 'number' && typeof valB === 'number') return order === 'desc' ? valB - valA : valA - valB return order === 'desc' ? String(valB).localeCompare(String(valA)) : String(valA).localeCompare(String(valB)) }) }, paginateAndSort(list, query) { const { sort, pageNum = 1, pageSize = 10, order } = query sort && Utils.sortByField(list, sort, order) return Utils.paginate(list, pageNum, pageSize) }, empty(query) { return { pageNum: Number(query.pageNum || 1), pageSize: Number(query.pageSize || 10), total: 0, list: [] } }, toList(map) { return Object.entries(map || {}).map(([name, val]) => typeof val === 'number' ? { name, count: val } : { name, ...val, total: (val.up || 0) + (val.down || 0) } ) }, async getMonthData(month) { await ensureData() if (!month || month === store.currentMonth) return store.data return await readMonth(month) }, async getTargetData(query) { const m = await Utils.getMonthData(query.month) if (!m) return null return query.day ? m.daily[query.day] : m }, async listMonths() { let names = [] try { names = (await Plugins.ReadDir(PATH)).map((f) => f.name) } catch (e) {} const set = new Set(store.currentMonth ? [store.currentMonth] : []) names.forEach((n) => { const m = n.match(/^(\d{4}-\d{2})-v[12]\.json$/) if (m) set.add(m[1]) }) return [...set].sort().reverse() } } function registerStatsApi(router) { router.get('/v1/stats/overview', { description: { zh: '实时统计概览' } }, (req, res) => { const d = store.data res.json(200, { month_summary: d ? d.summary : null, current_month: store.currentMonth, conn: d?.conn, peak: d?.peak, memory: d?.memory, residual: d?.residual, realtime: store.realtime.slice(-1)[0] || null, active_connections: Object.keys(store.lastConnections).length }) }) router.get( '/v1/stats/rank/:dimension', { description: { zh: '按维度统计: ' + DIMS.concat(['log_levels', 'dns_types', 'dns_domains', 'dns_ip_kinds', 'failed_hosts', 'clients']).join(',') }, examples: { 节点下行流量排行: '/v1/stats/rank/nodes?sort=down', 端口排行: '/v1/stats/rank/ports?sort=total', 代理链: '/v1/stats/rank/chains?sort=total' } }, async (req, res, { dimension }) => { const target = await Utils.getTargetData(req.query) if (!target || !target.details[dimension]) return res.json(200, Utils.empty(req.query)) res.json(200, Utils.paginateAndSort(Utils.toList(target.details[dimension]), req.query)) } ) router.get('/v1/stats/clients/:ip', { description: { zh: '按客户端查询: ip' } }, async (req, res, { ip }) => { const target = await Utils.getTargetData(req.query) if (!target || !target.details.clients[ip]) return res.end(404, {}, `Client not found: ${ip}`) res.json(200, target.details.clients[ip]) }) router.get('/v1/stats/clients/:ip/:dimension', { description: { zh: '按客户端和维度查询' } }, async (req, res, { ip, dimension }) => { const target = await Utils.getTargetData(req.query) if (!target || !target.details.clients[ip]?.[dimension]) return res.json(200, Utils.empty(req.query)) res.json(200, Utils.paginateAndSort(Utils.toList(target.details.clients[ip][dimension]), req.query)) }) router.get( '/v1/stats/pivot/:type/:key', { description: { zh: '交叉统计 type: node | node_process | domain | process | tag' } }, async (req, res, { type, key }) => { const target = await Utils.getTargetData(req.query) if (!target) return res.json(200, Utils.empty(req.query)) const field = { node: 'pivot_node_domain', node_process: 'pivot_node_process', domain: 'pivot_domain_node', process: 'pivot_process_domain', tag: 'pivot_tag_node' }[ type ] || 'pivot_tag_node' const detail = target.details[field]?.[key] if (!detail) return res.json(404, 'No Data') res.json(200, Utils.paginateAndSort(Utils.toList(detail), req.query)) } ) router.get('/v1/stats/hourly', { description: { zh: '24 小时分布 (?month=&day=)' } }, async (req, res) => { const target = await Utils.getTargetData(req.query) res.json(200, target ? target.hourly : []) }) router.get('/v1/stats/daily', { description: { zh: '每日汇总 (?month=)' } }, async (req, res) => { const m = await Utils.getMonthData(req.query.month) res.json(200, m ? Object.entries(m.daily).map(([day, d]) => ({ day, ...d.summary, conns: d.conn?.count || 0, peakDown: d.peak?.down || 0 })) : []) }) router.get('/v1/stats/connections', { description: { zh: '连接统计 / 峰值 / 直连代理比' } }, async (req, res) => { const t = await Utils.getTargetData(req.query) res.json(200, t ? { conn: t.conn, peak: t.peak } : null) }) router.get('/v1/stats/top/:kind', { description: { zh: 'kind: bytes | duration' } }, async (req, res, { kind }) => { const t = await Utils.getTargetData(req.query) res.json(200, t ? (kind === 'duration' ? t.long_conns : t.top_conns) : []) }) router.get('/v1/stats/realtime', { description: { zh: '最近 120 个实时速率采样' } }, (req, res) => res.json(200, store.realtime)) router.get('/v1/stats/nodes/meta', { description: { zh: '节点协议类型与延迟' } }, (req, res) => res.json(200, store.data?.node_meta || {})) router.get('/v1/stats/history/months', {}, async (req, res) => res.json(200, await Utils.listMonths())) } function registerTagsApi(router) { router.get('/v1/tags', {}, (req, res) => res.json(200, store.tagsConfig)) router.post('/v1/tags', {}, async (req, res) => { store.tagsConfig = Plugins.deepAssign(store.tagsConfig, typeof req.body === 'string' ? JSON.parse(req.body || '{}') : req.body) await Plugins.WriteFile(TAGS_FILE, JSON.stringify(store.tagsConfig)) res.json(200, 'OK') }) } /* ======================= UI ======================= */ /* ======================= UI ======================= */ const onRun = async () => { // clicked right after a close: queue the open (300ms) instead of dropping it const since = Date.now() - (TrafficUI.closedAt || 0) if (since < 300) await new Promise((r) => setTimeout(r, 300 - since)) // a previous window may still be playing its close animation: wait for it (bounded) instead of racing it if (TrafficUI.closing) await Promise.race([TrafficUI.closing, new Promise((r) => setTimeout(r, 600))]) TrafficUI.closing = null if (TrafficUI.modal) { // stale instance from an earlier open: drop it so this click always opens a fresh window try { TrafficUI.modal.destroy() } catch (e) {} TrafficUI.modal = null } TrafficUI.stopTimer() await ensureData() refreshNodeMeta() const modal = Plugins.modal({ title: '流量统计', width: '92', height: '92', minWidth: '90', minHeight: '90', maxWidth: '96', maxHeight: '96', submit: false, cancelText: '关闭', maskClosable: true, beforeClose: () => { // the fading mask would swallow a Run click during the close animation: let clicks pass through it const mask = TrafficUI.rootEl && TrafficUI.rootEl.closest && TrafficUI.rootEl.closest('.gui-modal-mask') if (mask) mask.style.pointerEvents = 'none' TrafficUI.closing = new Promise((r) => (TrafficUI.closed = r)) return true }, afterClose: () => { TrafficUI.closed && TrafficUI.closed() TrafficUI.closedAt = Date.now() TrafficUI.closing = null // destroyOnClose (default true) unmounts it; calling destroy() here re-entered close and could leave a stale instance TrafficUI.stopTimer() if (TrafficUI.modal === modal) TrafficUI.modal = null } }) TrafficUI.modal = modal // the component exposes modalSlots (toolbar / action), setContent patches them into the modal modal.setContent(TrafficUI.createComponent(), null, null, false) modal.open() } const TrafficUI = { timer: null, PALETTE: ['#4e79a7', '#59a14f', '#e0a33a', '#c75c5c', '#76a3b8', '#8f7bb0', '#b0876a', '#8a9a5b', '#a0a4ab'], TABS: [ { label: '节点', value: 'nodes' }, { label: '域名', value: 'domains' }, { label: '根域名', value: 'roots' }, { label: '进程', value: 'process_names' }, { label: '进程路径', value: 'processes' }, { label: '规则', value: 'rules' }, { label: '规则+载荷', value: 'rule_payloads' }, { label: '策略组', value: 'groups' }, { label: '代理链', value: 'chains' }, { label: '端口', value: 'ports' }, { label: '协议', value: 'networks' }, { label: '入站', value: 'inbounds' }, { label: '目标 IP', value: 'dest_ips' }, { label: '客户端', value: 'clients' }, { label: '标签', value: 'tags' }, { label: '失败', value: 'failed_hosts' } ], DRILL: { nodes: [ { label: '域名', field: 'pivot_node_domain' }, { label: '进程', field: 'pivot_node_process' } ], domains: [{ label: '节点', field: 'pivot_domain_node', keyOf: (r) => getRootDomain(r.name) }], roots: [{ label: '节点', field: 'pivot_domain_node' }], processes: [{ label: '域名', field: 'pivot_process_domain' }], tags: [{ label: '节点', field: 'pivot_tag_node' }], clients: [ { label: '域名', client: 'domains' }, { label: '节点', client: 'nodes' }, { label: '进程', client: 'processes' }, { label: '规则', client: 'rules' }, { label: '标签', client: 'tags' } ] }, stopTimer() { if (TrafficUI.timer) clearInterval(TrafficUI.timer) TrafficUI.timer = null }, fmt(n) { n = Number(n) || 0 const u = ['B', 'KB', 'MB', 'GB', 'TB'] let i = 0 while (n >= 1024 && i < u.length - 1) ((n /= 1024), i++) return (i === 0 ? n : n.toFixed(n >= 100 ? 0 : n >= 10 ? 1 : 2)) + ' ' + u[i] }, speed(n) { return TrafficUI.fmt(n) + '/s' }, num(n) { n = Number(n) || 0 return n >= 1e4 ? (n / 1e4).toFixed(n >= 1e5 ? 0 : 1) + '万' : n.toLocaleString() }, dur(s) { s = Math.round(Number(s) || 0) if (s < 60) return s + '秒' if (s < 3600) return Math.floor(s / 60) + '分' + (s % 60 ? (s % 60) + '秒' : '') if (s < 86400) return Math.floor(s / 3600) + '时' + Math.floor((s % 3600) / 60) + '分' return Math.floor(s / 86400) + '天' + Math.floor((s % 86400) / 3600) + '时' }, time(ts) { if (!ts) return '-' const d = new Date(ts) return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}` }, // ---- pure view-model builders (tested in node) ---- buildRows(map, { search = '', sort = 'total', order = 'desc' } = {}) { const rows = Object.entries(map || {}).map(([name, v]) => { if (typeof v === 'number') return { name, up: 0, down: 0, total: 0, hits: v } const up = v.up || 0 const down = v.down || 0 return { name, up, down, total: up + down, hits: v.hits || 0, last: v.last } }) const sumBytes = rows.reduce((s, r) => s + r.total, 0) const sumHits = rows.reduce((s, r) => s + r.hits, 0) rows.forEach((r) => (r.percent = sumBytes ? (r.total / sumBytes) * 100 : sumHits ? (r.hits / sumHits) * 100 : 0)) const q = search.trim().toLowerCase() const list = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows list.sort((a, b) => { const x = a[sort] const y = b[sort] const c = typeof x === 'number' && typeof y === 'number' ? x - y : String(x).localeCompare(String(y)) return order === 'desc' ? -c : c }) return list }, buildDaily(monthData, month) { const [y, m] = (month || '').split('-').map(Number) const days = y && m ? new Date(y, m, 0).getDate() : 31 const list = [] for (let d = 1; d <= days; d++) { const dd = monthData?.daily?.[String(d)] const s = dd?.summary || {} list.push({ day: String(d), up: s.up || 0, down: s.down || 0, conns: dd?.conn?.count || 0, hourly: dd?.hourly || null }) } return list }, buildChart(daily, selectedDay, W = 960, H = 220) { const L = 56 // left gutter for y labels const T = 14 // top padding so the top label is never clipped const B = 20 // bottom area for day labels const plot = H - T - B const base = T + plot const peak = Math.max(0, ...daily.map((d) => d.up + d.down)) // stacked value // 4 equal steps with a "nice" step size, so max = 4 × step ≥ peak const rawStep = Math.max(256, peak / 4) const p = Math.pow(1024, Math.floor(Math.log(rawStep) / Math.log(1024))) const step = p * [1, 1.25, 2, 2.5, 5, 7.5, 10, 12.5, 20, 25, 50, 75, 100, 125, 200, 250, 500, 750, 1000, 1024].find((x) => x * p >= rawStep) const max = step * 4 const yOf = (v) => base - (v / max) * plot const ticks = [0, 1, 2, 3, 4].map((k) => ({ y: yOf(step * k), label: TrafficUI.fmt(step * k) })) const bw = (W - L) / daily.length const bars = daily.map((d, i) => { const hDown = (d.down / max) * plot const hUp = (d.up / max) * plot return { ...d, x: L + i * bw + bw * 0.18, w: Math.max(1, bw * 0.64), yDown: base - hDown, hDown, yUp: base - hDown - hUp, hUp, lx: L + i * bw + bw / 2, bx: L + i * bw, bw, active: selectedDay === d.day, tip: `${d.day} 日 合计 ${TrafficUI.fmt(d.up + d.down)}\n上传 ${TrafficUI.fmt(d.up)} 下载 ${TrafficUI.fmt(d.down)}\n连接 ${d.conns}` } }) return { bars, ticks, W, H, plot, L, T, base, max } }, HEAT: [0, 0.18, 0.34, 0.52, 0.74, 1], buildHeatmap(monthData, daily, selectedDay) { const rows = selectedDay ? daily.filter((d) => d.day === selectedDay) : daily.filter((d) => d.up + d.down > 0) let max = 0 rows.forEach((r) => (r.hourly || []).forEach((h) => (max = Math.max(max, (h.up || 0) + (h.down || 0))))) const has = max > 0 const out = rows.map((r) => ({ day: r.day, cells: Array.from({ length: 24 }, (_, h) => { const v = r.hourly?.[h] || {} const t = (v.up || 0) + (v.down || 0) const lvl = !t ? 0 : Math.min(5, 1 + Math.floor(Math.sqrt(t / max) * 4.999)) return { h, lvl, alpha: TrafficUI.HEAT[lvl], tip: `${r.day} 日 ${pad2(h)}:00-${pad2(h + 1)}:00\n流量 ${TrafficUI.fmt(t)} 连接 ${v.hits || 0}${v.peakDown ? '\n下载峰值 ' + TrafficUI.speed(v.peakDown) : ''}` } }) })) const legend = TrafficUI.HEAT.map((a) => ({ alpha: a })) return { rows: has ? out : [], max, legend, single: !!selectedDay } }, buildDonut(map, limit = 6, valueOf = (v) => (v.up || 0) + (v.down || 0)) { const entries = Object.entries(map || {}) .map(([name, v]) => ({ name, value: typeof v === 'number' ? v : valueOf(v) })) .filter((e) => e.value > 0) entries.sort((a, b) => b.value - a.value) const top = entries.slice(0, limit) const rest = entries.slice(limit).reduce((s, e) => s + e.value, 0) if (rest > 0) top.push({ name: '其他', value: rest }) const total = top.reduce((s, e) => s + e.value, 0) const R = 42 const C = 2 * Math.PI * R let acc = 0 const segs = top.map((e, i) => { const len = total ? (e.value / total) * C : 0 const seg = { ...e, label: e.name, color: TrafficUI.PALETTE[i % TrafficUI.PALETTE.length], dash: `${len} ${C - len}`, offset: -acc, percent: total ? (e.value / total) * 100 : 0 } acc += len return seg }) return { segs, total, R } }, // smallest "nice" value ≥ v (1/2/2.5/5 × 1024^k), so bars never exceed the top tick niceMax(v, floor = 1024) { v = Math.max(floor, v) const p = Math.pow(1024, Math.floor(Math.log(v) / Math.log(1024))) const steps = [1, 1.25, 2, 2.5, 4, 5, 7.5, 10, 12.5, 20, 25, 40, 50, 75, 100, 125, 200, 250, 400, 500, 750, 1000, 1024] return p * steps.find((x) => x * p >= v) }, buildSpark(samples, W = 600, H = 220) { const N = 180 const list = samples.slice(-N) const peak = Math.max(0, ...list.map((x) => Math.max(x.up, x.down))) // nice y-scale with a 10 KB/s floor const max = TrafficUI.niceMax(peak * 1.05, 1024) const L = 58 const plotW = W - L - 4 const plotH = H - 18 const step = plotW / (N - 1) const off = N - list.length const y = (v) => (4 + plotH - (v / max) * plotH).toFixed(1) const pts = (k) => list.map((x, i) => `${(L + (off + i) * step).toFixed(1)},${y(x[k])}`).join(' ') const last = list[list.length - 1] || { up: 0, down: 0 } const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => ({ y: Number(y(max * f)), label: TrafficUI.speed(max * f) })) const first = list[0] const xlabels = first ? [ { x: L + off * step, label: new Date(first.t).toLocaleTimeString() }, { x: W - 4, label: new Date(last.t).toLocaleTimeString() } ] : [] return { up: pts('up'), down: pts('down'), downArea: list.length > 1 ? `${(L + off * step).toFixed(1)},${4 + plotH} ${pts('down')} ${(L + (N - 1) * step).toFixed(1)},${4 + plotH}` : '', max, peak, last, W, H, L, ticks, xlabels, count: list.length, bottom: 4 + plotH } }, buildKpi(t, monthData) { const s = t?.summary || {} const c = t?.conn || {} // conn.direct only covers v2-era traffic; legacy traffic is known per node, so take the larger and derive proxy from the total const nd = t?.details?.nodes?.DIRECT || {} const tot = (s.up || 0) + (s.down || 0) const direct = Math.min(tot, Math.max((c.direct?.up || 0) + (c.direct?.down || 0), (nd.up || 0) + (nd.down || 0))) const proxy = Math.max(0, tot - direct) return { total: (s.up || 0) + (s.down || 0), up: s.up || 0, down: s.down || 0, conns: c.count || 0, rejected: c.rejected || 0, closed: c.closed || 0, avgDur: c.closed ? c.durTotal / c.closed : 0, maxDur: c.durMax || 0, peakDown: t?.peak?.down || 0, peakUp: t?.peak?.up || 0, peakAt: t?.peak?.downAt || 0, directPct: direct + proxy ? (direct / (direct + proxy)) * 100 : 0, direct, proxy, memPeak: monthData?.memory?.peak || 0 } }, REMAINDER: '未细分(升级前/未记录)', // add a remainder entry so a dimension always sums to the summary total reconcile(map, summary) { const out = { ...(map || {}) } if (!summary) return out let up = 0 let down = 0 for (const k in out) { const v = out[k] if (v && typeof v === 'object') ((up += v.up || 0), (down += v.down || 0)) } const ru = (summary.up || 0) - up const rd = (summary.down || 0) - down const tot = (summary.up || 0) + (summary.down || 0) if (tot && ru + rd > tot * 0.001) out[TrafficUI.REMAINDER] = { up: Math.max(0, ru), down: Math.max(0, rd), hits: 0 } return out }, groupConns(list, skipZero) { const groups = {} for (const c of list || []) { const bytes = (c.up || 0) + (c.down || 0) if (skipZero && !bytes) continue const key = c.host + '\u0000' + c.node const g = groups[key] || (groups[key] = { key, host: c.host, node: c.node, chain: c.chain, process: c.process, rule: c.rule, count: 0, up: 0, down: 0, duration: 0, items: [] }) g.count++ g.up += c.up || 0 g.down += c.down || 0 g.duration = Math.max(g.duration, c.duration || 0) g.items.push({ ...c }) } return Object.values(groups) }, getDrillMap(target, tab, drill) { if (!target || !drill) return {} const d = target.details const opt = (TrafficUI.DRILL[tab] || [])[drill.sub] || {} if (opt.client) return d.clients?.[drill.key]?.[opt.client] || {} return d[opt.field]?.[drill.key] || {} }, // grouped connections -> flat rows for ; children follow their (expanded) group flattenConns(listId, groups, expanded = {}) { const out = [] for (const g of groups || []) { const key = listId + g.key const open = g.count > 1 && !!expanded[key] out.push({ id: key, key, group: true, open, expandable: g.count > 1, host: g.host, sub: [g.process, g.rule].filter(Boolean).join(' · '), node: g.node, chain: g.chain || g.node, count: g.count, bytes: g.up + g.down, duration: g.duration }) if (open) g.items.forEach((c, i) => out.push({ id: key + '#' + i, key, group: false, host: [TrafficUI.time(c.start), c.network, c.client].filter(Boolean).join(' · '), sub: '', node: c.process || '', chain: c.chain || c.node || '', count: '', bytes: (c.up || 0) + (c.down || 0), duration: c.duration }) ) } return out }, SORTS: [ { label: '按合计', value: 'total' }, { label: '按下载', value: 'down' }, { label: '按上传', value: 'up' }, { label: '按连接数', value: 'hits' }, { label: '按名称', value: 'name' } ], PAGE_SIZES: [ { label: '15 条/页', value: '15' }, { label: '30 条/页', value: '30' }, { label: '100 条/页', value: '100' } ], RANK_COLUMNS: [ { title: '#', key: 'rank', align: 'right' }, { title: '名称', key: 'name' }, { title: '上传', key: 'up', align: 'right' }, { title: '下载', key: 'down', align: 'right' }, { title: '合计', key: 'total', align: 'right' }, { title: '连接', key: 'hits', align: 'right' }, { title: '占比', key: 'percent' } ], // fixed cell widths (see the #slots) + a name width derived from the window, so columns never shift between refreshes // w = measured width of the content root (ResizeObserver), not the screen/window // tabsW = natural width of the tab row; below it the tabs become a Select tableLayout(w, sortKey, tabsW = 1100) { const narrow = w < 760 // hide secondary columns when narrow, but never the one being sorted by const cols = TrafficUI.RANK_COLUMNS.map((c) => (narrow && c.key !== sortKey && ['up', 'down', 'hits'].includes(c.key) ? { ...c, hidden: true } : c)) // rank 24 + total 72 + share 120 (+ up/down 72 each + hits 52) + 16px padding per cell + card padding/scrollbar ~32 const extra = { up: 72, down: 72, hits: 52 } const fixed = 24 + 72 + 120 + cols.reduce((sum, c) => sum + (!c.hidden && extra[c.key] ? extra[c.key] : 0), 0) + cols.filter((c) => !c.hidden).length * 16 + 32 const ringCols = w >= 1000 ? 3 : w >= 600 ? 2 : 1 return { narrow, compact: w < tabsW, ringCols, colsKey: cols.map((c) => (c.hidden ? 0 : 1)).join(''), cols, nameW: Math.max(60, Math.floor(w) - fixed) } }, CONN_COLUMNS: [ { title: '目标 / 进程', key: 'host' }, { title: '节点', key: 'node' }, { title: '次数', key: 'count', align: 'right' }, { title: '流量', key: 'bytes', align: 'right' }, { title: '时长', key: 'duration', align: 'right' } ], // whole view model, rebuilt from scratch on every refresh / user action (store data is mutated in place, so no reliance on Vue tracking it) buildView(st, pastData) { const isCurrent = st.month === store.currentMonth const md = isCurrent ? store.data : pastData const t = md ? (st.day ? md.daily?.[st.day] : md) : null const daily = TrafficUI.buildDaily(md, st.month) const d = t?.details || {} const c = t?.conn || {} const sum = t?.summary const RECON = [ 'domains', 'roots', 'nodes', 'processes', 'process_names', 'rules', 'rule_payloads', 'groups', 'chains', 'ports', 'networks', 'conn_types', 'inbounds', 'dest_ips', 'clients' ] const kpi = TrafficUI.buildKpi(t, md) const donut = (title, map, byHits) => { if (!byHits) map = TrafficUI.reconcile(map, sum) const r = TrafficUI.buildDonut(map, 6, byHits ? (v) => v.hits || 0 : undefined) return { title, ...r, centerLabel: byHits ? '连接' : '合计', centerValue: byHits ? TrafficUI.num(r.total) : TrafficUI.fmt(r.total) } } const drillOptions = TrafficUI.DRILL[st.tab] || [] let map = st.drill ? TrafficUI.getDrillMap(t, st.tab, st.drill) : d[st.tab] if (!st.drill && RECON.includes(st.tab)) map = TrafficUI.reconcile(map, sum) const rows = TrafficUI.buildRows(map, { search: st.search, sort: st.sort, order: st.order }) if (st.tab === 'nodes' && !st.drill) { const meta = md?.node_meta || {} rows.forEach((r) => (r.meta = meta[r.name] ? { ...meta[r.name] } : null)) } const pages = Math.max(1, Math.ceil(rows.length / st.pageSize)) if (st.page > pages) st.page = pages const offset = (st.page - 1) * st.pageSize const chips = (m, val = (v) => v) => Object.entries(m || {}) .map(([name, v]) => ({ name, value: val(v) })) .sort((a, b) => b.value - a.value) const rt = store.realtime const lastRt = rt[rt.length - 1] return { isCurrent, hasData: !!t, note: md?.v2Since ? `端口、协议、入站、策略组、代理链和小时分布从 ${TrafficUI.time(md.v2Since)} 开始统计,此前的流量记为“${LEGACY}”。` : '', kpi, chart: TrafficUI.buildChart(daily, st.day, st.chartW || 960), days: daily.map((x) => x.day), heat: TrafficUI.buildHeatmap(md, daily, st.day), spark: TrafficUI.buildSpark(rt, st.chartW || 600), live: !!lastRt && Date.now() - lastRt.t < 5000, active: Object.keys(store.lastConnections).length, residual: { up: md?.residual?.up || 0, down: md?.residual?.down || 0 }, donuts: [ donut('节点流量', d.nodes), donut('策略组', d.groups), donut('直连 / 代理', { 直连: { down: kpi.direct }, 代理: { down: kpi.proxy } }), donut('TCP / UDP', d.networks), donut('入站类型(连接数)', d.conn_types, true), donut('进程', d.process_names) ], drillOptions, canDrill: !st.drill && drillOptions.length > 0, table: { total: rows.length, pages, offset, list: rows.slice(offset, offset + st.pageSize).map((r, i) => ({ ...r, id: r.name, rank: offset + i + 1 })) }, topLists: [ { id: 'bytes', title: '单连接流量排行', durTitle: '时长', list: TrafficUI.groupConns(t?.top_conns, true).sort((a, b) => b.up + b.down - (a.up + a.down)) }, { id: 'dur', title: '长连接排行', durTitle: '最长', list: TrafficUI.groupConns(t?.long_conns, false).sort((a, b) => b.duration - a.duration) } ].map((tl) => ({ ...tl, rows: TrafficUI.flattenConns(tl.id, tl.list, st.expanded) })), dns: { types: chips(d.dns_types), kinds: chips(d.dns_ip_kinds), top: chips(d.dns_domains, (v) => v.hits || 0).slice(0, 24), logs: chips(d.log_levels) }, updatedAt: new Date().toLocaleTimeString() } }, createComponent() { const { reactive, shallowRef, defineComponent, onUnmounted, onMounted, h, resolveComponent } = Vue return defineComponent({ template: /* html */ `
{{ v.note }}
{{ fmt(v.kpi.total) }}
{{ s.day ? s.month + '-' + s.day : s.month + ' 全月' }}
{{ fmt(v.kpi.up) }}
峰值 {{ speed(v.kpi.peakUp) }}
{{ fmt(v.kpi.down) }}
峰值 {{ speed(v.kpi.peakDown) }},{{ time(v.kpi.peakAt) }}
{{ num(v.kpi.conns) }}
拦截 {{ num(v.kpi.rejected) }},已关闭 {{ num(v.kpi.closed) }}
平均 {{ dur(v.kpi.avgDur) }},最长 {{ dur(v.kpi.maxDur) }}
{{ speed(v.spark.last.down) }}
上传 {{ speed(v.spark.last.up) }},活动连接 {{ v.active }}
{{ v.kpi.directPct.toFixed(1) }}%
直连 {{ fmt(v.kpi.direct) }},代理 {{ fmt(v.kpi.proxy) }}
{{ t.label }} {{ b.tip }} {{ b.day }}
上传 下载
{{ t.label }} {{ x.label }}
上传 {{ speed(v.spark.last.up) }} 下载 {{ speed(v.spark.last.down) }} 区间峰值 {{ speed(v.spark.peak) }},内存峰值 {{ fmt(v.kpi.memPeak) }}
{{ g.name }} {{ g.percent.toFixed(1) }}% {{ d.centerLabel }} {{ d.centerValue }}
{{ g.name }} {{ g.percent.toFixed(1) }}%
{{ s.drill.label }}
共 {{ v.table.total }} 项