mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
216 lines
12 KiB
HTML
216 lines
12 KiB
HTML
{{ define "digest" }}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
{{ template "head" . }}
|
|
{{ template "meta" .Meta }}
|
|
</head>
|
|
<body>
|
|
{{ template "nav" . }}
|
|
|
|
<main class="container mx-auto px-4 py-8">
|
|
<div class="space-y-6">
|
|
<!-- Breadcrumb -->
|
|
<div class="text-sm breadcrumbs">
|
|
<ul>
|
|
<li><a href="/u/{{ .Owner.Handle }}" class="link link-primary">{{ .Owner.Handle }}</a></li>
|
|
<li><a href="/r/{{ .Owner.Handle }}/{{ .Repository }}" class="link link-primary">{{ .Repository }}</a></li>
|
|
<li><code class="font-mono text-xs">{{ truncateDigest .Manifest.Digest 24 }}</code></li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Digest Header -->
|
|
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4">
|
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
<div class="space-y-2">
|
|
<h1 class="text-xl font-bold font-mono break-all">{{ .Manifest.Digest }}</h1>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
{{ if .Manifest.Tags }}
|
|
{{ range .Manifest.Tags }}
|
|
<span class="badge badge-md badge-primary">{{ . }}</span>
|
|
{{ end }}
|
|
{{ end }}
|
|
{{ if .Manifest.IsManifestList }}
|
|
<span class="badge badge-md badge-soft badge-accent">Multi-arch</span>
|
|
{{ else if eq .Manifest.ArtifactType "helm-chart" }}
|
|
<span class="badge badge-md badge-soft badge-helm">{{ icon "helm" "size-3" }} Helm</span>
|
|
{{ end }}
|
|
{{ if .Manifest.HasAttestations }}
|
|
<span class="badge badge-md badge-soft badge-success">{{ icon "shield-check" "size-3" }} Attested</span>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-base-content text-sm flex items-center gap-1" title="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">{{ icon "history" "size-4" }}{{ timeAgoShort .Manifest.CreatedAt }}</span>
|
|
<button class="btn btn-ghost btn-sm" onclick="copyToClipboard('{{ .Manifest.Digest }}')" aria-label="Copy digest">{{ icon "copy" "size-4" }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{ if .Manifest.IsManifestList }}
|
|
<!-- Platform Picker for Manifest Lists -->
|
|
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4">
|
|
<h2 class="text-lg font-semibold">Platforms</h2>
|
|
<p class="text-sm">This is a multi-architecture manifest list. Select a platform to view layers and vulnerabilities.</p>
|
|
<div class="space-y-2">
|
|
{{ range .Manifest.Platforms }}
|
|
<a href="/d/{{ $.Owner.Handle }}/{{ $.Repository }}/{{ .Digest }}" class="flex items-center justify-between p-4 bg-base-200 rounded-lg hover:bg-base-300 transition-colors">
|
|
<div class="flex items-center gap-3">
|
|
<span class="font-medium">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
|
<code class="font-mono text-xs text-base-content" title="{{ .Digest }}">{{ truncateDigest .Digest 32 }}</code>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
{{ if .CompressedSize }}
|
|
<span class="text-sm">{{ humanizeBytes .CompressedSize }}</span>
|
|
{{ end }}
|
|
{{ icon "chevron-right" "size-4" }}
|
|
</div>
|
|
</a>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ else }}
|
|
<!-- Layers + Vulnerabilities Side by Side -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
<!-- Layers (Left) -->
|
|
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
|
<div class="flex items-center justify-between">
|
|
<h2 class="text-lg font-semibold">Layers ({{ len .Layers }})</h2>
|
|
<label class="flex items-center gap-2 text-sm cursor-pointer">
|
|
<input type="checkbox" class="checkbox checkbox-xs" id="show-empty-layers" onchange="toggleEmptyLayers(this.checked)">
|
|
<span>Show empty layers</span>
|
|
</label>
|
|
</div>
|
|
{{ if .Layers }}
|
|
<div class="overflow-x-auto">
|
|
<table class="table table-xs w-full" id="layers-table">
|
|
<thead>
|
|
<tr class="text-xs">
|
|
<th class="w-8">#</th>
|
|
<th>Command</th>
|
|
<th class="text-right w-24">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{ range .Layers }}
|
|
<tr data-empty="{{ .EmptyLayer }}" data-no-command="{{ and (not .Command) (not .EmptyLayer) }}">
|
|
<td class="font-mono text-xs">{{ .Index }}</td>
|
|
<td>
|
|
{{ if .Command }}
|
|
<code class="font-mono text-xs break-all line-clamp-2" title="{{ .Command }}">{{ .Command }}</code>
|
|
{{ end }}
|
|
</td>
|
|
<td class="text-right text-sm whitespace-nowrap">{{ humanizeBytes .Size }}</td>
|
|
</tr>
|
|
{{ end }}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<script>
|
|
(function() {
|
|
// Toggle empty layers (ENV, LABEL, ENTRYPOINT, etc.)
|
|
var showEmpty = localStorage.getItem('showEmptyLayers') === 'true';
|
|
var checkbox = document.getElementById('show-empty-layers');
|
|
if (checkbox) checkbox.checked = showEmpty;
|
|
|
|
window.toggleEmptyLayers = function(show) {
|
|
localStorage.setItem('showEmptyLayers', show);
|
|
applyLayerVisibility();
|
|
};
|
|
|
|
// Collapse consecutive no-command, non-empty layers (distroless base)
|
|
function collapseNoHistoryLayers() {
|
|
var tbody = document.querySelector('#layers-table tbody');
|
|
if (!tbody) return;
|
|
|
|
var rows = Array.from(tbody.querySelectorAll('tr'));
|
|
var i = 0;
|
|
while (i < rows.length) {
|
|
if (rows[i].dataset.noCommand === 'true') {
|
|
// Find consecutive no-command rows
|
|
var start = i;
|
|
while (i < rows.length && rows[i].dataset.noCommand === 'true') {
|
|
rows[i].classList.add('no-history-row', 'hidden');
|
|
i++;
|
|
}
|
|
var count = i - start;
|
|
if (count > 1) {
|
|
// Sum sizes from the collapsed rows
|
|
var totalBytes = 0;
|
|
for (var k = start; k < start + count; k++) {
|
|
var sizeCell = rows[k].querySelector('td:last-child');
|
|
if (sizeCell) {
|
|
var txt = sizeCell.textContent.trim();
|
|
var match = txt.match(/([\d.]+)\s*(B|KB|MB|GB|TB)/i);
|
|
if (match) {
|
|
var val = parseFloat(match[1]);
|
|
var unit = match[2].toUpperCase();
|
|
var multipliers = {'B':1,'KB':1024,'MB':1048576,'GB':1073741824,'TB':1099511627776};
|
|
totalBytes += val * (multipliers[unit] || 1);
|
|
}
|
|
}
|
|
}
|
|
var sizeStr = '';
|
|
if (totalBytes < 1024) sizeStr = totalBytes + ' B';
|
|
else if (totalBytes < 1048576) sizeStr = (totalBytes/1024).toFixed(1) + ' KB';
|
|
else if (totalBytes < 1073741824) sizeStr = (totalBytes/1048576).toFixed(1) + ' MB';
|
|
else sizeStr = (totalBytes/1073741824).toFixed(1) + ' GB';
|
|
|
|
// Insert summary row
|
|
var startIdx = rows[start].querySelector('td').textContent.trim();
|
|
var endIdx = rows[i - 1].querySelector('td').textContent.trim();
|
|
var summary = document.createElement('tr');
|
|
summary.className = 'no-history-summary cursor-pointer hover:bg-base-200';
|
|
summary.innerHTML = '<td colspan="2" class="text-sm py-2">Layers ' + startIdx + '-' + endIdx + ' contain no history <span class="text-xs ml-2">(' + count + ' layers, click to expand)</span></td><td class="text-right text-sm whitespace-nowrap">' + sizeStr + '</td>';
|
|
summary.onclick = function() {
|
|
summary.remove();
|
|
for (var j = start; j < start + count; j++) {
|
|
rows[j].classList.remove('hidden');
|
|
}
|
|
};
|
|
tbody.insertBefore(summary, rows[start]);
|
|
} else {
|
|
// Single no-command row, just show it
|
|
rows[start].classList.remove('hidden');
|
|
}
|
|
} else {
|
|
i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyLayerVisibility() {
|
|
var show = localStorage.getItem('showEmptyLayers') === 'true';
|
|
document.querySelectorAll('#layers-table tr[data-empty="true"]').forEach(function(row) {
|
|
row.style.display = show ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
collapseNoHistoryLayers();
|
|
applyLayerVisibility();
|
|
})();
|
|
</script>
|
|
{{ else }}
|
|
<p class="text-base-content">No layer information available</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<!-- Vulnerabilities (Right) -->
|
|
<div class="card bg-base-100 shadow-sm border border-base-300 p-6 space-y-4 min-w-0">
|
|
<h2 class="text-lg font-semibold">Vulnerabilities</h2>
|
|
{{ if .VulnData }}
|
|
{{ template "vuln-details" .VulnData }}
|
|
{{ else }}
|
|
<p class="text-base-content">No vulnerability scan data available</p>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
</main>
|
|
|
|
{{ template "footer" . }}
|
|
</body>
|
|
</html>
|
|
{{ end }}
|