Files
at-container-registry/pkg/appview/ui.go
T
Evan JarrettandClaude Opus 5 8bc6d65e1e appview: emit crane's mandatory destination in the pull command switcher
The client switcher built every command as "<client> pull <ref>". That is
valid for docker, podman, buildah and nerdctl, but crane requires a
destination:

  $ crane pull seamark.cr/user/bench8x1:v2
  Error: requires at least 2 arg(s), only received 1

So selecting crane handed the user a command that cannot run. pullPrefix is a
prefix-only helper, which is precisely why it could not express this; add a
matching pullPostfix that returns " <image>.tar" for crane and "" for
everything else, including "none" (image reference only), which must get
neither prefix nor postfix.

Both render paths change together, since fixing one leaves the bug visible in
the other: the Go template helper paints first, and updatePullCommand in
app.js re-renders when the dropdown changes.

Repository names may contain slashes, so only the last path segment is used —
otherwise the destination would name a subdirectory that does not exist. A
name ending in "/" yields no destination at all rather than a bare ".tar",
on the grounds that a visibly wrong-arity command beats silently writing a
hidden file. That input is not reachable through the real repo-name path.

The test asserts the whole command string rather than just the postfix, so it
covers the prefix/postfix interaction and the "none" case where both vanish.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
2026-09-02 21:03:01 -05:00

640 lines
18 KiB
Go

package appview
import (
"crypto/md5"
"embed"
"encoding/json"
"fmt"
"html/template"
"io/fs"
"maps"
"math/rand/v2"
"net/http"
"net/url"
"strings"
"time"
"atcr.io/pkg/appview/licenses"
)
// BrandingOverrides allows consumers to customize the AppView's public assets,
// templates, CSS, and template functions. Pass nil for default atcr.io behavior.
type BrandingOverrides struct {
// PublicFS overlays public/ assets (favicons, CSS, images, etc.).
// Files in this FS take priority over the embedded defaults.
PublicFS fs.FS
// TemplatesFS overlays templates/ (nav-brand.html, hero.html, etc.).
// Go's template.ParseFS replaces {{ define "name" }} blocks when
// called twice with the same name, so consumer templates naturally
// override defaults.
TemplatesFS fs.FS
// ExtraCSS is injected as a <style> block after the main stylesheet.
// Useful for DaisyUI color variable overrides without build tooling.
ExtraCSS string
// ExtraFuncMap is merged into the template FuncMap.
ExtraFuncMap template.FuncMap
}
// assetHashes stores MD5 hashes of embedded assets for cache busting
var assetHashes = make(map[string]string)
func init() {
// Compute MD5 hash of embedded default assets at startup.
// Consumers should call ComputeAssetHashes(overrides) to recompute
// with their overlay FS before serving requests.
computeAssetHashesFromFS(publicFS)
}
func computeAssetHashesFromFS(fsys fs.FS) {
files := []string{"css/style.css", "js/bundle.min.js"}
for _, f := range files {
var data []byte
var err error
if rfs, ok := fsys.(fs.ReadFileFS); ok {
data, err = rfs.ReadFile("public/" + f)
} else {
fh, openErr := fsys.Open("public/" + f)
if openErr != nil {
continue
}
defer fh.Close()
stat, statErr := fh.Stat()
if statErr != nil {
continue
}
data = make([]byte, stat.Size())
_, err = fh.(interface{ Read([]byte) (int, error) }).Read(data)
}
if err != nil {
continue
}
assetHashes[f] = fmt.Sprintf("%x", md5.Sum(data))[:8]
}
}
// ComputeAssetHashes recomputes cache-busting hashes using the overlay FS.
// Call this before serving requests if using BrandingOverrides.
func ComputeAssetHashes(overrides *BrandingOverrides) {
fsys := resolvePublicFS(overrides)
computeAssetHashesFromFS(fsys)
}
// humanizeCount renders any integer kind with a compact suffix (1.2K, 3.4M,
// 5.6B). Accepts any so templates can pass int, int64, etc. without an
// explicit conversion. Non-integer values render as "0".
func humanizeCount(v any) string {
var n int64
switch x := v.(type) {
case int:
n = int64(x)
case int8:
n = int64(x)
case int16:
n = int64(x)
case int32:
n = int64(x)
case int64:
n = x
case uint:
n = int64(x)
case uint8:
n = int64(x)
case uint16:
n = int64(x)
case uint32:
n = int64(x)
case uint64:
n = int64(x)
default:
return "0"
}
neg := n < 0
if neg {
n = -n
}
var s string
switch {
case n < 1000:
s = fmt.Sprintf("%d", n)
case n < 1_000_000:
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1000), ".0") + "K"
case n < 1_000_000_000:
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000), ".0") + "M"
default:
s = strings.TrimSuffix(fmt.Sprintf("%.1f", float64(n)/1_000_000_000), ".0") + "B"
}
if neg {
return "-" + s
}
return s
}
// AssetHash returns the cache-busting hash for an asset path
func AssetHash(path string) string {
if hash, ok := assetHashes[path]; ok {
return hash
}
return ""
}
// fontPreloadPaths returns the list of /fonts/*.woff2 files that exist in
// the resolved public FS. Skips *-ext (extended glyph) subsets to avoid
// preloading weights the primary latin face already covers, and skips
// non-canonical weights so the critical preload burst stays focused on
// the faces needed above the fold. Commit Mono 700 (~47KB) is loaded
// on demand via @font-face with font-display: swap.
func fontPreloadPaths(overrides *BrandingOverrides) []string {
fsys := resolvePublicFS(overrides)
entries, err := fs.ReadDir(fsys, "public/fonts")
if err != nil {
return nil
}
var paths []string
for _, e := range entries {
name := e.Name()
if e.IsDir() {
continue
}
if !strings.HasSuffix(name, ".woff2") {
continue
}
if strings.Contains(name, "-ext") {
continue
}
if strings.Contains(name, "italic") {
continue
}
if strings.Contains(name, "-700") {
continue
}
paths = append(paths, "/fonts/"+name)
}
return paths
}
// CacheMiddleware adds Cache-Control headers to static file responses
func CacheMiddleware(h http.Handler, maxAge int) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d", maxAge))
h.ServeHTTP(w, r)
})
}
//go:generate sh -c "command -v npm >/dev/null 2>&1 && cd ../.. && npm run build:appview || echo 'npm not found, skipping build'"
//go:embed templates/**/*.html
var templatesFS embed.FS
//go:embed public
var publicFS embed.FS
// resolvePublicFS returns an fs.FS that layers overrides on top of the embedded default.
func resolvePublicFS(overrides *BrandingOverrides) fs.FS {
if overrides == nil || overrides.PublicFS == nil {
return publicFS
}
return newOverlayFS(overrides.PublicFS, publicFS)
}
// resolveTemplatesFS returns an fs.FS that layers overrides on top of the embedded default.
func resolveTemplatesFS(overrides *BrandingOverrides) fs.FS {
if overrides == nil || overrides.TemplatesFS == nil {
return templatesFS
}
return newOverlayFS(overrides.TemplatesFS, templatesFS)
}
// Templates returns parsed templates with helper functions.
// Pass nil for default atcr.io behavior.
func Templates(overrides *BrandingOverrides) (*template.Template, error) {
extraCSS := ""
if overrides != nil {
extraCSS = overrides.ExtraCSS
}
funcMap := template.FuncMap{
"timeAgo": func(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "just now"
} else if duration < time.Hour {
mins := int(duration.Minutes())
if mins == 1 {
return "1 minute ago"
}
return fmt.Sprintf("%d minutes ago", mins)
} else if duration < 24*time.Hour {
hours := int(duration.Hours())
if hours == 1 {
return "1 hour ago"
}
return fmt.Sprintf("%d hours ago", hours)
} else {
days := int(duration.Hours() / 24)
if days == 1 {
return "1 day ago"
}
return fmt.Sprintf("%d days ago", days)
}
},
"timeAgoShort": func(t time.Time) string {
duration := time.Since(t)
if duration < time.Minute {
return "now"
} else if duration < time.Hour {
return fmt.Sprintf("%dm", int(duration.Minutes()))
} else if duration < 24*time.Hour {
return fmt.Sprintf("%dh", int(duration.Hours()))
} else if duration < 365*24*time.Hour {
return fmt.Sprintf("%dd", int(duration.Hours()/24))
} else {
return fmt.Sprintf("%dy", int(duration.Hours()/(24*365)))
}
},
"humanizeBytes": func(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
},
"truncateDigest": func(digest string, length int) string {
if len(digest) <= length {
return digest
}
return digest[:length] + "..."
},
"firstChar": func(s string) string {
if len(s) == 0 {
return "?"
}
return string([]rune(s)[0])
},
"trimPrefix": func(prefix, s string) string {
if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
return s[len(prefix):]
}
return s
},
"hasPrefix": strings.HasPrefix,
"displayHoldDID": func(holdDID string) string {
// did:web:hold01.atcr.io → hold01.atcr.io
if after, ok := strings.CutPrefix(holdDID, "did:web:"); ok {
return after
}
// did:plc:opaque... → did:plc:opaque...xxxx (truncated)
if len(holdDID) > 20 {
return holdDID[:20] + "…"
}
return holdDID
},
"sanitizeID": func(s string) string {
// Replace special CSS selector characters with dashes
// e.g., "sha256:abc123" becomes "sha256-abc123"
// e.g., "v0.0.2" becomes "v0-0-2"
// e.g., "did:web:172.28.0.3%3A8080" becomes "did-web-172-28-0-3-3A8080"
s = strings.ReplaceAll(s, ":", "-")
s = strings.ReplaceAll(s, ".", "-")
s = strings.ReplaceAll(s, "%", "-")
return s
},
"parseLicenses": func(licensesStr string) []licenses.LicenseInfo {
return licenses.ParseLicenses(licensesStr)
},
"derefTime": func(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
},
"sub": func(a, b int) int {
return a - b
},
"sub64": func(a, b int64) int64 {
return a - b
},
"absInt": func(n int) int {
if n < 0 {
return -n
}
return n
},
"humanizeByteDelta": func(bytes int64) string {
prefix := "+"
if bytes < 0 {
prefix = "-"
bytes = -bytes
} else if bytes == 0 {
return "no change"
}
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%s%d B", prefix, bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%s%.1f %cB", prefix, float64(bytes)/float64(div), "KMGTPE"[exp])
},
"dict": func(values ...any) map[string]any {
dict := make(map[string]any, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, _ := values[i].(string)
dict[key] = values[i+1]
}
return dict
},
"resizeImage": func(imgURL string, width int) string {
if imgURL == "" {
return ""
}
// Only apply Cloudflare Image Resizing to imgs.blue URLs
parsed, err := url.Parse(imgURL)
if err != nil || parsed.Host != "imgs.blue" {
return imgURL
}
// Cloudflare uses /cdn-cgi/image/width=X/ path format
parsed.Path = fmt.Sprintf("/cdn-cgi/image/width=%d,format=auto%s", width, parsed.Path)
return parsed.String()
},
"assetHash": AssetHash,
// seamarkHeroTagline picks a random Seamark hero tagline on the
// server so the Seamark hero renders with its final copy on first
// paint (crawlers get a real tagline; users see no JS flash). The
// prior approach rewrote innerHTML ~50ms after load, which caused
// a visible flash and always served "your beacon at sea." to bots.
"seamarkHeroTagline": func() []string {
options := [][]string{
{"guiding you ", "at", " sea."},
{"your beacon ", "at", " sea."},
{"never lost ", "at", " sea."},
{"find your way ", "at", " sea."},
{"charting courses ", "at", " sea."},
}
return options[rand.IntN(len(options))]
},
// fontPreloads returns the list of woff2 fonts present in /fonts/
// so head.html can emit <link rel="preload"> tags without hardcoding
// filenames (which 404 silently when renamed). The *-ext subset
// variants are skipped — we only preload the primary-latin subsets
// that are used above the fold.
"fontPreloads": func() []string {
return fontPreloadPaths(overrides)
},
"formatDate": func(t time.Time) string {
return t.Format("Jan 2, 2006")
},
"isZeroTime": func(t time.Time) bool {
return t.IsZero() || t.Year() < 2000
},
// pluralize picks singular/plural based on count. Use whenever a
// template would otherwise write "{{ if gt .N 1 }}s{{ end }}", which
// breaks for 0, negatives, and anything non-English.
// Usage: {{ pluralize .Count "package" "packages" }}
"pluralize": func(n int, singular, plural string) string {
if n == 1 || n == -1 {
return singular
}
return plural
},
// humanizeTime renders an absolute, human-readable timestamp.
// Distinct from timeAgo (relative) — use for tooltips and places
// where a stable formatted date is preferable to "3 days ago".
// Zero times render as empty to avoid "Jan 1, 0001" leakage.
"humanizeTime": func(t time.Time) string {
if t.IsZero() || t.Year() < 2000 {
return ""
}
return t.Format("Jan 2, 2006 at 3:04 PM MST")
},
// humanizeCount renders integers with compact suffix (1.2K, 3.4M, 5.6B).
// Numbers below 1000 render as-is. Negative values are prefixed with
// a minus sign (rare for counts but correctness beats surprise).
// Accepts any integer kind via reflect so templates can pass `int`,
// `int64`, etc. without an explicit conversion helper.
"humanizeCount": humanizeCount,
// severityLabel maps a severity code (C/H/M/L/N/U or a full name) to
// its canonical full-word label. Use alongside the color class so
// screen readers announce the word even when sighted users see only
// the initial: <span class="sr-only">{{ severityLabel "C" }}</span>C
"severityLabel": func(code string) string {
switch strings.ToUpper(strings.TrimSpace(code)) {
case "C", "CRIT", "CRITICAL":
return "Critical"
case "H", "HIGH":
return "High"
case "M", "MED", "MEDIUM":
return "Medium"
case "L", "LOW":
return "Low"
case "N", "NEG", "NEGLIGIBLE":
return "Negligible"
case "U", "UNK", "UNKNOWN":
return "Unknown"
default:
return code
}
},
// icon renders an SVG icon from the sprite sheet
// Usage: {{ icon "star" "size-4 text-amber-400" }}
// The name is the icon ID in icons.svg, classes are applied to the SVG element
"icon": func(name, classes string) template.HTML {
return template.HTML(fmt.Sprintf(
`<svg class="icon %s" aria-hidden="true"><use href="/icons.svg#%s"></use></svg>`,
template.HTMLEscapeString(classes),
template.HTMLEscapeString(name),
))
},
// jsonldScript renders a complete <script type="application/ld+json"> block.
// Returns the whole block as template.HTML to avoid html/template's JS context
// escaping that double-encodes JSON inside <script> tags.
// See https://github.com/golang/go/issues/20886
// Usage: {{ jsonldScript .SomeStruct }}
"jsonldScript": func(v any) template.HTML {
var jsonBytes []byte
if s, ok := v.(string); ok {
jsonBytes = []byte(s)
} else {
var err error
jsonBytes, err = json.MarshalIndent(v, " ", " ")
if err != nil {
jsonBytes = []byte("{}")
}
}
return template.HTML("<script type=\"application/ld+json\">\n " + string(jsonBytes) + "\n </script>")
},
// ociClientName returns the OCI client name, defaulting to "docker" if empty.
// Usage: {{ ociClientName .OciClient }}
"ociClientName": func(client string) string {
if client == "" {
return "docker"
}
return client
},
// pullPrefix returns the "<client> pull " prefix for a pull command, or an
// empty string when the user has selected "none" (image reference only).
// Usage: {{ pullPrefix .OciClient }}
"pullPrefix": func(client string) string {
if client == "none" {
return ""
}
if client == "" {
return "docker pull "
}
return client + " pull "
},
// pullPostfix returns the trailing argument a pull command needs after
// the image reference, including its leading space. Only crane needs
// one: `crane pull <ref>` fails with "requires at least 2 arg(s)"
// because the destination tarball is mandatory, so we emit
// " <image>.tar". Every other client, and "none" (image reference
// only), gets an empty string.
//
// Repository names may contain slashes; only the last path segment is
// used so the tarball lands in the working directory instead of a
// subdirectory that may not exist.
// Usage: {{ pullPostfix .OciClient .RepoName }}
"pullPostfix": func(client, image string) string {
if client != "crane" {
return ""
}
base := image
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if base == "" {
return ""
}
return " " + base + ".tar"
},
// toJSON marshals any value to a JSON string safe for use in HTML attributes.
// json.Marshal escapes <, >, & and properly escapes " inside strings,
// so the result can be used as template.HTML without further escaping.
// Usage: hx-vals='{{ dict "repo" .Repo "tag" .Tag | toJSON }}'
"toJSON": func(v any) template.HTML {
b, err := json.Marshal(v)
if err != nil {
return template.HTML("{}")
}
return template.HTML(b)
},
// extraCSS returns a <style> block with consumer CSS overrides, or empty string.
"extraCSS": func() template.HTML {
if extraCSS == "" {
return ""
}
return template.HTML("<style>" + extraCSS + "</style>")
},
}
// Merge extra func map from overrides
if overrides != nil && overrides.ExtraFuncMap != nil {
maps.Copy(funcMap, overrides.ExtraFuncMap)
}
tmpl := template.New("").Funcs(funcMap)
// Parse default templates
tfs := resolveTemplatesFS(overrides)
tmpl, err := tmpl.ParseFS(tfs, "templates/**/*.html")
if err != nil {
return nil, err
}
return tmpl, nil
}
// PublicHandler returns HTTP handler for static files.
// Pass nil for default atcr.io behavior.
func PublicHandler(overrides *BrandingOverrides) http.Handler {
fsys := resolvePublicFS(overrides)
sub, err := fs.Sub(fsys, "public")
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}
// PublicRootFiles returns list of root-level files in static directory (not subdirectories).
// Pass nil for default atcr.io behavior.
func PublicRootFiles(overrides *BrandingOverrides) ([]string, error) {
fsys := resolvePublicFS(overrides)
var entries []fs.DirEntry
var err error
if rdfs, ok := fsys.(fs.ReadDirFS); ok {
entries, err = rdfs.ReadDir("public")
} else {
entries, err = fs.ReadDir(fsys, "public")
}
if err != nil {
return nil, err
}
var files []string
for _, entry := range entries {
// Only include files, not directories
if !entry.IsDir() {
files = append(files, entry.Name())
}
}
return files, nil
}
// PublicSubdir returns an http.Handler for a subdirectory within public/.
// Pass nil for default atcr.io behavior.
func PublicSubdir(name string, overrides *BrandingOverrides) http.Handler {
fsys := resolvePublicFS(overrides)
sub, err := fs.Sub(fsys, "public/"+name)
if err != nil {
panic(err)
}
return http.FileServer(http.FS(sub))
}