ci/woodpecker/push/main-amd64-build Pipeline was successful
podman-compose does not substitute defaults inside "environment:" (a value
like ${MOSHIRO_NAME:-} reached the node as the literal text), so the node was
enrolled with an invalid name and reported the wrong network scope. The three
compose files now read .env with env_file and only interpolate image and
network_mode; the Backend .env keys are the container's own names
(MoShiro__PublicUrl, MoShiro__ClaimCode).
- The generated .env no longer carries a comment line for MOSHIRO_NAME; the
dialog says how to choose a name instead.
- The container entrypoint prints the name it enrolls with and defaults
MOSHIRO_NETWORK_SCOPE to host (Docker Desktop sets container in .env).
- The overview and line pages say "measurement channel not established"
instead of "not connected": the agent can be reachable and pingable while
the measurement TCP/UDP path is not.
416 lines
20 KiB
TypeScript
416 lines
20 KiB
TypeScript
import { ChevronLeft } from 'lucide-react'
|
||
import { useMemo } from 'react'
|
||
import { IP_VERSIONS, lineIdFor } from '@shared/ipVersions'
|
||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||
import { api } from '@shared/api/client'
|
||
import type { LineDto, TestDto } from '@shared/api/types'
|
||
import { TimeChart, type ChartSeries } from '@shared/charts/TimeChart'
|
||
import { fmtAgo, fmtMbps, fmtMs, fmtNum, fmtPct, fmtTime } from '@shared/format'
|
||
import { NodeTag, useNodeInfos } from '@shared/nodes'
|
||
import { fmtBytes, useHosts } from '../live'
|
||
import { useQuery } from '@shared/hooks'
|
||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||
import { PageError, ScoreChips } from '../lib'
|
||
import { Minimap } from '../Minimap'
|
||
import { readSpec, usePeriod, usePeriodSeries, writeSpec } from '../periods'
|
||
import { dayBounds, dayKey, RangePicker, WINDOWS, type WindowValue } from '../ranges'
|
||
|
||
const MIN = 60_000
|
||
|
||
function tone(value: number | null | undefined, good: number, poor: number): 'ok' | 'warn' | 'bad' | '' {
|
||
if (value === null || value === undefined) return ''
|
||
if (value <= good) return 'ok'
|
||
if (value >= poor) return 'bad'
|
||
return 'warn'
|
||
}
|
||
|
||
interface HostSeries {
|
||
t: number[]
|
||
values: Record<string, (number | null)[]>
|
||
}
|
||
|
||
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
||
const TILES: { label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
||
{ label: 'UDP 延迟 p50', key: 'rtt.p50', fmt: fmtMs },
|
||
{ label: 'UDP 延迟 p95', key: 'rtt.p95', fmt: fmtMs },
|
||
{ label: 'UDP 丢包', key: 'loss.total', fmt: fmtPct, good: 0.5, poor: 3 },
|
||
{ label: 'UDP 上行丢包', key: 'loss.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||
{ label: 'UDP 下行丢包', key: 'loss.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||
{ label: 'UDP 最长连续丢包', key: 'loss.run_max', fmt: fmtNum, unit: '包' },
|
||
{ label: 'UDP 上行抖动 p95', key: 'jitter.up.p95', fmt: fmtMs },
|
||
{ label: 'UDP 下行抖动 p95', key: 'jitter.down.p95', fmt: fmtMs },
|
||
{ label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||
{ label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||
{ label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||
{ label: 'TCP 重传率', key: 'tcp.retrans', fmt: fmtPct, good: 0.5, poor: 3 },
|
||
{ label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||
{ label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||
{ label: '建连成功率', key: 'connect.success', fmt: fmtPct, optional: true },
|
||
]
|
||
|
||
interface ChartDef {
|
||
id: string
|
||
title: string
|
||
unit: string
|
||
zero?: boolean
|
||
fill?: boolean
|
||
series: { key: string; label: string; color: string; dash?: number[] }[]
|
||
group: 'udp' | 'tcp' | 'connect' | 'score'
|
||
/** 占满一行。 */
|
||
wide?: boolean
|
||
}
|
||
|
||
const GROUPS = [
|
||
{ id: 'udp', title: 'UDP(往返探测)' },
|
||
{ id: 'tcp', title: 'TCP' },
|
||
{ id: 'connect', title: '建连(TCP 握手、TLS 握手)' },
|
||
{ id: 'score', title: '评分' },
|
||
] as const
|
||
|
||
const CHARTS: ChartDef[] = [
|
||
{ id: 'rtt', title: '延迟', group: 'udp', wide: true, unit: 'ms', series: [{ key: 'rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'rtt.p99', label: 'p99', color: '--chart-3' }] },
|
||
{ id: 'loss', title: '丢包', group: 'udp', unit: '%', series: [{ key: 'loss.up', label: '上行', color: '--chart-2' }, { key: 'loss.down', label: '下行', color: '--chart-3' }, { key: 'loss.total', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||
{ id: 'jitter', title: '抖动(p95)', group: 'udp', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '上行', color: '--chart-2' }, { key: 'jitter.down.p95', label: '下行', color: '--chart-1' }] },
|
||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp', unit: 'ms', series: [{ key: 'tcp.rtt.p50', label: 'p50', color: '--chart-1' }, { key: 'tcp.rtt.p95', label: 'p95', color: '--chart-2' }, { key: 'tcp.rtt.max', label: '最大', color: '--chart-3' }] },
|
||
{ id: 'retrans', title: '重传率', group: 'tcp', unit: '%', fill: true, series: [{ key: 'tcp.retrans', label: '重传率', color: '--chart-3' }] },
|
||
{ id: 'connect-time', title: '建连耗时(p95)', group: 'connect', unit: 'ms', series: [{ key: 'connect.tcp_ms.p95', label: 'TCP 建连', color: '--chart-1' }, { key: 'connect.tls_ms.p95', label: 'TLS 握手', color: '--chart-2' }] },
|
||
{ id: 'connect-ok', title: '建连成功率', group: 'connect', unit: '%', zero: false, series: [{ key: 'connect.success', label: '成功率', color: '--chart-3' }] },
|
||
{ id: 'score', title: '综合评分', group: 'score', wide: true, unit: '', series: [{ key: 'score.reality', label: 'REALITY', color: '--chart-1' }, { key: 'score.hy2', label: 'Hy2', color: '--chart-2' }, { key: 'score.mc', label: 'MC', color: '--chart-4' }, { key: 'score.base', label: '综合', color: '--chart-5', dash: [4, 3] }] },
|
||
]
|
||
|
||
/** EWMA 平滑的力度:系数越小越平滑(s = α·x + (1−α)·s)。 */
|
||
const EWMA = [
|
||
{ value: 'off', label: '关', alpha: 0 },
|
||
{ value: 'low', label: '轻', alpha: 0.3 },
|
||
{ value: 'mid', label: '中', alpha: 0.1 },
|
||
{ value: 'high', label: '重', alpha: 0.03 },
|
||
] as const
|
||
type EwmaValue = (typeof EWMA)[number]['value']
|
||
|
||
/** 指数加权移动平均;缺口(没有数据)处断开,缺口后重新开始,不会把缺口两边连在一起。 */
|
||
function ewma(values: (number | null)[], alpha: number): (number | null)[] {
|
||
let state: number | null = null
|
||
return values.map((v) => {
|
||
if (v === null || v === undefined) {
|
||
state = null
|
||
return null
|
||
}
|
||
state = state === null ? v : alpha * v + (1 - alpha) * state
|
||
return state
|
||
})
|
||
}
|
||
|
||
/** 主机状态:实时数据(SSE)每隔几秒来一次,只重绘这一块,不影响上面的图表。 */
|
||
function HostCard({ agentId, range }: { agentId: string | undefined; range: string | null }) {
|
||
const history = useQuery(
|
||
() => (agentId && range ? api.get<HostSeries>(`/api/public/agents/${agentId}/host`, { range }) : Promise.resolve(undefined)),
|
||
[agentId, range],
|
||
30_000,
|
||
)
|
||
const { hosts } = useHosts()
|
||
const host = agentId ? hosts.get(agentId)?.host : undefined
|
||
const data = history.data
|
||
const charts = useMemo(
|
||
() =>
|
||
data
|
||
? {
|
||
usage: [
|
||
{ label: 'CPU', values: data.values['cpu'] ?? [], color: '--chart-1' },
|
||
{ label: '内存', values: data.values['mem'] ?? [], color: '--chart-2' },
|
||
{ label: '磁盘', values: data.values['disk'] ?? [], color: '--chart-4' },
|
||
],
|
||
speed: [
|
||
{ label: '上行', values: data.values['tx_bps'] ?? [], color: '--chart-2' },
|
||
{ label: '下行', values: data.values['rx_bps'] ?? [], color: '--chart-1' },
|
||
],
|
||
}
|
||
: undefined,
|
||
[data],
|
||
)
|
||
if (!host && !data) return null
|
||
return (
|
||
<div className="card">
|
||
<div className="card-title">主机状态</div>
|
||
{host ? (
|
||
<div className="grid stats">
|
||
<Stat label="CPU" value={host.cpu_percent === null ? '—' : host.cpu_percent.toFixed(0)} unit="%" />
|
||
<Stat label="内存" value={host.mem_total ? ((100 * (host.mem_used ?? 0)) / host.mem_total).toFixed(0) : '—'} unit="%" />
|
||
<Stat label="磁盘" value={host.disk_total ? ((100 * (host.disk_used ?? 0)) / host.disk_total).toFixed(0) : '—'} unit="%" />
|
||
<Stat label="累计流量 ↑ / ↓" value={`${fmtBytes(host.net_tx_total)} / ${fmtBytes(host.net_rx_total)}`} />
|
||
</div>
|
||
) : null}
|
||
{data && charts ? (
|
||
<div className="grid cols-2" style={{ marginTop: 16 }}>
|
||
<TimeChart t={data.t} unit="%" series={charts.usage} />
|
||
<TimeChart t={data.t} unit=" B/s" series={charts.speed} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export function Line() {
|
||
const { id = '' } = useParams()
|
||
const [params, setParams] = useSearchParams()
|
||
|
||
const line = useQuery(() => api.get<LineDto>(`/api/public/lines/${encodeURIComponent(id)}`), [id], 15_000)
|
||
const infos = useNodeInfos()
|
||
const ready = Boolean(line.data)
|
||
const sinceMs = line.data?.data_since ? Date.parse(line.data.data_since) : null
|
||
const nowMs = useMemo(() => Date.now(), [line.data])
|
||
const todayStart = dayBounds(nowMs).start
|
||
|
||
const spec = readSpec(params)
|
||
const period = usePeriod(id, ready, sinceMs, nowMs, spec)
|
||
const series = usePeriodSeries(id, ready, period)
|
||
|
||
const update = (fn: (q: URLSearchParams) => void) => {
|
||
const q = new URLSearchParams(params)
|
||
fn(q)
|
||
setParams(q, { replace: true })
|
||
}
|
||
const dayParam = (dayStart: number) => (dayStart === todayStart ? null : dayKey(dayStart))
|
||
const changeDay = (next: { dayStart: number; window: WindowValue | null }) =>
|
||
update((q) => writeSpec(q, { ...readSpec(q), day: dayParam(next.dayStart), range: next.window, at: null }))
|
||
const selectRange = (fromMs: number, toMs: number) =>
|
||
update((q) => {
|
||
const a = Math.round((fromMs - period.dayStart) / MIN)
|
||
const b = Math.round((toMs - period.dayStart) / MIN)
|
||
writeSpec(q, { ...readSpec(q), day: dayParam(period.dayStart), range: null, at: `${a}_${b}` })
|
||
})
|
||
/** 点了一档时长。看的是最新的(今天、选区到现在)就继续看「最近一段」;否则保持选区的起点,往后取这么长。 */
|
||
const pickLength = (w: WindowValue) =>
|
||
update((q) => {
|
||
const base = { ...readSpec(q), day: dayParam(period.dayStart) }
|
||
const info = WINDOWS.find((x) => x.value === w)!
|
||
if (w === 'day' || (period.isToday && (period.live || period.toMs >= nowMs - 2 * MIN))) {
|
||
writeSpec(q, { ...base, range: w, at: null })
|
||
return
|
||
}
|
||
const start = Math.min(period.fromMs, nowMs - info.ms)
|
||
const a = Math.round((start - period.dayStart) / MIN)
|
||
writeSpec(q, { ...base, range: null, at: `${a}_${a + info.ms / MIN}` })
|
||
})
|
||
const setScale = (scale: 24 | 48) =>
|
||
update((q) => {
|
||
const current = readSpec(q)
|
||
// 换回 24 小时:跨零点的选区放不下了,回到默认
|
||
writeSpec(q, { ...current, span: scale === 24 ? '24' : null, at: scale === 24 && period.domainMin > 1440 ? null : current.at })
|
||
})
|
||
|
||
const agentId = line.data?.agent_id
|
||
const lastTestId = line.data?.last_test?.id
|
||
const test = useQuery(() => (lastTestId ? api.get<TestDto>(`/api/public/tests/${lastTestId}`) : Promise.resolve(undefined)), [lastTestId])
|
||
|
||
const smooth = EWMA.find((x) => x.value === params.get('ewma')) ?? EWMA[0]
|
||
const s = series.data
|
||
const m = s?.summary.metrics ?? {}
|
||
const gaps = useMemo(() => (s?.gaps ?? []).map((g) => ({ from: Date.parse(g.from), to: Date.parse(g.to) })), [s])
|
||
|
||
// 图表只在数据变化时更新;别的状态(加载中、实时推送)变化不会让它们闪
|
||
const charts = useMemo(
|
||
() =>
|
||
s
|
||
? GROUPS.map((group) => {
|
||
const cards = CHARTS.filter((def) => def.group === group.id).map((def) => {
|
||
const raw = def.series.map((x) => ({ x, values: s.values[x.key] ?? [] }))
|
||
// 开了 EWMA:原始数据画成淡线垫在后面,平滑后的线画在上面
|
||
const list: ChartSeries[] =
|
||
smooth.alpha > 0
|
||
? [
|
||
...raw.map(({ x, values }) => ({ label: `${x.label}(原始)`, values, color: x.color, faint: true })),
|
||
...raw.map(({ x, values }) => ({ label: x.label, values: ewma(values, smooth.alpha), color: x.color, dash: x.dash, fill: def.fill })),
|
||
]
|
||
: raw.map(({ x, values }) => ({ label: x.label, values, color: x.color, dash: x.dash, fill: def.fill }))
|
||
if (!list.some((x) => x.values.some((v) => v !== null))) return null
|
||
return { def, node: <TimeChart t={s.t} series={list} unit={def.unit} gaps={gaps} zero={def.zero !== false} height={def.wide ? 260 : 220} /> }
|
||
})
|
||
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
||
}).filter((g) => g.cards.length > 0)
|
||
: [],
|
||
[s, gaps, smooth.alpha],
|
||
)
|
||
const loadingNow = !ready || series.busy
|
||
const chartSections = charts.map(({ group, cards }) => (
|
||
<section className="chart-section" key={group.id}>
|
||
<h2>{group.title}</h2>
|
||
<div className="grid cols-2">
|
||
{cards.map(({ def, node }) => (
|
||
<div className={`card chart-card has-loading ${def.wide ? 'wide' : ''}`} key={def.id}>
|
||
<LoadingBar active={loadingNow} />
|
||
<h3>{def.title}</h3>
|
||
{node}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))
|
||
|
||
const result = test.data?.result
|
||
const peak = result?.metrics?.['tcp.bulk.peak.max']
|
||
|
||
return (
|
||
<main className="page">
|
||
<div className="crumbs">
|
||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||
<ChevronLeft size={16} />
|
||
总览
|
||
</Link>
|
||
</div>
|
||
|
||
<section className="hero">
|
||
<h1>
|
||
{line.data?.name ?? '线路'}
|
||
{line.data ? <span className="muted" style={{ fontWeight: 400 }}> → {line.data.probe_name}</span> : null}
|
||
</h1>
|
||
<div className="row" style={{ marginTop: 12 }}>
|
||
{IP_VERSIONS.map((v) => {
|
||
const query = new URLSearchParams(params)
|
||
query.set('ip_version', v.value)
|
||
return (
|
||
<Link key={v.value} className={`btn small${(id.endsWith('~ipv6') ? 'ipv6' : 'ipv4') === v.value ? ' primary' : ''}`} to={`/line/${encodeURIComponent(lineIdFor(id, v.value))}?${query.toString()}`}>
|
||
{v.label}
|
||
</Link>
|
||
)
|
||
})}
|
||
{line.data && !line.data.ipv6_available && id.endsWith('~ipv6') ? <span className="muted">此 Agent 未配置 IPv6 地址</span> : null}
|
||
</div>
|
||
{line.data ? (
|
||
<div className="row wrap" style={{ marginTop: 12 }}>
|
||
<Dot tone={line.data.online && line.data.probe_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||
<span className="muted">{line.data.online ? (line.data.probe_connected ? '在线' : 'Agent 在线,测量通道未建立(Probe 没能和它建立测量会话;机器可能是通的,ping 通不代表测量用的 TCP 和 UDP 通道可用)') : 'Agent 离线'}</span>
|
||
<span className="row" style={{ gap: 6 }}>
|
||
<span className="faint">Agent</span>
|
||
<NodeTag info={infos.get(line.data.agent_id)} />
|
||
</span>
|
||
<span className="row" style={{ gap: 6 }}>
|
||
<span className="faint">Probe</span>
|
||
<NodeTag info={infos.get(line.data.probe_id)} />
|
||
</span>
|
||
{line.data.labels.map((x) => (
|
||
<Chip key={x}>{x}</Chip>
|
||
))}
|
||
{series.loading && !s ? <Spinner /> : null}
|
||
</div>
|
||
) : null}
|
||
<div style={{ marginTop: 16 }}>
|
||
<RangePicker
|
||
dayStart={period.dayStart}
|
||
fromMs={period.fromMs}
|
||
toMs={period.toMs}
|
||
onChange={changeDay}
|
||
onWindow={pickLength}
|
||
scale={period.domainMin > 1440 ? 48 : 24}
|
||
onScale={setScale}
|
||
sinceMs={sinceMs}
|
||
nowMs={nowMs}
|
||
spanMs={period.spanMs}
|
||
>
|
||
<Minimap
|
||
domainStart={period.domainStart}
|
||
domainMin={period.domainMin}
|
||
t={period.overview.t}
|
||
values={period.overview.loss}
|
||
fromMs={period.fromMs}
|
||
toMs={period.toMs}
|
||
loading={!ready || period.busy}
|
||
onSelect={selectRange}
|
||
/>
|
||
</RangePicker>
|
||
</div>
|
||
</section>
|
||
|
||
<PageError error={line.error ?? series.error} />
|
||
|
||
{!s && (loadingNow || !line.error) && !series.error ? (
|
||
// 还没有数据:先摆出占位,每块上面有进度条
|
||
<div className="stack" style={{ marginTop: 24 }}>
|
||
<div className="tiles">
|
||
{Array.from({ length: 12 }, (_, i) => (
|
||
<div className="skeleton has-loading" key={i}>
|
||
<LoadingBar active />
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div className="skeleton chart has-loading">
|
||
<LoadingBar active />
|
||
</div>
|
||
<div className="grid cols-2">
|
||
{[0, 1].map((i) => (
|
||
<div className="skeleton chart has-loading" key={i}>
|
||
<LoadingBar active />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{s ? (
|
||
<div className="stack" style={{ marginTop: 24 }}>
|
||
<div className="tiles">
|
||
<div className="tile scores-tile has-loading">
|
||
<LoadingBar active={loadingNow} />
|
||
<span className="k">评分</span>
|
||
<ScoreChips metrics={m} />
|
||
</div>
|
||
{TILES.filter((t) => !t.optional || m[t.key] != null).map((t) => (
|
||
<Stat key={t.key} label={t.label} value={t.fmt(m[t.key])} unit={t.unit} tone={t.good !== undefined ? tone(m[t.key], t.good, t.poor ?? Infinity) : ''} />
|
||
))}
|
||
</div>
|
||
<p className="faint">
|
||
样本数:UDP {fmtNum(s.summary.samples['udp_echo'])} · TCP {fmtNum(s.summary.samples['tcp_echo'])}
|
||
</p>
|
||
{s.summary.coverage < 0.95 ? <p className="faint">这个时间段的数据覆盖率是 {(s.summary.coverage * 100).toFixed(0)}%,阴影部分是没有数据的时段。</p> : null}
|
||
|
||
<div className="row" style={{ justifyContent: 'flex-end', gap: 8 }}>
|
||
<span className="faint" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">
|
||
EWMA 平滑
|
||
</span>
|
||
<Segmented<EwmaValue>
|
||
options={EWMA.map((x) => ({ value: x.value, label: x.label }))}
|
||
value={smooth.value}
|
||
onChange={(v) => update((q) => (v === 'off' ? q.delete('ewma') : q.set('ewma', v)))}
|
||
/>
|
||
</div>
|
||
{chartSections}
|
||
|
||
<HostCard agentId={agentId} range={period.hostRange} />
|
||
|
||
{test.data && result ? (
|
||
<div className="card">
|
||
<div className="card-title">最近一次测试 · {test.data.plan}</div>
|
||
<div className="grid stats">
|
||
{peak !== undefined ? <Stat label="吞吐峰值" value={fmtMbps(peak)} /> : null}
|
||
{result.metrics['connect.success'] !== undefined ? <Stat label="建连成功率" value={fmtPct(result.metrics['connect.success'], 1)} /> : null}
|
||
{result.metrics['connect.tls_ms.p95'] !== undefined ? <Stat label="TLS 握手 p95" value={fmtMs(result.metrics['connect.tls_ms.p95'])} /> : null}
|
||
{result.metrics['hy2.sustainable_mbps'] !== undefined ? <Stat label="Hy2 可持续码率" value={fmtMbps(result.metrics['hy2.sustainable_mbps'])} /> : null}
|
||
{result.metrics['hy2.qos_onset_mbps'] !== undefined ? <Stat label="限速起点" value={fmtMbps(result.metrics['hy2.qos_onset_mbps'])} /> : null}
|
||
</div>
|
||
<p className="faint" style={{ marginTop: 12 }}>
|
||
{fmtTime(test.data.finished_at)}({fmtAgo(test.data.finished_at)})
|
||
</p>
|
||
</div>
|
||
) : null}
|
||
|
||
{s.events.length > 0 ? (
|
||
<div className="card">
|
||
<div className="card-title">事件</div>
|
||
<div className="event-list">
|
||
{s.events.slice(0, 20).map((e) => (
|
||
<div key={e.id}>
|
||
<span className="faint num" style={{ minWidth: 120 }}>
|
||
{fmtTime(e.at)}
|
||
</span>
|
||
<span>{e.kind}</span>
|
||
{e.detail ? <span className="muted mono">{e.detail}</span> : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
)
|
||
}
|