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}
</>