Files

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 }}