feat(ops): upload node logs and task traffic [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

This commit is contained in:
justme committed 2026-10-11 23:57:13 +08:00
1 parent e82b165eb0
commit 6cf6b23585
25 files changed
+714 -13

No files matched your search

+46
View File
@@ -0,0 +1,46 @@
import type { NodeRuntimeLogsDto } from '@shared/api/types'
import { fmtTime } from '@shared/format'
import { useQuery } from '@shared/hooks'
import { Chip, Empty, ErrorNotice, Spinner } from '@shared/ui'
import { api } from '@shared/api/client'
export function RuntimeLogs({ nodeId }: { nodeId: string }) {
const logs = useQuery(
() => api.get<NodeRuntimeLogsDto>(`/api/admin/nodes/${encodeURIComponent(nodeId)}/logs`, { limit: 100 }),
[nodeId],
5000,
)
return (
<section className="card">
<div className="card-title row">
运行日志
<span className="right muted">最近 {logs.data?.items.length ?? 0} 条</span>
</div>
<ErrorNotice error={logs.error} />
{logs.data?.dropped ? <p className="faint">节点或后端缓冲区累计丢弃 {logs.data.dropped} 条较早日志。</p> : null}
{logs.loading && !logs.data ? <Spinner /> : null}
{!logs.loading && (logs.data?.items.length ?? 0) === 0 ? <Empty>还没有收到运行日志</Empty> : null}
{(logs.data?.items.length ?? 0) > 0 ? (
<div className="runtime-logs">
{logs.data?.items.map((entry) => (
<div className="runtime-log" key={entry.id}>
<time className="muted num">{fmtTime(entry.at)}</time>
<Chip tone={entry.level === 'error' ? 'bad' : entry.level === 'warn' ? 'warn' : ''}>{entry.level}</Chip>
<div className="runtime-log-message">
<div>{entry.message}</div>
{Object.keys(entry.fields).length ? (
<div className="runtime-log-fields">
{Object.entries(entry.fields).map(([key, value]) => (
<span key={key}><b>{key}</b>={value}</span>
))}
</div>
) : null}
</div>
</div>
))}
</div>
) : null}
</section>
)
}
+48
View File
@@ -17,6 +17,54 @@
overflow: auto;
}
.runtime-logs {
display: grid;
gap: 1px;
max-height: 520px;
overflow: auto;
border-radius: var(--r-card);
background: var(--app-outline-soft);
}
.runtime-log {
display: grid;
grid-template-columns: 150px auto minmax(0, 1fr);
align-items: start;
gap: 10px;
padding: 9px 10px;
background: var(--app-card);
font-size: 13px;
}
.runtime-log-message {
min-width: 0;
overflow-wrap: anywhere;
}
.runtime-log-fields {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
margin-top: 4px;
color: var(--app-ink-muted);
font-size: 11px;
}
.runtime-log-fields b {
color: var(--app-ink-faint);
font-weight: 500;
}
@media (max-width: 700px) {
.runtime-log {
grid-template-columns: 1fr auto;
}
.runtime-log-message {
grid-column: 1 / -1;
}
}
.side .brand {
padding: 8px 14px 16px;
font-size: 18px;
+2
View File
@@ -8,6 +8,7 @@ import { useQuery } from '@shared/hooks'
import { Chip, Confirm, Dialog, Empty, ErrorNotice, Field, Spinner, useAction } from '@shared/ui'
import { AgentStatus } from './Agents'
import { IPV6_STATES } from '@shared/ipVersions'
import { RuntimeLogs } from '../RuntimeLogs'
function EditDialog({ agent, onClose, onSaved }: { agent: AgentDto; onClose: () => void; onSaved: () => void }) {
const { busy, run } = useAction()
@@ -358,6 +359,7 @@ export function AgentDetail() {
</table>
)}
</div>
<RuntimeLogs nodeId={a.id} />
</div>
) : null}
+2
View File
@@ -7,6 +7,7 @@ import { useQuery } from '@shared/hooks'
import { NodeTag, RuntimeStatusChip, useNodeInfos } from '@shared/nodes'
import { IPV6_STATES } from '@shared/ipVersions'
import { Chip, Dot, Empty, ErrorNotice, Spinner } from '@shared/ui'
import { RuntimeLogs } from '../RuntimeLogs'
export function ProbeDetail() {
const { id = '' } = useParams()
@@ -111,6 +112,7 @@ export function ProbeDetail() {
)}
</section>
</div>
<RuntimeLogs nodeId={p.id} />
</div>
) : null}
</>
+7 -3
View File
@@ -50,9 +50,13 @@ export function Host() {
<section className="hero">
<h1>{node?.name ?? id}</h1>
<div className="row wrap" style={{ marginTop: 12 }}>
{info?.country ? <Flag code={info.country} /> : null}
{info?.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
{info?.os_name ? <span className="row"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
{info?.country || info?.isp_key || info?.os_name ? (
<span className="node-tag">
{info.country ? <Flag code={info.country} /> : null}
{info.isp_key ? <IspIcon ispKey={info.isp_key} name={info.isp_name} /> : null}
{info.os_name ? <span className="plat"><OsIcon os={info.os} name={info.os_name} />{[info.os_name, info.arch].filter(Boolean).join(' / ')}</span> : null}
</span>
) : null}
{node ? <><Chip>{node.kind === 'agent' ? 'Agent' : 'Probe'}</Chip><Dot tone={node.online ? 'ok' : 'bad'} /><span className="muted">{node.online ? (node.stale ? '状态数据暂未更新' : '在线') : '离线'}</span></> : <span className="muted">节点信息加载中…</span>}
{node?.last_seen_at && !node.online ? <span className="faint">最后在线 {fmtAgo(node.last_seen_at)}</span> : null}
<span className={`live-dot ${live ? 'on' : ''}`} title={live ? '实时更新中' : '实时连接中断,正在重连'} />
+75
View File
@@ -501,6 +501,23 @@ export interface paths {
patch: operations["agents_update"];
trace?: never;
};
"/api/admin/nodes/{id}/logs": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Recent Agent or Probe runtime logs */
get: operations["node_runtime_logs"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/agents/{id}/approve": {
parameters: {
query?: never;
@@ -1830,6 +1847,22 @@ export interface components {
passkey: null | string;
account: null | string;
};
NodeRuntimeLogDto: {
/** Format: int64 */
id: number | string;
/** Format: date-time */
at: unknown;
level: string;
message: string;
fields: {
[key: string]: string;
};
};
NodeRuntimeLogsDto: {
items: components["schemas"]["NodeRuntimeLogDto"][];
/** Format: int64 */
dropped: number | string;
};
/** @enum {unknown} */
NodeRuntimeStatus: "initializing" | "running" | "probe_paused" | "maintenance" | "stopping" | "offline" | "updating";
NodeUpdateDto: {
@@ -3843,6 +3876,48 @@ export interface operations {
};
};
};
node_runtime_logs: {
parameters: {
query?: {
limit?: number | string;
};
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["NodeRuntimeLogsDto"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["ProblemDto"];
};
};
};
};
agents_approve: {
parameters: {
query?: never;
+1
View File
@@ -7,6 +7,7 @@ type NarrowValue<V> = unknown extends V ? string : number extends V ? Exclude<V,
type Narrow<T> = { [K in keyof T]: NarrowValue<T[K]> }
export type AgentDto = Narrow<S['AgentDto']>
export type NodeRuntimeLogsDto = Narrow<S['NodeRuntimeLogsDto']>
export type NodeRuntimeStatus = S['NodeRuntimeStatus']
export type AgentIpv6Dto = Narrow<S['AgentIpv6Dto']>
export type ConnectedProbeDto = Narrow<S['ConnectedProbeDto']>