384 lines
21 KiB
TypeScript
384 lines
21 KiB
TypeScript
import { ChevronLeft } from 'lucide-react'
|
||
import { useMemo, useState } 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 { useQuery } from '@shared/hooks'
|
||
import { Chip, Dot, LoadingBar, Segmented, Spinner, Stat } from '@shared/ui'
|
||
import { PageError, ScoreChips } from '../lib'
|
||
import { AdaptiveAudit } from '../AdaptiveAudit'
|
||
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'
|
||
}
|
||
|
||
/** 指标卡片:好坏的分界(越低越好)和显示格式。 */
|
||
type MetricGroup = 'quic' | 'tcp-connect' | 'score'
|
||
|
||
const METRIC_GROUPS: { value: MetricGroup; label: string }[] = [
|
||
{ value: 'quic', label: 'QUIC' },
|
||
{ value: 'tcp-connect', label: 'TCP' },
|
||
{ value: 'score', label: '评分' },
|
||
]
|
||
|
||
const QUIC_METRICS = 'rtt.p50,rtt.p95,rtt.p99,loss.total,loss.up,loss.down,loss.run.up_max,loss.run.down_max,jitter.up.p95,jitter.down.p95'
|
||
|
||
const TILES: { group: Exclude<MetricGroup, 'score'>; label: string; key: string; fmt: (v: number | null | undefined) => string; good?: number; poor?: number; unit?: string; /** 这个线路没有数据时不显示(建连任务不是每条线路都开着)。 */ optional?: boolean }[] = [
|
||
{ group: 'tcp-connect', label: 'TCP RTT p50', key: 'tcp.rtt.p50', fmt: fmtMs },
|
||
{ group: 'tcp-connect', label: 'TCP RTT p99', key: 'tcp.rtt.p99', fmt: fmtMs },
|
||
{ group: 'tcp-connect', label: 'TCP RTT 最大', key: 'tcp.rtt.max', fmt: fmtMs },
|
||
{ group: 'tcp-connect', label: 'TCP 去程重传率', key: 'tcp.retrans.up', fmt: fmtPct, good: 0.5, poor: 3 },
|
||
{ group: 'tcp-connect', label: 'TCP 回程重传率', key: 'tcp.retrans.down', fmt: fmtPct, good: 0.5, poor: 3 },
|
||
{ group: 'tcp-connect', label: 'TCP 建连 p95', key: 'connect.tcp_ms.p95', fmt: fmtMs, optional: true },
|
||
{ group: 'tcp-connect', label: 'TLS 握手 p95', key: 'connect.tls_ms.p95', fmt: fmtMs, optional: true },
|
||
{ group: 'tcp-connect', 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: MetricGroup
|
||
/** 占满一行。 */
|
||
wide?: boolean
|
||
/** 没有才是好的指标:整段为 0 时图上显示的说明 */
|
||
quiet?: string
|
||
}
|
||
|
||
const GROUPS = [
|
||
{ id: 'quic', title: 'QUIC Datagram' },
|
||
{ id: 'tcp-connect', title: 'TCP' },
|
||
{ id: 'score', title: '评分' },
|
||
] as const
|
||
|
||
const CHARTS: ChartDef[] = [
|
||
{ id: 'rtt', title: '延迟', group: 'quic', 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: 'jitter', title: '抖动(p95)', group: 'quic', unit: 'ms', series: [{ key: 'jitter.up.p95', label: '去程', color: '--chart-2' }, { key: 'jitter.down.p95', label: '回程', color: '--chart-1' }] },
|
||
{ id: 'loss', title: '丢包', group: 'quic', unit: '%', fill: true, quiet: '✓ 这段时间没有丢包', 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: 'loss-runs', title: '最长连续丢包', group: 'quic', unit: '包', fill: true, quiet: '✓ 这段时间没有连续丢包', series: [{ key: 'loss.run.up_max', label: '去程', color: '--chart-2' }, { key: 'loss.run.down_max', label: '回程', color: '--chart-3' }] },
|
||
{ id: 'tcp', title: '往返时延(RTT)', group: 'tcp-connect', 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-connect', unit: '%', fill: true, quiet: '✓ 这段时间没有重传', series: [{ key: 'tcp.retrans.up', label: '去程', color: '--chart-2' }, { key: 'tcp.retrans.down', label: '回程', color: '--chart-3' }, { key: 'tcp.retrans', label: '合计', color: '--chart-1', dash: [4, 3] }] },
|
||
{ id: 'connect-time', title: '建连耗时(p95)', group: 'tcp-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: 'tcp-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
|
||
})
|
||
}
|
||
|
||
export function Line() {
|
||
const { id = '' } = useParams()
|
||
const [params, setParams] = useSearchParams()
|
||
const [metricGroup, setMetricGroup] = useState<MetricGroup>('quic')
|
||
|
||
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,
|
||
metricGroup === 'score' ? 'scores' : metricGroup === 'tcp-connect' ? 'tcp,connect' : metricGroup,
|
||
metricGroup === 'quic' ? QUIC_METRICS : undefined,
|
||
)
|
||
|
||
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 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.filter((group) => group.id === metricGroup).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} quiet={def.quiet} gaps={gaps} zero={def.zero !== false} height={metricGroup === 'quic' ? 220 : def.wide ? 230 : 190} /> }
|
||
})
|
||
return { group, cards: cards.filter((c): c is NonNullable<typeof c> => c !== null) }
|
||
}).filter((g) => g.cards.length > 0)
|
||
: [],
|
||
[s, gaps, smooth.alpha, metricGroup],
|
||
)
|
||
const loadingNow = !ready || series.busy
|
||
const chartSections = charts.map(({ group, cards }) => (
|
||
<section className="chart-section" key={group.id}>
|
||
<div className={`grid cols-2 ${group.id === 'quic' ? 'quic-chart-grid' : ''}`}>
|
||
{cards.map(({ def, node }) => (
|
||
<div className={`card chart-card has-loading ${def.wide || cards.length === 1 ? '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 line-page">
|
||
<div className="crumbs">
|
||
<Link to={id.endsWith('~ipv6') ? '/?ip_version=ipv6' : '/'} className="row">
|
||
<ChevronLeft size={16} />
|
||
总览
|
||
</Link>
|
||
</div>
|
||
|
||
<section className="hero line-header-surface">
|
||
<div className="line-title-row">
|
||
<h1>
|
||
{line.data?.dialer_name ?? '线路'}
|
||
{line.data ? <span className="muted line-peer-name"> → {line.data.name}</span> : null}
|
||
</h1>
|
||
<div className="row line-version-switch">
|
||
{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>
|
||
</div>
|
||
{line.data ? (
|
||
<div className="row wrap line-route-meta">
|
||
<Dot tone={line.data.online && line.data.dialer_connected ? 'ok' : line.data.online ? 'warn' : 'bad'} />
|
||
<span className="line-route-status">{line.data.online ? (line.data.dialer_connected ? '在线' : '测量未连接') : '离线'}</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">来源 Agent</span>
|
||
<NodeTag info={infos.get(line.data.dialer_id)} />
|
||
</span>
|
||
{line.data.labels.map((x) => (
|
||
<Chip key={x}>{x}</Chip>
|
||
))}
|
||
{series.loading && !s ? <Spinner /> : null}
|
||
</div>
|
||
) : null}
|
||
<div className="line-range-picker">
|
||
<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}
|
||
toolbar={
|
||
<div className="line-toolbar-group line-smoothing">
|
||
<span className="line-section-label" title="指数加权移动平均:把线条磨平,看清趋势;原始数据以淡线垫在后面">平滑</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>
|
||
}
|
||
>
|
||
<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="line-loading-state has-loading" role="status" aria-live="polite">
|
||
<LoadingBar active />
|
||
<Spinner />
|
||
<span>正在加载线路数据…</span>
|
||
</div>
|
||
) : null}
|
||
|
||
{!s && !loadingNow && !series.error && line.data ? <div className="line-empty-state">所选时间段暂无测量数据。</div> : null}
|
||
|
||
{s ? (
|
||
<div className="stack line-metrics" style={{ marginTop: 16 }}>
|
||
<section className="line-metric-surface">
|
||
<header className="line-metric-header">
|
||
<div className="line-metric-heading">
|
||
<h2>{GROUPS.find((group) => group.id === metricGroup)?.title ?? '线路指标'}</h2>
|
||
{metricGroup === 'quic' ? <span>往返时延 · 双向丢包 · 抖动</span> : null}
|
||
</div>
|
||
<Segmented<MetricGroup> options={METRIC_GROUPS} value={metricGroup} onChange={setMetricGroup} />
|
||
</header>
|
||
{metricGroup === 'quic' ? <>
|
||
<div className="line-quic-summary">
|
||
<div className="line-combo-card">
|
||
<h3>QUIC RTT</h3>
|
||
<div className="line-combo-primary"><span>p50</span><strong>{fmtMs(m['rtt.p50'])}</strong></div>
|
||
<div className="line-combo-secondary"><span>p95 <b>{fmtMs(m['rtt.p95'])}</b></span><span className="combo-divider">|</span><span>p99 <b>{fmtMs(m['rtt.p99'])}</b></span></div>
|
||
</div>
|
||
<div className="line-combo-card">
|
||
<h3>QUIC Datagram 丢包</h3>
|
||
<div className="line-combo-primary"><span>总丢包</span><strong className={`combo-tone-${tone(m['loss.total'], 0.5, 3)}`}>{fmtPct(m['loss.total'])}</strong></div>
|
||
<div className="line-combo-secondary"><span>去 {fmtPct(m['loss.up'])}</span><span>/</span><span>回 {fmtPct(m['loss.down'])}</span></div>
|
||
<span className="line-combo-badge">最长连续 ↑ {fmtNum(m['loss.run.up_max'])} / ↓ {fmtNum(m['loss.run.down_max'])} 包</span>
|
||
</div>
|
||
<div className="line-combo-card">
|
||
<h3>QUIC 抖动 p95</h3>
|
||
<div className="line-jitter-pair"><span>↑ <b>{fmtMs(m['jitter.up.p95'])}</b></span><span>↓ <b>{fmtMs(m['jitter.down.p95'])}</b></span></div>
|
||
</div>
|
||
</div>
|
||
<div className="line-session-bar" title="会话计数从 来源 Agent 本次启动开始;传输疑似丢包率不等同于 Datagram 应用丢包率,连接迁移成功率暂不采集。">
|
||
<strong className="line-session-title">QUIC 会话与传输</strong>
|
||
{line.data?.quic_health ? (() => {
|
||
const h = line.data.quic_health!
|
||
return <>
|
||
<div className="line-session-items">
|
||
<span>握手 p95 <b>{fmtMs(h.handshake_p95_ms)}</b></span>
|
||
<span>成功率 <b>{fmtPct(h.handshake_success_rate)}</b></span>
|
||
<span>超时 <b>{fmtPct(h.handshake_timeout_rate)}</b></span>
|
||
<span>失败 <b>{fmtPct(h.handshake_failure_rate)}</b></span>
|
||
<span>传输疑似丢包 <b>{fmtPct(h.transport_packet_loss_rate)}</b></span>
|
||
<span>路径 MTU <b>{fmtNum(h.path_mtu)} B</b></span>
|
||
</div>
|
||
</>
|
||
})() : <span className="line-session-empty">来源 Agent 尚未上报会话数据</span>}
|
||
</div>
|
||
</> : null}
|
||
{metricGroup !== 'quic' ? <div className="tiles">
|
||
{metricGroup === 'score' ? <div className="tile scores-tile has-loading"><LoadingBar active={loadingNow} /><span className="k">评分</span><ScoreChips metrics={m} /></div> : null}
|
||
{TILES.filter((t) => t.group === metricGroup && (!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> : null}
|
||
<div className="line-data-notes">
|
||
<span>样本数:QUIC Datagram {fmtNum(s.summary.samples['quic_ping'])} · TCP {fmtNum(s.summary.samples['tcp_ping'])}</span>
|
||
{s.summary.coverage < 0.95 ? <span>数据覆盖率 {(s.summary.coverage * 100).toFixed(0)}%;阴影部分为无数据时段</span> : null}
|
||
</div>
|
||
{chartSections}
|
||
</section>
|
||
|
||
<AdaptiveAudit lineId={id} />
|
||
|
||
{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}
|
||
|
||
</div>
|
||
) : null}
|
||
</main>
|
||
)
|
||
}
|