Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-11 08:39:17 -05:00
pkg-proxy/internal/server/templates/pages/analytics.html
Abhinav Gautam a97b690858
Separate artifact and metadata cache metrics on /metrics and the analytics page (#463)
* Separate artifact and metadata cache metrics on /metrics and the analytics page

* Serve metadata cache stats from a shared snapshot and split Grafana cache panels by kind
2026-10-20 11:25:33 +01:00

354 lines
17 KiB
HTML

{{define "title"}}Analytics · git-pkgs proxy{{end}}
{{define "head"}}
<style>
/* Categorical slots, fixed order, assigned in sequence and never cycled.
The dark column is the same hues stepped for the dark surface, chosen
against it rather than flipped. Validated for CVD separation and
contrast against both surfaces. */
.donut-slot-0 { stroke: #2a78d6; }
.donut-slot-1 { stroke: #eb6834; }
.donut-slot-2 { stroke: #1baf7a; }
.donut-slot-3 { stroke: #eda100; }
.donut-slot-4 { stroke: #e87ba4; }
.donut-slot-5 { stroke: #008300; }
.dark .donut-slot-0 { stroke: #3987e5; }
.dark .donut-slot-1 { stroke: #d95926; }
.dark .donut-slot-2 { stroke: #199e70; }
.dark .donut-slot-3 { stroke: #c98500; }
.dark .donut-slot-4 { stroke: #d55181; }
.dark .donut-slot-5 { stroke: #008300; }
.swatch-0 { background-color: #2a78d6; }
.swatch-1 { background-color: #eb6834; }
.swatch-2 { background-color: #1baf7a; }
.swatch-3 { background-color: #eda100; }
.swatch-4 { background-color: #e87ba4; }
.swatch-5 { background-color: #008300; }
.dark .swatch-0 { background-color: #3987e5; }
.dark .swatch-1 { background-color: #d95926; }
.dark .swatch-2 { background-color: #199e70; }
.dark .swatch-3 { background-color: #c98500; }
.dark .swatch-4 { background-color: #d55181; }
.dark .swatch-5 { background-color: #008300; }
.donut-slice {
transition: opacity 120ms ease-out;
cursor: default;
}
/* Dim the rest of the ring rather than growing the hovered arc, so the
geometry never misstates a share while being read. */
.donut:hover .donut-slice { opacity: 0.35; }
.donut .donut-slice.is-active,
.donut:hover .donut-slice.is-active { opacity: 1; }
.legend-row.is-active { background-color: rgb(243 244 246); }
.dark .legend-row.is-active { background-color: rgb(31 41 55); }
</style>
{{end}}
{{define "content"}}
<div class="mb-8">
<h1 class="text-2xl font-semibold">Analytics</h1>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
Accumulated download volume and cache composition across
{{.Totals.Ecosystems}} ecosystem{{if ne .Totals.Ecosystems 1}}s{{end}}.
</p>
</div>
{{if .StatsStale}}
<div role="status"
class="mb-8 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-200">
<span class="font-medium">Figures may be out of date.</span>
The database query is failing, so the download and cache numbers below are
the last snapshot that could be read{{if .StatsAge}}, from {{.StatsAge}}{{end}}.
Check the proxy logs.
</div>
{{end}}
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 mb-4">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-lg font-semibold">Download size by ecosystem</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">Bytes served from cache</p>
</div>
{{if .Donut.HasSlices}}
<div class="p-6 flex flex-col lg:flex-row items-center gap-8">
<div class="relative shrink-0 w-full max-w-[320px]">
<svg class="donut w-full h-auto -rotate-90" viewBox="0 0 {{.Donut.Size}} {{.Donut.Size}}" role="img"
aria-label="Accumulated download size by ecosystem, {{.Donut.CenterValue}} in total">
{{range .Donut.Slices}}
<circle class="donut-slice donut-slot-{{.Index}}"
data-slice="{{.Index}}"
data-label="{{.Label}}"
data-value="{{.Value}}"
data-share="{{.SharePct}}"
data-downloads="{{.Downloads}}"
data-cache="{{.CacheSize}}"
data-members="{{.Members}}"
cx="{{$.Donut.Center}}" cy="{{$.Donut.Center}}" r="{{$.Donut.Radius}}"
fill="none"
stroke-width="{{$.Donut.Stroke}}"
stroke-dasharray="{{.Dash}} {{.Gap}}"
stroke-dashoffset="{{.Offset}}">
<title>{{.Label}}: {{.Value}} ({{.SharePct}}%)</title>
</circle>
{{end}}
</svg>
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none text-center px-10">
<div class="text-4xl sm:text-5xl font-semibold tracking-tight">{{.Donut.CenterValue}}</div>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400 leading-tight">{{.Donut.CenterLabel}}</div>
</div>
</div>
<!-- Legend: identity never rests on colour alone -->
<ul class="w-full flex-1 space-y-1">
{{range .Donut.Slices}}
<li class="legend-row flex items-center gap-3 rounded-lg px-3 py-2 transition-colors"
data-slice="{{.Index}}"
data-label="{{.Label}}"
data-value="{{.Value}}"
data-share="{{.SharePct}}"
data-downloads="{{.Downloads}}"
data-cache="{{.CacheSize}}"
data-members="{{.Members}}">
<span class="swatch-{{.Index}} h-3 w-3 shrink-0 rounded-sm"></span>
<span class="min-w-0 flex-1 truncate text-sm font-medium">
{{.Label}}
{{- if .IsOther}}<span class="ml-1 font-normal text-gray-500 dark:text-gray-400">({{.Members}})</span>{{end}}
</span>
<span class="shrink-0 text-sm tabular-nums">{{.Value}}</span>
<span class="w-14 shrink-0 text-right text-sm tabular-nums text-gray-500 dark:text-gray-400">{{.SharePct}}%</span>
</li>
{{end}}
</ul>
</div>
{{else if .StatsFailed}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
Download figures are unavailable — the database query failed. Check the proxy logs.
</div>
{{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">Nothing has been served from cache yet</div>
{{end}}
</div>
<!-- Cache figures, carried over from the dashboard -->
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-8">
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 shadow-sm border border-gray-200 dark:border-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">Downloads</div>
<div class="text-2xl font-semibold mt-1">{{.Totals.Downloads}}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 shadow-sm border border-gray-200 dark:border-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">Cache size</div>
<div class="text-2xl font-semibold mt-1">{{.Totals.CacheSize}}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 shadow-sm border border-gray-200 dark:border-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">Cached artifacts</div>
<div class="text-2xl font-semibold mt-1">{{.Totals.CachedArtifacts}}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 shadow-sm border border-gray-200 dark:border-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">Packages</div>
<div class="text-2xl font-semibold mt-1">{{.Totals.Packages}}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 shadow-sm border border-gray-200 dark:border-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">Versions</div>
<div class="text-2xl font-semibold mt-1">{{.Totals.Versions}}</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-xl p-5 shadow-sm border border-gray-200 dark:border-gray-800">
<div class="text-sm text-gray-500 dark:text-gray-400">Ecosystems in use</div>
<div class="text-2xl font-semibold mt-1">{{.Totals.ActiveEcosystems}}<span class="text-base font-normal text-gray-400 dark:text-gray-500"> / {{.Totals.Ecosystems}}</span></div>
</div>
</div>
{{if .Totals.Amplification}}
<p class="-mt-4 mb-8 text-sm text-gray-500 dark:text-gray-400">
The cache has served {{.Totals.Amplification}} the bytes it currently stores.
</p>
{{end}}
<!-- Table view: every figure the ring folds away -->
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 mb-8 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-lg font-semibold">Per-ecosystem breakdown</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">Every ecosystem, including any folded into the ring's "Other" slice</p>
</div>
{{if .Ecosystems}}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<tr>
<th scope="col" class="px-6 py-3 text-left font-medium">Ecosystem</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Downloaded</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Share</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Downloads</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Cache size</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Artifacts</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Avg size</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Packages</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Versions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
{{range .Ecosystems}}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<th scope="row" class="px-6 py-3 text-left font-normal">
<a href="/ui/packages?ecosystem={{.Ecosystem}}&sort=hits" class="hover:opacity-80">{{template "ecosystem_badge" .Ecosystem}}</a>
</th>
<td class="px-6 py-3 text-right tabular-nums font-medium">{{.Downloaded}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.SharePct}}%</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.Downloads}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.CacheSize}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.Artifacts}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.AvgArtifactSize}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.Packages}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.Versions}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else if .StatsFailed}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
The per-ecosystem query failed, so this table is unavailable. Check the proxy logs.
</div>
{{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No packages cached yet</div>
{{end}}
</div>
<!-- Metadata cache: stored apart from artifacts, so none of the figures above include it -->
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-sm border border-gray-200 dark:border-gray-800 mb-8 overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h2 class="text-lg font-semibold">Metadata cache</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Cached registry metadata, kept apart from artifacts: the download, cache size and
artifact figures above do not include it.
{{if not .Metadata.Enabled}}Metadata caching is off; set <code class="text-xs">cache_metadata</code> to enable it.{{end}}
</p>
</div>
{{if .Metadata.Rows}}
<div class="grid grid-cols-2 gap-4 px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<div>
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Cached documents</div>
<div class="text-xl font-semibold mt-1 tabular-nums">{{.Metadata.Entries}}</div>
</div>
<div>
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Metadata size</div>
<div class="text-xl font-semibold mt-1 tabular-nums">{{.Metadata.Size}}</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-800">
<tr>
<th scope="col" class="px-6 py-3 text-left font-medium">Ecosystem</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Size</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Share</th>
<th scope="col" class="px-6 py-3 text-right font-medium">Documents</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-800">
{{range .Metadata.Rows}}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">
<th scope="row" class="px-6 py-3 text-left font-normal">{{template "ecosystem_badge" .Ecosystem}}</th>
<td class="px-6 py-3 text-right tabular-nums font-medium">{{.Size}}</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.SharePct}}%</td>
<td class="px-6 py-3 text-right tabular-nums text-gray-500 dark:text-gray-400">{{.Entries}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else if .Metadata.Failed}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">
The metadata cache query failed, so these figures are unavailable. Check the proxy logs.
</div>
{{else}}
<div class="px-6 py-12 text-center text-gray-500 dark:text-gray-400">No metadata cached yet</div>
{{end}}
</div>
{{if .EnrichmentStats.HasVulns}}
{{template "security_overview" .EnrichmentStats}}
{{end}}
{{if .Runtime.Available}}
{{template "runtime_metrics" .Runtime}}
{{end}}
<p class="text-sm text-gray-500 dark:text-gray-400">
Every figure on this page is also exported for Prometheus at
<a href="/metrics" class="text-blue-600 dark:text-blue-400 hover:underline">/metrics</a>.
This page shows current state only — nothing here is a time series. For history,
trends and alerting, scrape <code class="text-xs">/metrics</code> and use the
Grafana dashboard shipped in <code class="text-xs">deploy/grafana/</code>.
</p>
{{end}}
{{define "scripts"}}
<script>
(function () {
const donut = document.querySelector('.donut');
if (!donut) return;
const slices = Array.from(donut.querySelectorAll('.donut-slice'));
const rows = Array.from(document.querySelectorAll('.legend-row'));
const tip = document.createElement('div');
tip.className = 'pointer-events-none fixed z-50 hidden rounded-lg bg-gray-900 dark:bg-gray-100 ' +
'px-3 py-2 text-xs text-gray-100 dark:text-gray-900 shadow-lg';
document.body.appendChild(tip);
function activate(index) {
slices.forEach(s => s.classList.toggle('is-active', s.dataset.slice === index));
rows.forEach(r => r.classList.toggle('is-active', r.dataset.slice === index));
}
function clear() {
slices.forEach(s => s.classList.remove('is-active'));
rows.forEach(r => r.classList.remove('is-active'));
tip.classList.add('hidden');
}
function line(cls, text) {
const el = document.createElement('div');
el.className = cls;
el.textContent = text;
return el;
}
function show(el, event) {
const d = el.dataset;
tip.replaceChildren(
line('font-semibold', d.label),
line('mt-1 tabular-nums whitespace-nowrap', d.value + ' · ' + d.share + '% of total'),
line('tabular-nums whitespace-nowrap', d.downloads + ' downloads · ' + d.cache + ' cached')
);
if (d.members) {
tip.appendChild(line('mt-1 max-w-[16rem] text-gray-300 dark:text-gray-600', d.members));
}
tip.classList.remove('hidden');
move(event);
}
function move(event) {
const pad = 14;
const r = tip.getBoundingClientRect();
let x = event.clientX + pad;
let y = event.clientY + pad;
if (x + r.width > window.innerWidth - pad) x = event.clientX - r.width - pad;
if (y + r.height > window.innerHeight - pad) y = event.clientY - r.height - pad;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
}
// The arc stroke is a thin ring, so the row is given the same hover
// behaviour: it is a much larger hit target for the same slice.
slices.concat(rows).forEach(el => {
el.addEventListener('mouseenter', e => { activate(el.dataset.slice); show(el, e); });
el.addEventListener('mousemove', move);
el.addEventListener('mouseleave', clear);
});
donut.addEventListener('mouseleave', clear);
})();
</script>
{{end}}