more ui fixes and scanner fixes

This commit is contained in:
Evan Jarrett
2026-04-12 20:48:24 -05:00
parent 2f30c22f0a
commit e843b7233c
11 changed files with 92 additions and 102 deletions
+2 -2
View File
@@ -31,8 +31,8 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
db.SetRegistryURL(featuredCards, h.RegistryURL)
// Fetch recently updated repositories (top 18 by last push - 6 rows)
recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 18, currentUserDID, db.SortByLastUpdate)
// Fetch recently updated repositories (top 24 by last push - 6 rows at 4-col xl)
recentCards, err := db.GetRepoCards(h.ReadOnlyDB, 24, currentUserDID, db.SortByLastUpdate)
if err != nil {
log.Printf("Error fetching recent repos: %v", err)
recentCards = []db.RepoCardData{}
+1
View File
@@ -43,6 +43,7 @@
<symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
<symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -15,7 +15,7 @@
- .HasMore: bool - whether to show Load More button
*/}}
{{ if .Repositories }}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-6">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3{{ if eq .Columns 4 }} xl:grid-cols-4{{ end }} gap-4">
{{ range .Repositories }}
{{ template "repo-card" . }}
{{ end }}
@@ -13,3 +13,22 @@
</button>
</div>
{{ end }}
{{ define "image-ref" }}
{{/*
Image reference component - shows a short image reference with a copy button
that copies the full pull command. Used in dense card layouts where the full
command would truncate.
Expects: dict with
- Display: string - short form shown in the UI (e.g. "alice.bsky.social/myapp:v1.2.3")
- Copy: string - full command copied to clipboard (e.g. "docker pull atcr.io/alice.bsky.social/myapp:v1.2.3")
*/}}
<div class="cmd group !w-full">
{{ icon "package" "size-4 shrink-0 text-base-content/60" }}
<code class="flex-1">{{ .Display }}</code>
<button class="btn btn-ghost btn-xs shrink-0 sm:opacity-0 sm:group-hover:opacity-100 focus:opacity-100 transition-opacity" data-cmd="{{ .Copy }}" aria-label="Copy pull command to clipboard">
{{ icon "copy" "size-4" }}
</button>
</div>
{{ end }}
@@ -17,7 +17,7 @@
- LastUpdated: time.Time (optional) - Last push time
- RegistryURL: string - Registry URL for docker commands (e.g., "atcr.io")
*/}}
<div class="card card-interactive bg-base-200 border-2 border-base-300 p-6 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ .Repository }}">
<div class="card card-interactive bg-base-200 border-2 border-base-300 p-4 flex flex-col justify-between min-h-60 w-full" data-href="/r/{{ .OwnerHandle }}/{{ .Repository }}">
<div class="flex gap-4 items-start">
{{ if .IconURL }}
<img src="{{ resizeImage .IconURL 96 }}" alt="{{ .Repository }}" loading="lazy" width="48" height="48" class="w-12 rounded-lg object-cover shrink-0">
@@ -47,19 +47,27 @@
<div class="flex-1 flex flex-col justify-end py-2 min-w-0">
{{ if eq .ArtifactType "helm-chart" }}
{{ if .Tag }}
{{ template "docker-command" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag) }}
{{ template "image-ref" (dict
"Display" (printf "%s/%s:%s" .OwnerHandle .Repository .Tag)
"Copy" (printf "helm pull oci://%s/%s/%s --version %s" .RegistryURL .OwnerHandle .Repository .Tag)) }}
{{ else }}
{{ template "docker-command" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository) }}
{{ template "image-ref" (dict
"Display" (printf "%s/%s" .OwnerHandle .Repository)
"Copy" (printf "helm pull oci://%s/%s/%s" .RegistryURL .OwnerHandle .Repository)) }}
{{ end }}
{{ else }}
{{ if .Tag }}
{{ template "docker-command" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag) }}
{{ template "image-ref" (dict
"Display" (printf "%s/%s:%s" .OwnerHandle .Repository .Tag)
"Copy" (printf "%s pull %s/%s/%s:%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository .Tag)) }}
{{ else }}
{{ template "docker-command" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository) }}
{{ template "image-ref" (dict
"Display" (printf "%s/%s" .OwnerHandle .Repository)
"Copy" (printf "%s pull %s/%s/%s" (ociClientName .OciClient) .RegistryURL .OwnerHandle .Repository)) }}
{{ end }}
{{ end }}
</div>
<div class="flex justify-between items-center pt-3 border-t border-base-300">
<div class="flex justify-between items-center pt-3 -mx-4 px-4 border-t border-base-300">
<div class="flex gap-6 items-center">
{{ template "star" (dict "IsStarred" .IsStarred "StarCount" .StarCount) }}
{{ template "pull-count" (dict "PullCount" .PullCount) }}
+1 -1
View File
@@ -45,7 +45,7 @@
{{ if .RecentRepos }}
<section>
<h2 class="text-2xl font-bold mb-6">What's New</h2>
{{ template "card-grid" (dict "Repositories" .RecentRepos) }}
{{ template "card-grid" (dict "Repositories" .RecentRepos "Columns" 4) }}
</section>
{{ end }}
</div>
+1 -1
View File
@@ -48,7 +48,7 @@
</div>
{{ else }}
<div class="w-full">
{{ template "card-grid" (dict "Repositories" .Repositories "EmptyMessage" "No images yet.") }}
{{ template "card-grid" (dict "Repositories" .Repositories "Columns" 4 "EmptyMessage" "No images yet.") }}
</div>
{{ end }}
</div>
@@ -1,6 +1,7 @@
{{/* Search results partial - renders repository cards in a grid */}}
{{ template "card-grid" (dict
"Repositories" .Repositories
"Columns" 4
"EmptyIcon" "search-x"
"EmptyMessage" "No repositories found matching your search."
"EmptySubtext" "Try a different search term or browse the homepage."
+1
View File
@@ -43,6 +43,7 @@
<symbol id="loader" viewBox="0 0 24 24"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></symbol>
<symbol id="loader-2" viewBox="0 0 24 24"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></symbol>
<symbol id="moon" viewBox="0 0 24 24"><path d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"/></symbol>
<symbol id="package" viewBox="0 0 24 24"><path d="M11 21.73a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73z"/><path d="M12 22V12"/><polyline points="3.29 7 12 12 20.71 7"/><path d="m7.5 4.27 9 5.15"/></symbol>
<symbol id="pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></symbol>
<symbol id="plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="radio-tower" viewBox="0 0 24 24"><path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9"/><path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5"/><circle cx="12" cy="9" r="2"/><path d="M16.2 4.8c2 2 2.26 5.11.8 7.47"/><path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1"/><path d="M9.5 18h5"/><path d="m8 22 4-11 4 11"/></symbol>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

+37 -86
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
@@ -126,9 +125,23 @@ func scanVulnerabilities(ctx context.Context, s *sbom.SBOM, vulnDBPath string) (
return reportJSON, digest, summary, nil
}
// grypeDBConfig returns the distribution and installation configs used for
// all Grype DB load/update calls. Kept in one place so both the initial load
// and the periodic reload see identical settings.
func grypeDBConfig(vulnDBPath string) (distribution.Config, installation.Config) {
return distribution.DefaultConfig(), installation.Config{
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 14 * 24 * time.Hour, // tolerates upstream publish gaps
}
}
// loadVulnDatabase loads the Grype vulnerability database with caching and
// automatic refresh. The cached DB is returned if loaded less than
// vulnDBRefreshAge ago. On a stale or missing DB, it downloads a fresh copy.
// vulnDBRefreshAge ago. On a stale or missing DB, Grype downloads a fresh copy
// in the same call (update=true) — a single curator handles everything so
// there is no chance of a double-curator update+load seeing different state.
func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Provider, error) {
vulnDBLock.RLock()
if vulnDB != nil && time.Since(vulnDBLoaded) < vulnDBRefreshAge {
@@ -155,40 +168,32 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
}
}
slog.Info("Loading Grype vulnerability database", "path", vulnDBPath)
slog.Info("Loading Grype vulnerability database", "path", vulnDBPath, "tmpdir", os.Getenv("TMPDIR"))
if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
if err := os.MkdirAll(vulnDBPath, 0o755); err != nil {
return nil, fmt.Errorf("failed to create vulnerability database directory: %w", err)
}
distConfig := distribution.DefaultConfig()
installConfig := installation.Config{
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 14 * 24 * time.Hour, // 2 weeks — tolerates upstream publish gaps
}
distConfig, installConfig := grypeDBConfig(vulnDBPath)
// Try loading existing DB first (no network)
store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, false)
// update=true: a single grype curator checks the upstream feed, downloads
// if needed, activates, and then opens the reader — all in one call. If
// the upstream is unreachable but the on-disk DB is still valid, it falls
// back to serving the existing DB.
store, status, err := grype.LoadVulnerabilityDB(distConfig, installConfig, true)
if err != nil {
slog.Warn("Vulnerability database load failed, attempting update", "error", err)
// Download fresh DB
if updateErr := updateVulnDatabase(vulnDBPath); updateErr != nil {
return nil, fmt.Errorf("failed to update vulnerability database: %w (original: %w)", updateErr, err)
}
// Retry loading after update
store, status, err = grype.LoadVulnerabilityDB(distConfig, installConfig, false)
if err != nil {
return nil, fmt.Errorf("failed to load vulnerability database after update (status=%v): %w", status, err)
}
return nil, fmt.Errorf("failed to load vulnerability database: %w", err)
}
age := "unknown"
if !status.Built.IsZero() {
age = time.Since(status.Built).Round(time.Minute).String()
}
slog.Info("Vulnerability database loaded",
"built", status.Built,
"schemaVersion", status.SchemaVersion)
"age", age,
"schemaVersion", status.SchemaVersion,
"path", status.Path)
if vulnDB != nil {
vulnDB.Close()
@@ -198,67 +203,13 @@ func loadVulnDatabase(ctx context.Context, vulnDBPath string) (vulnerability.Pro
return vulnDB, nil
}
// initializeVulnDatabase ensures a fresh vulnerability database exists on startup.
func initializeVulnDatabase(vulnDBPath, tmpDir string) error {
// initializeVulnDatabase primes the in-memory DB cache on startup so the first
// scan doesn't pay the download cost. Caller is responsible for TMPDIR being
// set to a path on the same filesystem as vulnDBPath — see WorkerPool.Start.
func initializeVulnDatabase(vulnDBPath string) error {
slog.Info("Initializing vulnerability database", "path", vulnDBPath)
grpeTmpDir := filepath.Join(tmpDir, "grype-dl")
if err := os.MkdirAll(grpeTmpDir, 0755); err != nil {
return fmt.Errorf("failed to create temp directory: %w", err)
}
oldTmpDir := os.Getenv("TMPDIR")
os.Setenv("TMPDIR", grpeTmpDir)
defer func() {
if oldTmpDir != "" {
os.Setenv("TMPDIR", oldTmpDir)
} else {
os.Unsetenv("TMPDIR")
}
}()
return updateVulnDatabase(vulnDBPath)
}
// updateVulnDatabase downloads a fresh vulnerability database if needed.
// The curator internally checks whether an update is necessary (DB missing,
// stale, or update-check cooldown expired) so this is safe to call often.
func updateVulnDatabase(vulnDBPath string) error {
if err := os.MkdirAll(vulnDBPath, 0755); err != nil {
return fmt.Errorf("failed to create database directory: %w", err)
}
distConfig := distribution.DefaultConfig()
installConfig := installation.Config{
DBRootDir: vulnDBPath,
ValidateAge: true,
ValidateChecksum: true,
MaxAllowedBuiltAge: 14 * 24 * time.Hour,
}
downloader, err := distribution.NewClient(distConfig)
if err != nil {
return fmt.Errorf("failed to create database downloader: %w", err)
}
curator, err := installation.NewCurator(installConfig, downloader)
if err != nil {
return fmt.Errorf("failed to create database curator: %w", err)
}
slog.Info("Checking vulnerability database for updates...")
updated, err := curator.Update()
if err != nil {
return fmt.Errorf("failed to update vulnerability database: %w", err)
}
if updated {
slog.Info("Vulnerability database updated successfully")
} else {
slog.Info("Vulnerability database is up to date")
}
return nil
_, err := loadVulnDatabase(context.Background(), vulnDBPath)
return err
}
func countVulnerabilitiesBySeverity(matches match.Matches) scanner.VulnerabilitySummary {
+14 -5
View File
@@ -37,20 +37,29 @@ func NewWorkerPool(cfg *config.Config, q *queue.JobQueue, c *client.HoldClient)
// Start launches worker goroutines
func (wp *WorkerPool) Start(ctx context.Context) {
// Point TMPDIR at the configured tmp dir so Grype's DB download
// (go-getter zstd decompression can be 1 GB+) and stereoscope's layer
// extraction both land on the same partition as the scanner volume —
// NOT on /tmp, which is typically tmpfs with ~400 MB and would silently
// fail mid-extract. This must be set before any scanner/grype goroutine
// starts and must never be restored to a smaller default mid-process.
if wp.cfg.Vuln.TmpDir != "" {
if err := os.MkdirAll(wp.cfg.Vuln.TmpDir, 0o755); err != nil {
slog.Warn("Failed to create scanner tmp dir", "path", wp.cfg.Vuln.TmpDir, "error", err)
}
os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir)
}
// Initialize vuln database on startup if enabled
if wp.cfg.Vuln.Enabled {
go func() {
if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath, wp.cfg.Vuln.TmpDir); err != nil {
if err := initializeVulnDatabase(wp.cfg.Vuln.DBPath); err != nil {
slog.Error("Failed to initialize vulnerability database", "error", err)
slog.Warn("Vulnerability scanning will be disabled until database is available")
}
}()
}
// Point TMPDIR at the configured tmp dir so stereoscope's internal
// layer extraction uses the same partition (not /tmp which may be small)
os.Setenv("TMPDIR", wp.cfg.Vuln.TmpDir)
for i := 0; i < wp.cfg.Scanner.Workers; i++ {
wp.wg.Add(1)
go wp.worker(ctx, i)