mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
* feat(admin): add a Billing tab for subscriptions and sales
The admin can now follow subscriptions and sales without opening Stripe:
- Summary tiles: active subscriptions (monthly/annual), MRR with annual
plans counted as 1/12, sales and revenue for 7d/30d/90d/12m/all time,
all-time sales, subscriptions cancelling at period end, and refunds
and disputes.
- A stacked chart of sales per day, week or month by plan family.
- A by-plan table: active grants, period and all-time sales and revenue.
- A paginated list of grants with plan, Stripe status, active/expired/
revoked and user filters, linked to the admin user, repository or
conference.
- One row of webhook health: pending, failed, oldest pending event, last
error and unreviewed payment reversals.
A sale is one checkout or subscription, so a paper pack's three grants
count once. Revenue is each sale's Stripe list price, resolved once per
price and cached in memory. Currencies are never added together. Without
Stripe the page shows counts and marks revenue as unavailable.
GET /api/admin/billing/summary and /grants sit behind the admin check.
Grants get a { purchasedAt, _id } index for the list.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: preserve payment history in admin billing totals
* fix: scope billing history and preserve original sale prices
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
429 lines
37 KiB
HTML
429 lines
37 KiB
HTML
<div class="container paper-page admin-page overview-page">
|
|
<header class="page-head">
|
|
<div class="paper-crumbs"><a href="/admin/">Admin</a> / <span class="here">Overview</span></div>
|
|
|
|
<div class="ov-header page-head-main">
|
|
<h1 class="paper-page-title">Overview</h1>
|
|
<span class="ov-live"><span class="ov-live-dot" aria-hidden="true"></span>Live<span class="ov-live-extra"> · refreshes every 30s</span></span>
|
|
</div>
|
|
</header>
|
|
|
|
<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/billing"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-card"></use></svg> Billing</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…</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 class="ov-views" role="tablist" aria-label="Overview sections">
|
|
<button type="button" role="tab" v-for="v in [{id: 'summary', label: 'Summary'}, {id: 'performance', label: 'Performance'}, {id: 'cache', label: 'Cache'}]" :key="v.id" :aria-selected="ovView === v.id ? 'true' : 'false'" :class="{ active: ovView === v.id }" @click="setOvView(v.id)">{{ v.label }}</button>
|
|
</div>
|
|
|
|
<div v-if="data" v-show="ovView === 'summary'">
|
|
|
|
<div class="ov-health" role="status" v-if="data?.system?.memPercent > 90 || data?.system?.diskPercent > 90">
|
|
<svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-warning"></use></svg>
|
|
<span><b>Server needs attention.</b> {{ healthAlert() }}</span>
|
|
</div>
|
|
|
|
<!-- ── Top KPI row ──────────────────────────────────────────── -->
|
|
<section class="ov-kpi-row">
|
|
<div class="ov-kpi-card">
|
|
<div class="ov-kpi-label">Live repositories</div>
|
|
<div class="ov-kpi-value">{{ humanNum((data?.repos?.statusBreakdown || []).filter(s => s._id !== 'removed' && s._id !== 'expired').reduce((a, s) => a + (s.count || 0), 0)) }}</div>
|
|
<div class="ov-kpi-sub">{{ humanNum(((data?.repos?.statusBreakdown || []).find(s => s._id === 'removed') || {}).count || 0) }} removed · {{ humanNum(data?.repos?.total) }} total</div>
|
|
</div>
|
|
<div class="ov-kpi-card">
|
|
<div class="ov-kpi-label">CPU</div>
|
|
<div class="ov-kpi-value" :class="{'ov-val-warn': data?.system?.cpuPercent >= 75}">{{ data?.system?.cpuPercent }}%</div><div class="ov-meter" :class="{ warn: data?.system?.cpuPercent >= 75, crit: data?.system?.cpuPercent > 90 }"><span :style="{ width: Math.min(100, data?.system?.cpuPercent || 0) + '%' }"></span></div>
|
|
<div class="ov-kpi-sub"><b class="ov-flag" :class="{ critical: data?.system?.cpuPercent > 90 }" v-if="data?.system?.cpuPercent >= 75">{{ data?.system?.cpuPercent > 90 ? 'Critical' : 'High' }} · </b>{{ data?.system?.cpuCount }} cores · 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="{'ov-val-warn': data?.system?.memPercent >= 75}">{{ data?.system?.memPercent }}%</div><div class="ov-meter" :class="{ warn: data?.system?.memPercent >= 75, crit: data?.system?.memPercent > 90 }"><span :style="{ width: Math.min(100, data?.system?.memPercent || 0) + '%' }"></span></div>
|
|
<div class="ov-kpi-sub"><b class="ov-flag" :class="{ critical: data?.system?.memPercent > 90 }" v-if="data?.system?.memPercent >= 75">{{ data?.system?.memPercent > 90 ? 'Critical' : 'High' }} · </b>{{ humanBytes(data?.system?.memUsed).replace(/\s*GB$/, '') }} / {{ humanBytes(data?.system?.memTotal) }}</div>
|
|
</div>
|
|
<div class="ov-kpi-card">
|
|
<div class="ov-kpi-label">Disk</div>
|
|
<div class="ov-kpi-value" :class="{'ov-val-warn': data?.system?.diskPercent >= 75}">{{ data?.system?.diskPercent }}%</div><div class="ov-meter" :class="{ warn: data?.system?.diskPercent >= 75, crit: data?.system?.diskPercent > 90 }"><span :style="{ width: Math.min(100, data?.system?.diskPercent || 0) + '%' }"></span></div>
|
|
<div class="ov-kpi-sub"><b class="ov-flag" :class="{ critical: data?.system?.diskPercent > 90 }" v-if="data?.system?.diskPercent >= 75">{{ data?.system?.diskPercent > 90 ? 'Critical' : 'High' }} · </b>{{ humanBytes(data?.system?.diskUsed).replace(/\s*GB$/, '') }} / {{ humanBytes(data?.system?.diskTotal) }}<template v-if="data?.system?.diskMount && data.system.diskMount !== '/'"> · {{ data?.system?.diskMount }}</template></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 }} · {{ 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 || 0) }}</div>
|
|
<div class="ov-daily-sub">so far today</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 || 0) }}</div>
|
|
<div class="ov-daily-sub">{{ fmt?.number(data?.users?.total) }} total · +{{ fmt?.number(usersGained()) }} in 30 days</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 || 0) }}</div>
|
|
<div class="ov-daily-sub">so far today</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Daily charts ─────────────────────────────────────────── -->
|
|
<section class="ov-chart-row" :class="{ 'is-three': usersTiny() }">
|
|
<div class="ov-chart-card">
|
|
<div class="ov-chart-head">
|
|
<span class="ov-chart-title">Daily page views · 30d</span>
|
|
<span class="ov-chart-legends"><span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-accent"></span>views/day · peak {{ fmt?.number(historyMax('dailyPageViews')) }}</span><span class="ov-legend-missing" v-if="historyHasMissing()"><span class="ov-swatch-missing" aria-hidden="true"></span>No data</span></span>
|
|
</div>
|
|
<div class="ov-spark-bars ov-has-axis" v-if="data?.history?.length">
|
|
<div class="ov-yaxis" aria-hidden="true"><span>{{ fmt?.number(axisMax('dailyPageViews')) }}</span><span>{{ fmt?.number(axisMax('dailyPageViews') / 2) }}</span><span>0</span></div>
|
|
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :class="{ 'is-missing': d?._empty }" :data-tip="d?._empty ? historyLabel(d) + ': no data' : (historyLabel(d)) + ": +" + (fmt?.number(d?.dailyPageViews)) + (d?.dailyPageViews === 1 ? " view" : " views")" :aria-label="d?._empty ? historyLabel(d) + ': no data' : (historyLabel(d)) + ": +" + (fmt?.number(d?.dailyPageViews)) + (d?.dailyPageViews === 1 ? " view" : " views")">
|
|
<span class="ov-spark-fill" :style="{height: historyBarH(d, 'dailyPageViews') + 'px'}"></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 · 30d</span>
|
|
<span class="ov-chart-legends"><span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-ok-fill"></span>repos/day · peak {{ fmt?.number(historyMax('dailyRepositories')) }}</span><span class="ov-legend-missing" v-if="historyHasMissing()"><span class="ov-swatch-missing" aria-hidden="true"></span>No data</span></span>
|
|
</div>
|
|
<div class="ov-spark-bars ov-has-axis" v-if="data?.history?.length">
|
|
<div class="ov-yaxis" aria-hidden="true"><span>{{ fmt?.number(axisMax('dailyRepositories')) }}</span><span>{{ fmt?.number(axisMax('dailyRepositories') / 2) }}</span><span>0</span></div>
|
|
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :class="{ 'is-missing': d?._empty }" :data-tip="d?._empty ? historyLabel(d) + ': no data' : (historyLabel(d)) + ": +" + (fmt?.number(d?.dailyRepositories)) + (d?.dailyRepositories === 1 ? " repo" : " repos")" :aria-label="d?._empty ? historyLabel(d) + ': no data' : (historyLabel(d)) + ": +" + (fmt?.number(d?.dailyRepositories)) + (d?.dailyRepositories === 1 ? " repo" : " repos")">
|
|
<span class="ov-spark-fill ov-spark-fill-alt" :style="{height: historyBarH(d, 'dailyRepositories') + 'px'}"></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" v-if="!usersTiny()">
|
|
<div class="ov-chart-head">
|
|
<span class="ov-chart-title">Users · 30d</span>
|
|
<span class="ov-chart-legends"><span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-user-fill"></span>total users · {{ fmt?.number(historyRange('nbUsers').max) }} now · +{{ fmt?.number(Math.max(0, historyRange('nbUsers').max - usersFirst())) }} in 30 days</span><span class="ov-legend-missing" v-if="historyHasMissing()"><span class="ov-swatch-missing" aria-hidden="true"></span>No data</span></span>
|
|
</div>
|
|
<div class="ov-spark-bars ov-spark-step ov-has-axis" v-if="data?.history?.length">
|
|
<div class="ov-yaxis" aria-hidden="true"><span>{{ fmt?.number(userAxis().hi) }}</span><span>{{ fmt?.number((userAxis().hi + userAxis().lo) / 2) }}</span><span>{{ fmt?.number(userAxis().lo) }}</span></div>
|
|
<svg class="ov-step-svg" :viewBox="'0 0 ' + data.history.length + ' 140'" preserveAspectRatio="none" role="img" :aria-label="'Total users over 30 days, from ' + fmt?.number(usersFirst()) + ' to ' + fmt?.number(historyRange('nbUsers').max)">
|
|
<path class="ov-step-area" v-if="userAxis().lo === 0" :d="userStepPath(true)"></path>
|
|
<path class="ov-step-line" :d="userStepPath(false)" vector-effect="non-scaling-stroke"></path>
|
|
</svg>
|
|
<div class="ov-step-missing" v-if="firstDataIndex() > 0" :style="{width: (firstDataIndex() / data.history.length * 100) + '%'}"></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 · 30d</span>
|
|
<span class="ov-chart-legends"><span class="ov-chart-legend"><span class="ov-dot-legend ov-dot-new-user-fill"></span>users/day · peak {{ fmt?.number(historyMax('dailyUsers')) }}</span><span class="ov-legend-missing" v-if="historyHasMissing()"><span class="ov-swatch-missing" aria-hidden="true"></span>No data</span></span>
|
|
</div>
|
|
<div class="ov-spark-bars ov-has-axis" v-if="data?.history?.length">
|
|
<div class="ov-yaxis" aria-hidden="true"><span>{{ fmt?.number(axisMax('dailyUsers')) }}</span><span>{{ fmt?.number(axisMax('dailyUsers') / 2) }}</span><span>0</span></div>
|
|
<div class="ov-spark-col has-tip" v-for="(d, index) in data?.history" :key="index" :class="{ 'is-missing': d?._empty }" :data-tip="d?._empty ? historyLabel(d) + ': no data' : (historyLabel(d)) + ": +" + (fmt?.number(d?.dailyUsers)) + (d?.dailyUsers === 1 ? " user" : " users")" :aria-label="d?._empty ? historyLabel(d) + ': no data' : (historyLabel(d)) + ": +" + (fmt?.number(d?.dailyUsers)) + (d?.dailyUsers === 1 ? " user" : " users")">
|
|
<span class="ov-spark-fill ov-spark-fill-new-user" :style="{height: historyBarH(d, 'dailyUsers') + 'px'}"></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" v-if="barPct('ready') > 0" title="Ready" :style="{width: barPct('ready') + '%'}"></span>
|
|
<span class="ov-bar-seg ov-bar-preparing" v-if="barPct('preparing') > 0" title="Preparing" :style="{width: barPct('preparing') + '%'}"></span>
|
|
<span class="ov-bar-seg ov-bar-error" v-if="barPct('error') > 0" title="Error" :style="{width: barPct('error') + '%'}"></span>
|
|
<span class="ov-bar-seg ov-bar-expired" v-if="barPct('expired') > 0" title="Expiring / expired" :style="{width: barPct('expired') + '%'}"></span>
|
|
<span class="ov-bar-seg ov-bar-removed" v-if="barPct('removed') > 0" title="Removing / removed" :style="{width: barPct('removed') + '%'}"></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> Expiring / 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> Removing / 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 · 24h</span>
|
|
<span class="ov-panel-meta">{{ fmt?.number(realErrors()) }} {{ realErrors() === 1 ? 'error' : 'errors' }}</span>
|
|
</div>
|
|
<div class="ov-error-bars">
|
|
<div class="ov-ebar-row">
|
|
<span class="ov-ebar-label">Error</span>
|
|
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-error" :style="{width: errPct('error') + '%'}"></span></span>
|
|
<span class="ov-ebar-n">{{ data?.errors?.severity?.error }}</span>
|
|
</div>
|
|
<div class="ov-ebar-row">
|
|
<span class="ov-ebar-label">Warning</span>
|
|
<span class="ov-ebar-track"><span class="ov-ebar-fill ov-ebar-warn" :style="{width: errPct('warn') + '%'}"></span></span>
|
|
<span class="ov-ebar-n">{{ data?.errors?.severity?.warn }}</span>
|
|
</div>
|
|
<div class="ov-ehours" v-if="errorHours().length" role="img" :aria-label="'Errors per hour over the last 24 hours'">
|
|
<div class="ov-ehours-legend"><span class="ov-ehours-key"><span class="ov-ehours-swatch is-error" aria-hidden="true"></span>Error</span><span class="ov-ehours-key"><span class="ov-ehours-swatch is-warn" aria-hidden="true"></span>Warning</span><span class="ov-ehours-note">Per hour<span class="seg-sep" aria-hidden="true">·</span>peak {{ fmt?.number(errorHoursPeak()) }}</span></div>
|
|
<div class="ov-ehours-bars"><span v-for="h in errorHours()" :key="h.key" :class="{ 'is-bad': h.bad, 'is-zero': h.zero }" :title="h.title"><i class="ov-ehours-zero" v-if="h.zero"></i><template v-else><i class="ov-ehours-e" v-if="h.errPct" :style="{ height: h.errPct + '%' }"></i><i class="ov-ehours-w" v-if="h.warnPct" :style="{ height: h.warnPct + '%' }"></i></template></span></div>
|
|
<div class="ov-ehours-x"><span>24h ago</span><span>now</span></div>
|
|
</div>
|
|
<p class="ov-ebar-note">Info {{ fmt?.number(data?.errors?.severity?.info || 0) }} · logged, not counted as errors</p>
|
|
</div>
|
|
<div class="ov-panel-foot" v-if="data?.repos?.recentErrors24h">
|
|
<a href="/admin/errors">{{ data?.repos?.recentErrors24h }} repos in error state →</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="{'ov-n-bad': data?.queues?.download?.failed > 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="{'ov-n-bad': data?.queues?.remove?.failed > 0}">{{ data?.queues?.remove?.failed }}</span>
|
|
</a>
|
|
<a class="ov-route-row" href="/admin/queues">
|
|
<span class="ov-route-name">Cache cleanup</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="{'ov-n-bad': data?.queues?.cache?.failed > 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">5 services · {{ realErrors() > 0 ? '1 needs attention' : 'all healthy' }}</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 }} · up {{ humanDuration(data?.system?.uptime) }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="ov-svc">
|
|
<span class="ov-svc-dot" :class="queueTotal(data?.queues?.download) > 0 ? 'ov-dot-ok' : 'ov-dot-idle'"></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) }} {{ queueTotal(data?.queues?.download) === 1 ? 'job' : '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) > 0 ? 'ov-dot-ok' : 'ov-dot-idle'"></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) }} {{ queueTotal(data?.queues?.cache) === 1 ? 'job' : '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) > 0 ? 'ov-dot-ok' : 'ov-dot-idle'"></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) }} {{ queueTotal(data?.queues?.remove) === 1 ? 'job' : '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="realErrors() > 0 ? 'ov-dot-warn' : 'ov-dot-ok'" :title="realErrors() > 0 ? 'Errors in the last 24 hours' : 'No errors in the last 24 hours'"></span>
|
|
<div class="ov-svc-info">
|
|
<span class="ov-svc-name">errors</span>
|
|
<span class="ov-svc-meta">{{ fmt?.number(realErrors()) }} / 24h</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
</div>
|
|
|
|
<section class="ov-panel-card ov-cache" aria-labelledby="cache-heading" v-show="ovView === 'cache'">
|
|
<div class="ov-panel-head ov-performance-head">
|
|
<div><h2 class="ov-panel-title" id="cache-heading">Cache</h2>
|
|
<p class="ov-performance-note">Shared storage, repeated reads and anonymization work</p></div>
|
|
<div class="ov-performance-window">
|
|
<label for="cache-window">Time range</label>
|
|
<select id="cache-window" class="form-control form-control-sm" v-model.number="cacheMinutes" @change="loadCache()">
|
|
<option :value="15">Last 15 minutes</option><option :value="60">Last hour</option>
|
|
<option :value="1440">Last 24 hours</option><option :value="10080">Last 7 days</option>
|
|
</select>
|
|
<label for="cache-traffic">Traffic</label>
|
|
<select id="cache-traffic" class="form-control form-control-sm" v-model="cacheTraffic">
|
|
<option value="all">All traffic</option><option value="file">File reads</option><option value="website">Website rendering</option>
|
|
<option value="zip">ZIP downloads</option><option value="monitor">Known monitors</option><option value="background">Background work</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div v-if="cacheError" class="ov-performance-message ov-performance-warning" role="alert">
|
|
<span>{{ cacheError }}</span><button type="button" class="btn btn-sm" @click="loadCache()">Retry</button>
|
|
</div>
|
|
<p v-if="!cacheReport && !cacheError" class="ov-performance-empty" role="status">Loading cache monitoring…</p>
|
|
<template v-if="cacheReport?.available">
|
|
<p v-if="!cacheReport.enabled" class="ov-performance-note">Shared blob storage is disabled. Inspection and legacy-cache activity are still measured.</p>
|
|
<p v-if="!cacheReport.filesystemSupported" class="ov-performance-note">Shared blobs require a local filesystem. The configured storage continues to use its existing cache.</p>
|
|
<p v-if="cacheReport.dropped" class="ov-performance-message ov-performance-warning" role="status">{{ cacheReport.dropped }} telemetry events were dropped. Totals may be incomplete.</p>
|
|
<div class="ov-metrics-scroll" tabindex="0" role="region" aria-label="Cache storage budgets" v-if="cacheReport.source">
|
|
<table class="ov-metrics-table">
|
|
<caption class="sr-only">Current shared-cache storage and byte budgets</caption>
|
|
<thead><tr><th scope="col">Pool</th><th scope="col" class="num">Allocated / budget</th><th scope="col" class="num">Reserved for fills</th><th scope="col" class="num">Original bytes</th><th scope="col" class="num">Blobs</th><th scope="col" class="num">Active readers</th></tr></thead>
|
|
<tbody><tr v-for="pool in [{name: 'Source blobs', data: cacheReport.source}, {name: 'Transformed output', data: cacheReport.results}]" :key="pool.name" v-show="pool.data">
|
|
<th scope="row" class="ov-event-name">{{ pool.name }}</th><td class="num budget-cell" :class="{ 'is-over': pool.data?.budgetBytes && pool.data?.allocatedBytes > pool.data?.budgetBytes }" :title="pool.data?.budgetBytes ? Math.round(100 * (pool.data?.allocatedBytes || 0) / pool.data.budgetBytes) + '% of budget' : null"><span class="ov-metric-bad-text" v-if="pool.data?.budgetBytes && pool.data?.allocatedBytes > pool.data?.budgetBytes">Over budget · </span>{{ humanBytes(pool.data?.allocatedBytes) }} / {{ humanBytes(pool.data?.budgetBytes) }}<span class="budget-bar" v-if="pool.data?.budgetBytes" aria-hidden="true"><i :style="{ width: Math.min(100, Math.round(100 * (pool.data?.allocatedBytes || 0) / pool.data.budgetBytes)) + '%' }"></i></span></td>
|
|
<td class="num">{{ humanBytes(pool.data?.reservedBytes) }}</td><td class="num">{{ humanBytes(pool.data?.originalBytes) }}</td>
|
|
<td class="num">{{ fmt?.number(pool.data?.blobs) }}</td><td class="num">{{ fmt?.number(pool.data?.activeReaders) }}</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
<dl class="ov-cache-rates" v-if="cacheReport.source">
|
|
<div><dt>Source request hit rate</dt><dd>{{ cacheRate('source', 'count') }}</dd></div>
|
|
<div><dt>Source byte hit rate</dt><dd>{{ cacheRate('source', 'bytes') }}</dd></div>
|
|
<div><dt>Decision hit rate</dt><dd>{{ cacheRate('decision', 'count') }}</dd></div>
|
|
</dl>
|
|
<p class="ov-performance-note">Reuse counts measure consecutive reads of the same blob within each interval. Codec timings are elapsed stream time, not CPU time; eviction times measure how long a blob was idle. Known monitors are identified by request metadata; other automation may remain in file reads.</p>
|
|
<div v-if="cacheRows().length" class="ov-metrics-scroll" tabindex="0" role="region" aria-label="Cache events and processing durations">
|
|
<table class="ov-metrics-table">
|
|
<caption class="sr-only">Cache events for the selected time range and traffic</caption>
|
|
<thead><tr><th scope="col">Event</th><th scope="col">Traffic</th><th scope="col" class="num">Count</th><th scope="col" class="num">Bytes</th><th scope="col" class="num">Mean measured time</th><th scope="col" class="num">p95 bound</th></tr></thead>
|
|
<tbody><tr v-for="row in cacheRows()" :key="row.name + ':' + row.traffic">
|
|
<th scope="row" class="ov-event-name">{{ cacheEventName(row.name) }}</th><td class="ov-event-traffic">{{ row.traffic }}</td><td class="num">{{ fmt?.number(row.count) }}</td><td class="num">{{ humanBytes(row.bytes) }}</td>
|
|
<td class="num" :class="{ 'ov-muted': !row.ms }">{{ row.ms ? fmt.duration(row.ms / row.count) : "Not timed" }}</td><td class="num" :class="{ 'ov-muted': !row.ms }">{{ cacheP95(row) }}</td></tr></tbody>
|
|
</table>
|
|
</div>
|
|
<p v-else class="ov-performance-empty" role="status">No cache events in this time range for the selected traffic.</p>
|
|
<p class="ov-performance-note">Updated {{ sampleAge(cacheReport) }} seconds ago. Minute history is retained for 48 hours; hourly history for 30 days.</p>
|
|
</template>
|
|
</section>
|
|
|
|
<section class="ov-panel-card ov-performance" aria-labelledby="performance-heading" v-show="ovView === 'performance'">
|
|
<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</p>
|
|
</div>
|
|
<div class="ov-performance-window">
|
|
<template v-if="performance?.available"><label for="performance-view">Show</label>
|
|
<select id="performance-view" class="form-control form-control-sm" v-model="performanceTab" aria-label="Performance metrics">
|
|
<option value="routes">Routes</option><option value="processes">Processes</option><option value="stages">Work stages</option>
|
|
</select></template>
|
|
<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 && !performanceError" class="ov-performance-empty" role="status">Loading performance monitoring…</p>
|
|
<div v-if="performance?.available">
|
|
<div id="performance-routes" role="region" aria-label="Routes" 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 }} · {{ 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-bad': 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="region" aria-label="Processes" v-show="performanceTab === 'processes'">
|
|
<p class="ov-performance-note">API and streamer processes · 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 == null ? 'Unavailable' : sample.cpuPercent.toFixed(1) + '%' }}</td><td class="num">{{ sample.eventLoop?.p95Ms == null ? 'Not applicable' : fmt.duration(sample.eventLoop.p95Ms) }}</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="region" aria-label="Work stages" 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 “Needs attention” have stale samples, dropped monitoring data, CLOSE_WAIT sockets, high event loop delay, or high memory usage.</p>
|
|
</details>
|
|
</div>
|
|
</section>
|
|
</div>
|