Files
Thomas DurieuxandClaude Opus 5.5 511dcd7067 feat(admin): billing tab to monitor subscriptions and sales (#952)
* 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>
2026-11-13 14:37:29 +01:00

205 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<div class="container paper-page admin-page errors-page">
<div class="paper-crumbs">Admin &nbsp;/&nbsp; <span class="here">Errors</span></div>
<header class="errors-header">
<h1 class="paper-page-title">Errors</h1>
<div class="errors-actions">
<button class="btn btn-sm" type="button" @click="exportCsv()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-export"></use></svg> Export CSV</button>
<button class="btn btn-sm btn-outline-danger" type="button" @click="clearAll()"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-trash"></use></svg> Clear all</button>
</div>
</header>
<nav class="admin-nav">
<a href="/admin/"><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" class="active"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-bug"></use></svg> Errors</a>
</nav>
<section class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label">Occurrences · 24h</div>
<div class="kpi-value">{{ fmt?.number(stats?.last24h ?? 0) }}</div>
<div class="kpi-sub" :class="{up: stats?.delta &gt; 0, down: stats?.delta &lt; 0}">
<span v-if="stats?.prev24h &gt;= 20">{{ (stats?.last24h ?? 0) - (stats?.prev24h ?? 0) &gt;= 0 ? '+' : '' }}{{ Math.round(((stats?.last24h ?? 0) - (stats?.prev24h ?? 0)) / stats.prev24h * 100) }}% vs yesterday</span><span v-else-if="stats?.prev24h !== undefined">{{ fmt?.number(stats?.prev24h ?? 0) }} in prior 24h</span>
<span v-if="!stats?.prev24h">no prior baseline</span>
</div>
</div>
<div class="kpi-card kpi-error kpi-filter" role="button" tabindex="0" :class="{ 'is-selected': query?.bucket === 'error' }" :aria-pressed="query?.bucket === 'error'" :title="'Show only errors (5xx)'" @click="setBucket(query?.bucket === 'error' ? '' : 'error')" @keydown.enter.prevent="setBucket(query?.bucket === 'error' ? '' : 'error')">
<div class="kpi-label">Error</div>
<div class="kpi-value">{{ fmt?.number(stats?.severity?.error ?? 0) }}</div>
<div class="kpi-sub">5xx · {{ stats?.unique?.error }} unique</div>
</div>
<div class="kpi-card kpi-warn kpi-filter" role="button" tabindex="0" :class="{ 'is-selected': query?.bucket === 'warn' }" :aria-pressed="query?.bucket === 'warn'" :title="'Show only warnings (4xx, aborted requests)'" @click="setBucket(query?.bucket === 'warn' ? '' : 'warn')" @keydown.enter.prevent="setBucket(query?.bucket === 'warn' ? '' : 'warn')">
<div class="kpi-label">Warning</div>
<div class="kpi-value">{{ fmt?.number(stats?.severity?.warn ?? 0) }}</div>
<div class="kpi-sub">4xx, aborted · {{ stats?.unique?.warn }} unique</div>
</div>
<div class="kpi-card kpi-info kpi-filter" role="button" tabindex="0" :class="{ 'is-selected': query?.bucket === 'info' }" :aria-pressed="query?.bucket === 'info'" :title="'Show only info events'" @click="setBucket(query?.bucket === 'info' ? '' : 'info')" @keydown.enter.prevent="setBucket(query?.bucket === 'info' ? '' : 'info')">
<div class="kpi-label">Info</div>
<div class="kpi-value">{{ fmt?.number(stats?.severity?.info ?? 0) }}</div>
<div class="kpi-sub">Auth, 404 · {{ stats?.unique?.info }} unique</div>
</div>
<div class="kpi-card" :class="{&#x27;kpi-error&#x27;: stats?.dropped &gt; 0}">
<div class="kpi-label">Distinct errors stored</div>
<div class="kpi-value">{{ fmt?.number(total) }}</div>
<div class="kpi-sub">
all time · cap {{ fmt?.number(cap) }} · {{ available ? 'live' : 'redis off' }}
<span class="dropped-warn" v-if="stats?.dropped &gt; 0"> · {{ stats?.dropped }} dropped</span>
</div>
</div>
</section>
<section class="volume-chart">
<div class="volume-head">
<span class="volume-title">Volume · 24h · 1h buckets</span>
<span class="volume-legend">
<span v-if="stats?.severity?.error"><span class="dot dot-error"></span>Error</span>
<span v-if="stats?.severity?.warn"><span class="dot dot-warn"></span>Warning</span>
<span v-if="stats?.severity?.info"><span class="dot dot-info"></span>Info</span>
</span>
</div>
<div class="volume-bars">
<div class="volume-bar" v-for="(b, index) in stats?.buckets" :key="index" :title="(bucketTitle(b))">
<span class="seg seg-error" :style="{height: barPx(b, &#x27;error&#x27;) + &#x27;px&#x27;}"></span>
<span class="seg seg-warn" :style="{height: barPx(b, &#x27;warn&#x27;) + &#x27;px&#x27;}"></span>
<span class="seg seg-info" :style="{height: barPx(b, &#x27;info&#x27;) + &#x27;px&#x27;}"></span>
</div>
</div>
<div class="volume-axis" aria-hidden="true"><span>24h ago</span><span>12h ago</span><span>now</span></div>
</section>
<form class="errors-toolbar" aria-label="Error filters" v-form="viewState">
<div class="seg-filter" role="radiogroup" aria-label="Filter by severity">
<button type="button" role="radio" class="seg-chip" :class="{ active: (query?.bucket || '') === '' }" :aria-checked="(query?.bucket || '') === ''" @click="setBucket('')">All <span class="count">{{ fmt?.number((stats?.severity?.error ?? 0) + (stats?.severity?.warn ?? 0) + (stats?.severity?.info ?? 0)) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: (query?.bucket || '') === 'error', 'is-empty': !(stats?.severity?.error) }" :aria-checked="(query?.bucket || '') === 'error'" @click="setBucket('error')"><span class="seg-dot bad" aria-hidden="true"></span>Error <span class="count">{{ fmt?.number(stats?.severity?.error ?? 0) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: (query?.bucket || '') === 'warn', 'is-empty': !(stats?.severity?.warn) }" :aria-checked="(query?.bucket || '') === 'warn'" @click="setBucket('warn')"><span class="seg-dot warn" aria-hidden="true"></span>Warning <span class="count">{{ fmt?.number(stats?.severity?.warn ?? 0) }}</span></button>
<button type="button" role="radio" class="seg-chip" :class="{ active: (query?.bucket || '') === 'info', 'is-empty': !(stats?.severity?.info) }" :aria-checked="(query?.bucket || '') === 'info'" @click="setBucket('info')"><span class="seg-dot off" aria-hidden="true"></span>Info <span class="count">{{ fmt?.number(stats?.severity?.info ?? 0) }}</span></button>
</div>
<div class="search-wrap">
<svg class="ui-icon search-icon" aria-hidden="true"><use href="#ui-icon-search"></use></svg>
<input type="search" class="form-control" placeholder="Search errors…" title="Search messages, or filter with code:repo_not_found module:route status:&gt;=400" autocomplete="off" v-field="{ state: viewState, set: value =&gt; { query.search = value }, value: query?.search, form: null, options: {} }">
<span class="filter-count" v-if="parsedFilterCount">{{ parsedFilterCount }} filter{{ parsedFilterCount > 1 ? 's' : '' }}</span>
</div>
<div class="select-wrap">
<label>Sort</label>
<select class="form-control form-control-sm" v-field="{ state: viewState, set: value =&gt; { query.sort = value }, value: query?.sort, form: null, options: {} }">
<option value="recent">Most recent</option>
<option value="count">Most frequent</option>
</select>
</div>
<div class="select-wrap">
<label title="Rows are always sectioned by severity. This merges repeated events inside each section.">Merge</label>
<select class="form-control form-control-sm" v-field="{ state: viewState, set: value =&gt; { query.group = value }, value: query?.group, form: null, options: {} }">
<option value="">Don't merge</option>
<option value="code">Same error</option>
<option value="module">Same module</option>
</select>
</div>
<label class="autoref">
<input type="checkbox" v-field="{ state: viewState, set: value =&gt; { query.autoRefresh = value }, value: query?.autoRefresh, form: null, options: {} }">
<span class="auto-refresh-label">Auto-refresh</span>
</label>
<button class="btn btn-sm btn-icon" type="button" title="Refresh now" @click="refreshNow()" aria-label="Refresh"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-refresh"></use></svg></button>
</form>
<div class="admin-empty" v-if="!visible?.length">No errors captured.</div>
<div class="errors-pager" v-if="canLoadMore() &amp;&amp; visible?.length">
<span>{{ query?.group &amp;&amp; visible?.length !== entries?.length ? fmt?.number(visible?.length) + (visible?.length === 1 ? ' group' : ' groups') + ' from ' : '' }}{{ fmt?.number(entries?.length) }} of {{ fmt?.number(total) }} stored errors loaded</span>
<button class="btn btn-sm" type="button" @click="loadMore()">Load 250 older</button>
</div>
<div class="errors-list" v-if="visible?.length">
<div class="errors-list-head">
<span class="col-when">When</span>
<span class="col-sev">Severity</span>
<span class="col-mod">Module</span>
<span class="col-msg">Message</span>
<span class="col-count" title="Total occurrences since first seen">Total</span>
<span class="col-status">Status</span>
</div>
<template v-for="(row, index) in visible" :key="row?._key">
<div class="errors-group-head" v-if="index === 0 || visible[index - 1]?._bucket !== row?._bucket"><span class="sev-dot" :class="'sev-' + row?._bucket"></span>{{ row?._bucket === 'error' ? 'Error' : row?._bucket === 'warn' ? 'Warning' : 'Info' }}<span class="errors-group-count">{{ fmt?.number(visible.filter(r =&gt; r?._bucket === row?._bucket).length) }}</span><span class="errors-group-note" v-if="index === 0">By severity · {{ query?.group === 'code' ? 'repeats merged by error · ' : query?.group === 'module' ? 'repeats merged by module · ' : '' }}{{ query?.sort === 'count' ? 'most frequent first' : 'most recent first' }}</span></div>
<div class="errors-row" :class="{open: expanded[row?._key]}">
<div class="errors-row-main" @click="toggle(row)">
<div class="col-when">
<div class="when-rel">{{ relTime(row?.ts) }}</div>
<div class="when-abs">{{ absTimeShort(row?.ts) }}</div>
</div>
<div class="col-sev">
<span class="sev-dot" :class="&#x27;sev-&#x27; + row?._bucket"></span>
<span class="sev-label">{{ row?._bucket === 'error' ? 'Error' : row?._bucket === 'warn' ? 'Warning' : 'Info' }}</span>
</div>
<div class="col-mod"><span class="pill pill-module">{{ row?.module }}</span></div>
<div class="col-msg">
<strong class="msg-code">{{ row?.displayMessage }}</strong>
<span class="msg-context" v-if="row?.displayContext &amp;&amp; row?.displayContext !== row?.displayMessage">{{ row?.displayContext }}</span>
<span class="msg-detail" v-if="row?._detail">{{ row?._detail }}</span>
<div class="msg-url" v-if="row?._url" :title="row?._url">{{ row?._url }}</div>
</div>
<div class="col-count">
<span class="count-pill" v-if="row?.count &gt; 1">×{{ row?.count }}</span>
<span class="count-pill count-pill-muted" v-if="row?.count === 1">×1</span>
</div>
<div class="col-status">
<span class="status-pill" v-if="row?._status" :class="String(row?._status).match(/^[23]/) ? 'status-neutral' : (&#x27;status-&#x27; + row?._bucket)" :title="String(row?._status).match(/^2/) &amp;&amp; row?._bucket === 'warn' ? 'Client closed the connection' : null">{{ String(row?._status).match(/^2/) &amp;&amp; row?._bucket === 'warn' ? '—' : row?._status }}</span>
<span class="status-empty" v-if="!row?._status" title="No HTTP status for this event">—</span>
</div>
</div>
<div class="errors-row-detail" v-if="expanded[row?._key]">
<div class="detail-tabs">
<button type="button" :class="{active: detailTab[row?._key] === &#x27;raw&#x27; || !detailTab[row?._key]}" @click="detailTab[row._key] = &#x27;raw&#x27;">Raw</button>
<button type="button" v-if="row?._stack" :class="{active: detailTab[row?._key] === &#x27;stack&#x27;}" @click="detailTab[row._key] = &#x27;stack&#x27;">Stack</button>
<button type="button" v-if="row?.count &gt; 1" :class="{active: detailTab[row?._key] === &#x27;related&#x27;}" @click="detailTab[row._key] = &#x27;related&#x27;">Related ({{ row?.count }})</button>
</div>
<div class="detail-body">
<div class="detail-main">
<pre v-if="(detailTab[row?._key] || &#x27;raw&#x27;) === &#x27;raw&#x27;">{{ row?._detailJson }}</pre>
<pre class="stack-pre" v-if="detailTab[row?._key] === &#x27;stack&#x27;">{{ row?._stack }}</pre>
<div class="related-list" v-if="detailTab[row?._key] === &#x27;related&#x27;">
<div class="related-row" v-for="(r, index) in row?._related" :key="index">
<span class="when-abs">{{ absTimeShort(r?.ts) }}</span>
<span class="msg-url">{{ r?._url }}</span>
<span class="status-pill" v-if="r?._status" :class="&#x27;status-&#x27; + r?._bucket">{{ r?._status }}</span>
</div>
</div>
<div class="detail-actions">
<button class="btn btn-sm" type="button" title="Copy a curl that reproduces the request" @click="copyCurl(row)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-code"></use></svg> Copy curl</button>
<button class="btn btn-sm" type="button" @click="copyJson(row)"><svg class="ui-icon" aria-hidden="true"><use href="#ui-icon-clipboard"></use></svg> Copy JSON</button>
<span class="copy-hint" v-if="copyHint">{{ copyHint }}</span>
</div>
</div>
<aside class="detail-aside">
<div class="aside-block">
<div class="aside-label">First seen</div>
<div class="aside-value" :title="(absTime(row?._firstSeen))">{{ relTime(row?._firstSeen) }}</div>
</div>
<div class="aside-block">
<div class="aside-label">Last seen</div>
<div class="aside-value" :title="(absTime(row?.ts))">{{ relTime(row?.ts) }}</div>
</div>
<div class="aside-block">
<div class="aside-label">Occurrences</div>
<div class="aside-value">{{ row?.count }}<span class="aside-sub" v-if="row?._lastHourCount"> · {{ row?._lastHourCount }} this hour</span></div>
</div>
<div class="aside-block" v-if="row?._repoId">
<div class="aside-label">Repository</div>
<a class="aside-value" :href="safeUrl(&quot;/r/&quot; + (row?._repoId))">{{ row?._repoId }}</a>
</div>
<div class="aside-block" v-if="row?._url">
<div class="aside-label">URL</div>
<div class="aside-value mono">{{ row?._url }}</div>
</div>
</aside>
</div>
</div>
</div>
</template>
</div>
</div>