Files
anonymous_github/public/partials/admin/overview.htm
T
tdurieuxandClaude Opus 5.5 80944954d6 fix: bring the admin pages in line with the rest of the app
- Overview: error-volume bars render (the fills were inline spans), the
  cumulative users chart is scaled to its range and labels it, latencies
  over a second read in seconds, and charts and daily numbers use the
  accent instead of four unrelated colours.
- Labels, table headers and state chips drop the mono caps; queue stat
  values drop the serif.
- Error rows keep their content aligned; row metadata wraps instead of
  truncating every item; "1 term" is singular.
- Errors filter uses the shared segmented control; breadcrumbs include
  Admin; user detail spaces its status and actions, uses the accent for
  Generate and says when no access token is stored.
- The jobs table scrolls inside its card on phones.
- Mock server: admin fixtures for every admin endpoint (ADMIN=1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 14:46:03 +02:00

356 lines
25 KiB
HTML

<div class="container paper-page admin-page overview-page">
<div class="paper-crumbs">Admin &nbsp;/&nbsp; <span class="here">Overview</span></div>
<div class="ov-header">
<h1 class="paper-page-title">Overview</h1>
</div>
<nav class="admin-nav">
<a href="/admin/" class="active"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-chart"></use></svg> Overview</a>
<a href="/admin/repositories"><svg class="ui-icon" aria-hidden="true"><use href="#project-type-repo"></use></svg> Repositories</a>
<a href="/admin/users"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-users"></use></svg> Users</a>
<a href="/admin/conferences"><svg class="ui-icon" aria-hidden="true"><use href="#project-icon-conference"></use></svg> Conferences</a>
<a href="/admin/queues"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-list"></use></svg> Queues</a>
<a href="/admin/errors"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-bug"></use></svg> Errors</a>
</nav>
<div class="admin-empty" v-if="loading">Loading overview&hellip;</div>
<div class="alert alert-danger" style="margin:12px 0" v-if="error"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg> {{ error }}</div>
<div v-if="data">
<!-- ── Top KPI row ──────────────────────────────────────────── -->
<section class="ov-kpi-row">
<div class="ov-kpi-card">
<div class="ov-kpi-label">Repositories <span class="ov-dot ov-dot-ok"></span></div>
<div class="ov-kpi-value">{{ humanNum(data?.repos?.total) }}</div>
<div class="ov-kpi-sub">+{{ data?.repos?.newRepos24h }} &middot; last 24h</div>
</div>
<div class="ov-kpi-card">
<div class="ov-kpi-label">CPU</div>
<div class="ov-kpi-value" :class="{&#x27;ov-val-warn&#x27;: data?.system?.cpuPercent &gt; 80}">{{ data?.system?.cpuPercent }}%</div>
<div class="ov-kpi-sub">{{ data?.system?.cpuCount }} cores &middot; load {{ data?.system?.loadAvg?.[0]?.toFixed(1) }}</div>
</div>
<div class="ov-kpi-card">
<div class="ov-kpi-label">Memory</div>
<div class="ov-kpi-value" :class="{&#x27;ov-val-warn&#x27;: data?.system?.memPercent &gt; 85}">{{ data?.system?.memPercent }}%</div>
<div class="ov-kpi-sub">{{ humanBytes(data?.system?.memUsed) }} / {{ humanBytes(data?.system?.memTotal) }}</div>
</div>
<div class="ov-kpi-card">
<div class="ov-kpi-label">Disk</div>
<div class="ov-kpi-value" :class="{&#x27;ov-val-warn&#x27;: data?.system?.diskPercent &gt; 85}">{{ data?.system?.diskPercent }}%</div>
<div class="ov-kpi-sub">{{ humanBytes(data?.system?.diskUsed) }} / {{ humanBytes(data?.system?.diskTotal) }} &middot; {{ data?.system?.diskMount }}</div>
</div>
<div class="ov-kpi-card">
<div class="ov-kpi-label">Uptime</div>
<div class="ov-kpi-value">{{ humanDuration(data?.system?.uptime) }}</div>
<div class="ov-kpi-sub">{{ data?.system?.nodeVersion }} &middot; {{ data?.system?.platform }}</div>
</div>
</section>
<!-- ── Daily activity highlights ───────────────────────────── -->
<section class="ov-daily-row">
<div class="ov-daily-card">
<div class="ov-daily-label">New repos today</div>
<div class="ov-daily-value">+{{ fmt?.number(data?.daily?.today?.repositories) }}</div>
<div class="ov-daily-sub">since yesterday</div>
</div>
<div class="ov-daily-card">
<div class="ov-daily-label">New users today</div>
<div class="ov-daily-value">+{{ fmt?.number(data?.daily?.today?.users) }}</div>
<div class="ov-daily-sub">{{ fmt?.number(data?.users?.total) }} total users</div>
</div>
<div class="ov-daily-card">
<div class="ov-daily-label">Page views today</div>
<div class="ov-daily-value">+{{ fmt?.number(data?.daily?.today?.pageViews) }}</div>
<div class="ov-daily-sub">since yesterday</div>
</div>
</section>
<!-- ── Daily charts ─────────────────────────────────────────── -->
<section class="ov-chart-row">
<div class="ov-chart-card">
<div class="ov-chart-head">
<span class="ov-chart-title">Daily page views &middot; 30d</span>
<span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-accent"></span>views/day</span>
</div>
<div class="ov-spark-bars" v-if="data?.history?.length">
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :data-tip="(historyLabel(d)) + &quot;: +&quot; + (fmt?.number(d?.dailyPageViews)) + &quot; views&quot;" :aria-label="(historyLabel(d)) + &quot;: +&quot; + (fmt?.number(d?.dailyPageViews)) + &quot; views&quot;">
<span class="ov-spark-fill" :style="{height: historyBarH(d, &#x27;dailyPageViews&#x27;) + &#x27;px&#x27;}"></span>
</div>
</div>
<div class="ov-spark-x" v-if="data?.history?.length">
<span>{{ historyLabel(data?.history[0]) }}</span>
<span>{{ historyLabel(data?.history[Math?.floor(data?.history?.length/2)]) }}</span>
<span>{{ historyLabel(data?.history[data?.history?.length - 1]) }}</span>
</div>
</div>
<div class="ov-chart-card">
<div class="ov-chart-head">
<span class="ov-chart-title">New repos &middot; 30d</span>
<span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-ok-fill"></span>repos/day</span>
</div>
<div class="ov-spark-bars" v-if="data?.history?.length">
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :data-tip="(historyLabel(d)) + &quot;: +&quot; + (fmt?.number(d?.dailyRepositories)) + &quot; repos&quot;" :aria-label="(historyLabel(d)) + &quot;: +&quot; + (fmt?.number(d?.dailyRepositories)) + &quot; repos&quot;">
<span class="ov-spark-fill ov-spark-fill-alt" :style="{height: historyBarH(d, &#x27;dailyRepositories&#x27;) + &#x27;px&#x27;}"></span>
</div>
</div>
<div class="ov-spark-x" v-if="data?.history?.length">
<span>{{ historyLabel(data?.history[0]) }}</span>
<span>{{ historyLabel(data?.history[Math?.floor(data?.history?.length/2)]) }}</span>
<span>{{ historyLabel(data?.history[data?.history?.length - 1]) }}</span>
</div>
</div>
<div class="ov-chart-card">
<div class="ov-chart-head">
<span class="ov-chart-title">Users &middot; 30d</span>
<span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-user-fill"></span>total users &middot; {{ fmt?.number(historyRange(&#x27;nbUsers&#x27;).min) }}&ndash;{{ fmt?.number(historyRange(&#x27;nbUsers&#x27;).max) }}</span>
</div>
<div class="ov-spark-bars" v-if="data?.history?.length">
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :data-tip="(historyLabel(d)) + &quot;: &quot; + (fmt?.number(d?.nbUsers)) + &quot; users&quot;" :aria-label="(historyLabel(d)) + &quot;: &quot; + (fmt?.number(d?.nbUsers)) + &quot; users&quot;">
<span class="ov-spark-fill ov-spark-fill-user" :style="{height: historyRangeH(d, &#x27;nbUsers&#x27;) + &#x27;px&#x27;}"></span>
</div>
</div>
<div class="ov-spark-x" v-if="data?.history?.length">
<span>{{ historyLabel(data?.history[0]) }}</span>
<span>{{ historyLabel(data?.history[Math?.floor(data?.history?.length/2)]) }}</span>
<span>{{ historyLabel(data?.history[data?.history?.length - 1]) }}</span>
</div>
</div>
<div class="ov-chart-card">
<div class="ov-chart-head">
<span class="ov-chart-title">New users &middot; 30d</span>
<span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-new-user-fill"></span>users/day</span>
</div>
<div class="ov-spark-bars" v-if="data?.history?.length">
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :data-tip="(historyLabel(d)) + &quot;: +&quot; + (fmt?.number(d?.dailyUsers)) + &quot; users&quot;" :aria-label="(historyLabel(d)) + &quot;: +&quot; + (fmt?.number(d?.dailyUsers)) + &quot; users&quot;">
<span class="ov-spark-fill ov-spark-fill-new-user" :style="{height: historyBarH(d, &#x27;dailyUsers&#x27;) + &#x27;px&#x27;}"></span>
</div>
</div>
<div class="ov-spark-x" v-if="data?.history?.length">
<span>{{ historyLabel(data?.history[0]) }}</span>
<span>{{ historyLabel(data?.history[Math?.floor(data?.history?.length/2)]) }}</span>
<span>{{ historyLabel(data?.history[data?.history?.length - 1]) }}</span>
</div>
</div>
</section>
<!-- ── Three-panel row: Status / Errors / Queues ────────────── -->
<section class="ov-triple-row">
<!-- Repo status breakdown -->
<div class="ov-panel-card">
<div class="ov-panel-head">
<span class="ov-panel-title">Repo status</span>
<span class="ov-panel-meta">{{ fmt?.number(data?.repos?.total) }} total</span>
</div>
<div class="ov-stacked-bar">
<span class="ov-bar-seg ov-bar-ready" title="Ready" :style="{width: barPct(&#x27;ready&#x27;) + &#x27;%&#x27;}"></span>
<span class="ov-bar-seg ov-bar-preparing" title="Preparing" :style="{width: barPct(&#x27;preparing&#x27;) + &#x27;%&#x27;}"></span>
<span class="ov-bar-seg ov-bar-error" title="Error" :style="{width: barPct(&#x27;error&#x27;) + &#x27;%&#x27;}"></span>
<span class="ov-bar-seg ov-bar-expired" title="Expired" :style="{width: barPct(&#x27;expired&#x27;) + &#x27;%&#x27;}"></span>
<span class="ov-bar-seg ov-bar-removed" title="Removed" :style="{width: barPct(&#x27;removed&#x27;) + &#x27;%&#x27;}"></span>
</div>
<div class="ov-bar-legend">
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-ready"></span> ready <span class="ov-legend-n">{{ fmt?.number(statusCount('ready')) }}</span></a>
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-preparing"></span> preparing <span class="ov-legend-n">{{ fmt?.number(statusCount('preparing') + statusCount('download')) }}</span></a>
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-error"></span> error <span class="ov-legend-n">{{ fmt?.number(statusCount('error')) }}</span></a>
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-expired"></span> expired <span class="ov-legend-n">{{ fmt?.number(statusCount('expired') + statusCount('expiring')) }}</span></a>
<a href="/admin/repositories" class="ov-legend-item"><span class="ov-swatch ov-bar-removed"></span> removed <span class="ov-legend-n">{{ fmt?.number(statusCount('removed') + statusCount('removing')) }}</span></a>
</div>
</div>
<!-- Error breakdown -->
<div class="ov-panel-card">
<div class="ov-panel-head">
<span class="ov-panel-title">Errors &middot; 24h</span>
<span class="ov-panel-meta">{{ data?.errors?.last24h }} total</span>
</div>
<div class="ov-error-bars">
<div class="ov-ebar-row">
<span class="ov-ebar-label">5xx</span>
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-error" :style="{width: errPct(&#x27;error&#x27;) + &#x27;%&#x27;}"></span></span>
<span class="ov-ebar-n">{{ data?.errors?.severity?.error }}</span>
</div>
<div class="ov-ebar-row">
<span class="ov-ebar-label">4xx</span>
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-warn" :style="{width: errPct(&#x27;warn&#x27;) + &#x27;%&#x27;}"></span></span>
<span class="ov-ebar-n">{{ data?.errors?.severity?.warn }}</span>
</div>
<div class="ov-ebar-row">
<span class="ov-ebar-label">Info</span>
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-info" :style="{width: errPct(&#x27;info&#x27;) + &#x27;%&#x27;}"></span></span>
<span class="ov-ebar-n">{{ data?.errors?.severity?.info }}</span>
</div>
</div>
<div class="ov-panel-foot" v-if="data?.repos?.recentErrors24h">
<a href="/admin/errors">{{ data?.repos?.recentErrors24h }} repos in error state &rarr;</a>
</div>
</div>
<!-- Top routes / queues -->
<div class="ov-panel-card">
<div class="ov-panel-head">
<span class="ov-panel-title">Queues</span>
<span class="ov-panel-meta">by state</span>
</div>
<div class="ov-routes-table">
<div class="ov-route-row ov-route-head">
<span class="ov-route-name">Queue</span>
<span class="ov-route-n">Active</span>
<span class="ov-route-n">Wait</span>
<span class="ov-route-n ov-route-lat">Failed</span>
</div>
<a class="ov-route-row" href="/admin/queues">
<span class="ov-route-name">download</span>
<span class="ov-route-n">{{ data?.queues?.download?.active }}</span>
<span class="ov-route-n">{{ data?.queues?.download?.waiting }}</span>
<span class="ov-route-n ov-route-lat" :class="{&#x27;ov-n-bad&#x27;: data?.queues?.download?.failed &gt; 0}">{{ data?.queues?.download?.failed }}</span>
</a>
<a class="ov-route-row" href="/admin/queues">
<span class="ov-route-name">remove</span>
<span class="ov-route-n">{{ data?.queues?.remove?.active }}</span>
<span class="ov-route-n">{{ data?.queues?.remove?.waiting }}</span>
<span class="ov-route-n ov-route-lat" :class="{&#x27;ov-n-bad&#x27;: data?.queues?.remove?.failed &gt; 0}">{{ data?.queues?.remove?.failed }}</span>
</a>
<a class="ov-route-row" href="/admin/queues">
<span class="ov-route-name">cache</span>
<span class="ov-route-n">{{ data?.queues?.cache?.active }}</span>
<span class="ov-route-n">{{ data?.queues?.cache?.waiting }}</span>
<span class="ov-route-n ov-route-lat" :class="{&#x27;ov-n-bad&#x27;: data?.queues?.cache?.failed &gt; 0}">{{ data?.queues?.cache?.failed }}</span>
</a>
</div>
</div>
</section>
<!-- ── Services bar ─────────────────────────────────────────── -->
<section class="ov-services-card">
<div class="ov-services-head">
<span class="ov-panel-title">Services</span>
<span class="ov-panel-meta">{{ fmt?.number(data?.users?.total) }} users &middot; {{ data?.conferences?.total }} conferences</span>
</div>
<div class="ov-services-grid">
<div class="ov-svc">
<span class="ov-svc-dot ov-dot-ok"></span>
<div class="ov-svc-info">
<span class="ov-svc-name">web</span>
<span class="ov-svc-meta">{{ data?.system?.nodeVersion }}</span>
</div>
<span class="ov-svc-detail">uptime {{ humanDuration(data?.system?.uptime) }}</span>
</div>
<div class="ov-svc">
<span class="ov-svc-dot" :class="queueTotal(data?.queues?.download) &gt; 0 ? &#x27;ov-dot-ok&#x27; : &#x27;ov-dot-idle&#x27;"></span>
<div class="ov-svc-info">
<span class="ov-svc-name">download</span>
<span class="ov-svc-meta" v-if="queueTotal(data?.queues?.download)">{{ queueTotal(data?.queues?.download) }} jobs</span>
<span class="ov-svc-meta" v-if="!queueTotal(data?.queues?.download)">idle</span>
</div>
</div>
<div class="ov-svc">
<span class="ov-svc-dot" :class="queueTotal(data?.queues?.cache) &gt; 0 ? &#x27;ov-dot-ok&#x27; : &#x27;ov-dot-idle&#x27;"></span>
<div class="ov-svc-info">
<span class="ov-svc-name">cache</span>
<span class="ov-svc-meta" v-if="queueTotal(data?.queues?.cache)">{{ queueTotal(data?.queues?.cache) }} jobs</span>
<span class="ov-svc-meta" v-if="!queueTotal(data?.queues?.cache)">idle</span>
</div>
</div>
<div class="ov-svc">
<span class="ov-svc-dot" :class="queueTotal(data?.queues?.remove) &gt; 0 ? &#x27;ov-dot-ok&#x27; : &#x27;ov-dot-idle&#x27;"></span>
<div class="ov-svc-info">
<span class="ov-svc-name">remove</span>
<span class="ov-svc-meta" v-if="queueTotal(data?.queues?.remove)">{{ queueTotal(data?.queues?.remove) }} jobs</span>
<span class="ov-svc-meta" v-if="!queueTotal(data?.queues?.remove)">idle</span>
</div>
</div>
<div class="ov-svc">
<span class="ov-svc-dot" :class="data?.repos?.recentErrors24h &gt; 10 ? &#x27;ov-dot-warn&#x27; : &#x27;ov-dot-ok&#x27;"></span>
<div class="ov-svc-info">
<span class="ov-svc-name">errors</span>
<span class="ov-svc-meta">{{ data?.errors?.last24h }} / 24h</span>
</div>
</div>
</div>
</section>
</div>
<section class="ov-panel-card ov-performance" aria-labelledby="performance-heading">
<div class="ov-panel-head ov-performance-head">
<div>
<h2 class="ov-panel-title" id="performance-heading">Request performance</h2>
<p class="ov-performance-note">Request latency and process health &middot; refreshes every 30 seconds</p>
</div>
<div class="ov-performance-window">
<label for="performance-window">Time range</label>
<select id="performance-window" class="form-control form-control-sm" v-model.number="performanceMinutes" @change="loadPerformance()" aria-label="Performance time range">
<option :value="15">Last 15 minutes</option><option :value="60">Last hour</option>
</select>
</div>
</div>
<div v-if="performanceError" class="ov-performance-message ov-performance-warning" role="alert">
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg>
<span>{{ performanceError }}</span>
<button type="button" class="btn btn-sm" @click="loadPerformance()">Retry</button>
</div>
<p v-if="!performance &amp;&amp; !performanceError" class="ov-performance-empty" role="status">Loading performance monitoring&hellip;</p>
<div v-if="performance?.available">
<div class="paper-tabs ov-performance-tabs" role="tablist" aria-label="Performance metrics">
<button id="performance-routes-tab" type="button" class="paper-tab" role="tab" :class="{active: performanceTab === 'routes'}" :aria-selected="performanceTab === 'routes'" :tabindex="performanceTab === 'routes' ? 0 : -1" aria-controls="performance-routes" @click="performanceTab = 'routes'" @keydown="previewTabKeydown($event)">Routes</button>
<button id="performance-processes-tab" type="button" class="paper-tab" role="tab" :class="{active: performanceTab === 'processes'}" :aria-selected="performanceTab === 'processes'" :tabindex="performanceTab === 'processes' ? 0 : -1" aria-controls="performance-processes" @click="performanceTab = 'processes'" @keydown="previewTabKeydown($event)">Processes</button>
<button id="performance-stages-tab" type="button" class="paper-tab" role="tab" :class="{active: performanceTab === 'stages'}" :aria-selected="performanceTab === 'stages'" :tabindex="performanceTab === 'stages' ? 0 : -1" aria-controls="performance-stages" @click="performanceTab = 'stages'" @keydown="previewTabKeydown($event)">Work stages</button>
</div>
<div id="performance-routes" role="tabpanel" aria-labelledby="performance-routes-tab" v-show="performanceTab === 'routes'">
<div class="ov-metrics-scroll" tabindex="0" role="region" aria-label="Route latency metrics, scroll for more columns" v-if="performance.routes?.length">
<table class="ov-metrics-table">
<caption class="sr-only">Route latency for the selected time range</caption>
<thead><tr><th scope="col">Route</th><th scope="col" class="num">Requests</th><th scope="col" class="num">Average</th><th scope="col" class="num">First byte p95</th><th scope="col" class="num">p95</th><th scope="col" class="num">p99</th><th scope="col" class="num">Interrupted</th><th scope="col" class="num">5xx</th><th scope="col" class="num">Slow</th></tr></thead>
<tbody><tr v-for="row in performance.routes" :key="row.service + row.method + row.route">
<th scope="row" class="ov-metric-name"><span>{{ row.route }}</span><small>{{ row.service }} &middot; {{ row.method }}</small></th>
<td class="num">{{ fmt.number(row.count) }}</td><td class="num">{{ formatMs(row.avgMs) }}</td>
<td class="num">{{ firstByteBound(row) }}</td><td class="num">{{ latencyBound(row, 'p95UpperMs') }}</td><td class="num">{{ latencyBound(row, 'p99UpperMs') }}</td>
<td class="num" :class="{'ov-metric-warn': row.aborted > 0}">{{ fmt.number(row.aborted) }}</td><td class="num" :class="{'ov-metric-warn': row.errors > 0}">{{ fmt.number(row.errors) }}</td><td class="num" :class="{'ov-metric-warn': row.slow > 0}">{{ fmt.number(row.slow) }}</td>
</tr></tbody>
</table>
</div>
<p v-else class="ov-performance-empty">No recorded requests in this time range yet.</p>
</div>
<div id="performance-processes" role="tabpanel" aria-labelledby="performance-processes-tab" v-show="performanceTab === 'processes'">
<p class="ov-performance-note">API and streamer processes &middot; latest sample per instance</p>
<div v-if="!performance.instances?.length" class="ov-performance-message ov-performance-warning" role="status">No recent process samples. Monitoring coverage is incomplete.</div>
<div v-else class="ov-metrics-scroll" tabindex="0" role="region" aria-label="Process resource metrics, scroll for more columns">
<table class="ov-metrics-table">
<caption class="sr-only">API and streamer process resource usage</caption>
<thead><tr><th scope="col">Process</th><th scope="col" class="num">RSS / limit</th><th scope="col" class="num">CPU</th><th scope="col" class="num">Event loop p95</th><th scope="col" class="num">Active requests</th><th scope="col" class="num">Sockets / CLOSE_WAIT</th><th scope="col" class="num">Workers / waiting</th><th scope="col" class="num">Sample age</th><th scope="col" class="num">Dropped batches / metrics</th></tr></thead>
<tbody><tr v-for="sample in performance.instances" :key="sample.instance" :class="{'ov-metric-warning-row': performanceWarning(sample)}">
<th scope="row" class="ov-metric-name"><span>{{ sample.instance }}</span><small class="ov-metric-warn" v-if="performanceWarning(sample)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg> Needs attention</small></th>
<td class="num">{{ humanBytes(sample.memory.rss) }} / {{ humanBytes(sample.memoryLimitBytes) }}<small>Peak {{ humanBytes(peakRss(sample)) }}</small></td>
<td class="num">{{ sample.cpuPercent.toFixed(1) }}%</td><td class="num">{{ sample.eventLoop.p95Ms.toFixed(1) }} ms</td>
<td class="num">{{ sample.activeRequests }}</td><td class="num">{{ sample.sockets?.descriptors ?? 'Unavailable' }} / {{ sample.sockets?.closeWait ?? 'Unavailable' }}</td>
<td class="num">{{ sample.workers?.running ?? 0 }} / {{ sample.workers?.waiting ?? 0 }}</td><td class="num">{{ sampleAge(sample) }} s</td>
<td class="num">{{ sample.droppedBatches }} / {{ sample.droppedMetrics }}</td>
</tr></tbody>
</table>
</div>
</div>
<div id="performance-stages" role="tabpanel" aria-labelledby="performance-stages-tab" v-show="performanceTab === 'stages'">
<div v-if="performance.stages?.some(row => row.metric !== 'first_byte')" class="ov-metrics-scroll" tabindex="0" role="region" aria-label="Work stage metrics, scroll for more columns">
<table class="ov-metrics-table">
<caption class="sr-only">Work stages and cache results</caption>
<thead><tr><th scope="col">Work stage</th><th scope="col" class="num">Samples</th><th scope="col" class="num">Average</th><th scope="col" class="num">p95</th></tr></thead>
<tbody><tr v-for="row in performance.stages.filter(row => row.metric !== 'first_byte')" :key="row.service + row.metric">
<th scope="row" class="ov-metric-name"><span>{{ row.metric }}</span><small>{{ row.service }}</small></th><td class="num">{{ fmt.number(row.count) }}</td><td class="num">{{ formatMs(row.avgMs) }}</td><td class="num">{{ latencyBound(row, 'p95UpperMs') }}</td>
</tr></tbody>
</table>
</div>
<p v-else class="ov-performance-empty">No recorded work stages or cache results in this time range yet.</p>
</div>
<details class="ov-performance-help">
<summary>How to read these metrics</summary>
<p>Latency includes upstream waits and response transfer. Percentiles show histogram bucket upper bounds, not exact timings. Process rows marked &ldquo;Needs attention&rdquo; have stale samples, dropped monitoring data, CLOSE_WAIT sockets, high event loop delay, or high memory usage.</p>
</details>
</div>
</section>
</div>