Compare commits

...

3 Commits

Author SHA1 Message Date
unkinben eee8ee1c31 feat(ui): add "How do I use this?" usage instructions to repo detail pages (#105)
ci/woodpecker/tag/docker Pipeline was successful
## Why

A repository detail page in the ArtifactAPI UI showed configuration and stats, but nothing that told a user how to actually *consume* the repo. You had to already know the per-package-type URL scheme (yum baseurl, pip index-url, docker registry host, terraform source address, ...) by hand. This adds an in-page, copy-pasteable "How do I use this?" panel so each repo page tells you exactly how to point a Linux host at it.

## Changes

- Add a `UsageInstructions` component: a collapsible "How do I use this?" panel with monospace code boxes and copy-to-clipboard buttons, styled to match the existing detail-section / badge theme.
- Generate instructions per package type (rpm, pypi, npm, docker, terraform, helm, alpine, goproxy, puppet, generic) and per class:
  - **remote** — consume via the caching proxy (`/api/v1/remote/<name>/...`).
  - **local** — consume via the real registry endpoint, plus a publish/push example (rpm `PUT .../files/`, docker Registry V2 push, terraform provider upload).
  - **virtual** — consume the merged index via `/api/v1/virtual/<name>/...` using the same per-type client config.
- Interpolate the repository's real name into every snippet so it is genuinely copy-pasteable.
- Resolve the instance base URL from `window.location.origin` (the UI is served on the API origin, client `BASE=''`) instead of hardcoding a hostname; falls back to the public host only when `window` is unavailable.
- Render the panel on `RemoteDetail`, `LocalDetail`, and the `Virtuals` member-expand panel.

## Verification

- `npm run build` (tsc typecheck + vite build) passes.
- Rendered the component in headless Chromium against real repo data for rpm remote, rpm local (with publish), docker local (with push), terraform local (HCL `required_providers` + signing note), and a pypi virtual — all snippets render with the correct URLs and theme.

Reviewed-on: #105
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-25 14:16:22 +10:00
unkinben f6b0afc5d6 feat(ui): direct-download links for downloadable local repo files (#106)
## Why

The local-repo object browser renders every file name as inert text, so there is no way to grab a file from the UI even though local rpm repos already serve their contents as real yum repos. Users have to hand-construct URLs. This adds one-click downloads, built as a modular per-repo-type capability so other types can be switched on later with a single map entry.

## Changes

- Adds a `downloadableTypes` capability map (`ui/src/components/downloads.ts`) keyed by `package_type`; each entry builds the direct-download URL for a file.
- Enables `rpm`, pointing at the yum files route `/api/v2/remotes/<repo>/files/<path>` (path-segment-encoded).
- Renders file names in the object browser as `<a href download>` links when the repo's type is downloadable, and as plain text otherwise.
- Fetches the repo's `package_type` on the Objects page as the modularity hook and threads it through the tree rows.

## Notes

Download links are same-origin unauthenticated GETs (the API serves local repos with no token on reads, matching how yum clients fetch), so a bare `href` carries no credentials. Adding a future type is one entry in `downloadableTypes`.

---------

Co-authored-by: Ben Vin <neotheo@gmail.com>
Reviewed-on: #106
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-25 14:15:25 +10:00
unkinben 649f89f58b fix: make local docker uploads replica-independent (#104)
ci/woodpecker/tag/docker Pipeline was successful
## Why

Chunked blob uploads kept the in-progress session in **process memory** keyed by upload UUID, so the `POST`/`PATCH`/`PUT` of a single `docker push` had to land on the same replica. The API runs at `minReplicas: 2` with no session affinity (see argocd-apps `api-hpa.yaml`), so a real push — which streams the layer via `PATCH` then finalises with `PUT` — intermittently 404s with `BLOB_UPLOAD_UNKNOWN` when a chunk hits a replica that never saw the `POST`. This was flagged when the local docker registry landed (#103).

## Changes

- Stage chunked uploads in object storage under `uploads/<uuid>` instead of an in-memory temp file. The UUID travels in the `Location` URL handed to the client, so any replica reconstructs the staging key with no shared in-process state. Finalise streams the staged bytes plus any trailing `PUT` body through the CAS in one pass; monolithic uploads are unchanged.
- Support `DELETE` of an in-progress upload (cancel) by dropping its staging object.
- Reap abandoned staging objects in the GC (`uploads/` older than 24h) via a new `S3.ListStaleObjects`, so cancelled/interrupted pushes don't leak.

## Verification

- Split a single push across **two instances sharing one Postgres+MinIO**: `POST`→A, `PATCH`→B, `PUT`→A finalises with the correct digest, and the blob pulls back **byte-identical from both** replicas. Config-blob and manifest pushes split the same way succeed; `tags/list` is correct. (Pre-fix, the cross-replica `PATCH` 404s.)
- `scripts/docker-e2e.sh` still passes (incl. `TestLocalDockerPushPull`); unit tests + `go vet` clean.

Reviewed-on: #104
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-05 17:39:49 +10:00
12 changed files with 603 additions and 70 deletions
+77 -64
View File
@@ -1,6 +1,8 @@
package v1
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
@@ -11,7 +13,6 @@ import (
"os"
"sort"
"strings"
"sync"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
@@ -36,52 +37,54 @@ import (
const dockerAPIVersionHeader = "registry/2.0"
// uploadSession is an in-progress chunked blob upload, buffered to a temp file
// on disk. Sessions are held in-memory keyed by upload UUID, so a single push's
// PATCH/PUT chunks must be served by the same replica — true for the
// homelab single-instance deployment. Monolithic uploads avoid this entirely.
type uploadSession struct {
file *os.File
size int64
}
// Chunked blob uploads are staged in object storage under uploads/<uuid> rather
// than in process memory, so the POST / PATCH / PUT of a single push can each be
// served by a different replica (the API runs with minReplicas>1 and no session
// affinity). The upload UUID travels in the Location URL handed back to the
// client, so any replica reconstructs the staging key with no shared in-process
// state. Abandoned stages are dropped by the GC's uploads sweep.
func uploadKey(id string) string { return "uploads/" + id }
type uploadStore struct {
mu sync.Mutex
sessions map[string]*uploadSession
}
var errUploadUnknown = errors.New("unknown upload")
func newUploadStore() *uploadStore {
return &uploadStore{sessions: make(map[string]*uploadSession)}
}
func (s *uploadStore) create() (string, *uploadSession, error) {
f, err := os.CreateTemp("", "docker-upload-*")
// appendUpload appends a chunk to the staged upload object and returns the new
// total size. The staged bytes live entirely in object storage (download,
// append to a per-request temp file, re-upload), which keeps the session state
// replica-independent. Docker sends the whole layer in one PATCH, so this is a
// single append in the common case.
func (h *ProxyHandler) appendUpload(ctx context.Context, id string, chunk io.Reader) (int64, error) {
key := uploadKey(id)
reader, info, err := h.store.Download(ctx, key)
if err != nil {
return "", nil, err
return 0, errUploadUnknown
}
id := uuid.NewString()
sess := &uploadSession{file: f}
s.mu.Lock()
s.sessions[id] = sess
s.mu.Unlock()
return id, sess, nil
}
func (s *uploadStore) get(id string) *uploadSession {
s.mu.Lock()
defer s.mu.Unlock()
return s.sessions[id]
}
func (s *uploadStore) remove(id string) {
s.mu.Lock()
sess := s.sessions[id]
delete(s.sessions, id)
s.mu.Unlock()
if sess != nil {
sess.file.Close()
os.Remove(sess.file.Name())
tmp, err := os.CreateTemp("", "docker-upload-*")
if err != nil {
reader.Close()
return 0, err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
if _, err := io.Copy(tmp, reader); err != nil {
reader.Close()
return 0, err
}
reader.Close()
n, err := io.Copy(tmp, chunk)
if err != nil {
return 0, err
}
size := info.Size + n
if _, err := tmp.Seek(0, io.SeekStart); err != nil {
return 0, err
}
if err := h.store.Upload(ctx, key, tmp, size, "application/octet-stream"); err != nil {
return 0, err
}
return size, nil
}
// dockerReq is a parsed /v2/<remote>/<image>/... request. kind is one of
@@ -205,7 +208,18 @@ func (h *ProxyHandler) dockerDelete(w http.ResponseWriter, r *http.Request) {
return
}
req, ok := parseDockerPath(chi.URLParam(r, "*"))
if !ok || (req.kind != "manifest" && req.kind != "blob") {
if !ok {
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
return
}
// Cancel an in-progress upload: drop its staging object.
if req.kind == "upload" && req.ref != "" {
_ = h.store.Delete(r.Context(), uploadKey(req.ref))
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusNoContent)
return
}
if req.kind != "manifest" && req.kind != "blob" {
dockerError(w, http.StatusNotFound, "NAME_UNKNOWN", "unrecognised registry path")
return
}
@@ -333,8 +347,9 @@ func (h *ProxyHandler) dockerStartUpload(w http.ResponseWriter, r *http.Request,
return
}
id, _, err := h.uploads.create()
if err != nil {
// Stage an empty object keyed by the upload UUID; PATCH/PUT append to it.
id := uuid.NewString()
if err := h.store.Upload(r.Context(), uploadKey(id), bytes.NewReader(nil), 0, "application/octet-stream"); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
@@ -352,21 +367,19 @@ func (h *ProxyHandler) dockerPatchUpload(w http.ResponseWriter, r *http.Request,
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
sess := h.uploads.get(req.ref)
if sess == nil {
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
n, err := io.Copy(sess.file, r.Body)
size, err := h.appendUpload(r.Context(), req.ref, r.Body)
if err != nil {
if errors.Is(err, errUploadUnknown) {
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
sess.size += n
loc := fmt.Sprintf("/v2/%s/%s/blobs/uploads/%s", remote.Name, req.image, req.ref)
w.Header().Set("Location", loc)
w.Header().Set("Docker-Upload-UUID", req.ref)
w.Header().Set("Range", fmt.Sprintf("0-%d", sess.size-1))
w.Header().Set("Range", fmt.Sprintf("0-%d", size-1))
w.Header().Set("Docker-Distribution-Api-Version", dockerAPIVersionHeader)
w.WriteHeader(http.StatusAccepted)
}
@@ -384,22 +397,22 @@ func (h *ProxyHandler) dockerFinishUpload(w http.ResponseWriter, r *http.Request
h.dockerCommitBlob(w, r, remote, req.image, digest, r.Body)
return
}
sess := h.uploads.get(req.ref)
if sess == nil {
key := uploadKey(req.ref)
reader, _, err := h.store.Download(r.Context(), key)
if err != nil {
dockerError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "unknown upload")
return
}
defer h.uploads.remove(req.ref)
defer reader.Close()
// Drop the staging object once we're done, regardless of outcome; a fresh
// context so cleanup still runs if the client disconnects.
defer h.store.Delete(context.Background(), key)
if _, err := io.Copy(sess.file, r.Body); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
if _, err := sess.file.Seek(0, io.SeekStart); err != nil {
dockerError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
return
}
h.dockerCommitBlob(w, r, remote, req.image, digest, sess.file)
// Stream the staged bytes plus any trailing PUT body through the CAS in one
// pass — no extra round trip to re-assemble.
combined := io.MultiReader(reader, r.Body)
h.dockerCommitBlob(w, r, remote, req.image, digest, combined)
}
// dockerCommitBlob stores blob bytes through the CAS, verifies the client's
-2
View File
@@ -24,7 +24,6 @@ type ProxyHandler struct {
store *storage.S3
local *v2.LocalHandler
cas *storage.CAS
uploads *uploadStore
}
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB, store *storage.S3, local *v2.LocalHandler) *ProxyHandler {
@@ -35,7 +34,6 @@ func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *da
store: store,
local: local,
cas: storage.NewCAS(store),
uploads: newUploadStore(),
}
}
+28
View File
@@ -14,6 +14,11 @@ import (
// before the referencing artifact/local_files row exists.
const blobGracePeriod = 1 * time.Hour
// uploadGracePeriod is how long a docker blob-upload staging object
// (uploads/<uuid>) may sit idle before GC treats it as an abandoned push and
// reaps it. Generous so a slow but live push is never cut off mid-flight.
const uploadGracePeriod = 24 * time.Hour
type Collector struct {
db *database.DB
store *storage.S3
@@ -43,6 +48,8 @@ func (c *Collector) Run(ctx context.Context) {
func (c *Collector) sweep(ctx context.Context) {
start := time.Now()
c.sweepUploads(ctx)
orphaned, err := c.db.FindOrphanedBlobs(ctx, blobGracePeriod)
if err != nil {
slog.Error("gc: find orphaned blobs", "error", err)
@@ -70,3 +77,24 @@ func (c *Collector) sweep(ctx context.Context) {
)
}
}
// sweepUploads reaps docker blob-upload staging objects abandoned longer than
// uploadGracePeriod (cancelled or interrupted pushes that never finalised).
func (c *Collector) sweepUploads(ctx context.Context) {
stale, err := c.store.ListStaleObjects(ctx, "uploads/", time.Now().Add(-uploadGracePeriod))
if err != nil {
slog.Error("gc: list stale uploads", "error", err)
return
}
reaped := 0
for _, key := range stale {
if err := c.store.Delete(ctx, key); err != nil {
slog.Warn("gc: delete stale upload", "key", key, "error", err)
continue
}
reaped++
}
if reaped > 0 {
slog.Info("gc: reaped stale docker uploads", "count", reaped)
}
}
+16
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"io"
"log/slog"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
@@ -97,3 +98,18 @@ func (s *S3) Stat(ctx context.Context, key string) (*minio.ObjectInfo, error) {
}
return &info, nil
}
// ListStaleObjects returns keys under prefix last modified before cutoff. Used
// by the GC to reap abandoned staging objects (e.g. cancelled docker pushes).
func (s *S3) ListStaleObjects(ctx context.Context, prefix string, cutoff time.Time) ([]string, error) {
var keys []string
for obj := range s.client.ListObjects(ctx, s.bucket, minio.ListObjectsOptions{Prefix: prefix, Recursive: true}) {
if obj.Err != nil {
return nil, obj.Err
}
if obj.LastModified.Before(cutoff) {
keys = append(keys, obj.Key)
}
}
return keys, nil
}
+99
View File
@@ -0,0 +1,99 @@
.usage-panel {
margin: 24px 0;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.usage-toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 14px 18px;
background: transparent;
border: none;
color: var(--text-bright);
font-size: 0.95em;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.usage-toggle:hover {
background: var(--bg-elevated);
}
.usage-caret {
display: inline-block;
transition: transform 0.15s;
color: var(--text-muted);
font-size: 0.9em;
}
.usage-caret.open {
transform: rotate(90deg);
}
.usage-body {
padding: 4px 18px 18px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 18px;
}
.usage-snippet-title {
font-size: 0.85em;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
margin: 14px 0 8px;
}
.usage-codebox {
position: relative;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.usage-codebox pre {
margin: 0;
padding: 14px 16px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 0.85em;
line-height: 1.5;
color: var(--text-bright);
white-space: pre;
}
.usage-copy-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 3px 10px;
font-size: 0.75em;
font-family: var(--font-sans);
color: var(--text-muted);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
.usage-copy-btn:hover {
color: var(--text-bright);
border-color: var(--accent);
}
.usage-note {
margin-top: 8px;
font-size: 0.82em;
color: var(--text-muted);
line-height: 1.45;
}
+290
View File
@@ -0,0 +1,290 @@
import { useState } from 'react';
import './UsageInstructions.css';
// repoClass distinguishes the three ways a repository is consumed. remotes are
// caching proxies, locals are real registries you also publish to, virtuals are
// merged read-only indexes.
type RepoClass = 'remote' | 'local' | 'virtual';
interface Snippet {
title: string;
language: string;
code: string;
note?: string;
}
// baseURL resolves the externally reachable origin of this artifactapi instance.
// The UI is served on the same origin as the API (client BASE is ''), so
// window.location.origin is the address a host would actually curl/pull against
// — no hardcoded hostname, works in prod and in `npm run dev` behind a proxy.
function baseURL(): string {
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin.replace(/\/$/, '');
}
return 'https://artifactapi.k8s.syd1.au.unkin.net';
}
// hostOnly is the bare host[:port] with no scheme, for docker/terraform source
// addresses which are scheme-less.
function hostOnly(): string {
try {
return new URL(baseURL()).host;
} catch {
return 'artifactapi.k8s.syd1.au.unkin.net';
}
}
// remoteProxyBase is where a remote (or virtual) repo's proxied artifacts live.
function remoteProxyBase(cls: RepoClass, name: string): string {
const seg = cls === 'virtual' ? 'virtual' : 'remote';
return `${baseURL()}/api/v1/${seg}/${name}`;
}
export function buildSnippets(packageType: string, repoClass: RepoClass, name: string): Snippet[] {
const url = baseURL();
const host = hostOnly();
const proxy = remoteProxyBase(repoClass, name);
const isLocal = repoClass === 'local';
switch (packageType) {
case 'rpm':
return [
{
title: isLocal
? 'Add the yum repo (real yum repo, repodata auto-regenerated)'
: 'Add the yum repo (caching proxy)',
language: 'bash',
code: `sudo tee /etc/yum.repos.d/${name}.repo >/dev/null <<'EOF'
[${name}]
name=${name} (artifactapi)
baseurl=${isLocal ? `${url}/api/v2/remotes/${name}/files/` : `${proxy}/`}
enabled=1
gpgcheck=0
repo_gpgcheck=0
EOF
sudo dnf install <package>`,
note: isLocal
? 'gpgcheck=0: artifactapi serves the repo unsigned. If you sign your RPMs, import your key and set gpgcheck=1.'
: 'gpgcheck=0 trusts upstream over the proxy. To verify package signatures, import the upstream GPG key and set gpgcheck=1.',
},
...(isLocal
? [
{
title: 'Publish an RPM (repodata regenerates automatically)',
language: 'bash',
code: `curl -fsSL --upload-file ./my-package-1.0-1.el9.x86_64.rpm \\
${url}/api/v2/remotes/${name}/files/my-package-1.0-1.el9.x86_64.rpm`,
},
]
: []),
];
case 'pypi':
return [
{
title: 'Install a package (one-off)',
language: 'bash',
code: `pip install --index-url ${proxy}/simple/ <package>`,
},
{
title: 'Configure pip persistently',
language: 'bash',
code: `mkdir -p ~/.config/pip
cat > ~/.config/pip/pip.conf <<'EOF'
[global]
index-url = ${proxy}/simple/
EOF
pip install <package>`,
},
];
case 'npm':
return [
{
title: 'Point npm at this registry',
language: 'bash',
code: `npm config set registry ${proxy}/
npm install <package>`,
},
{
title: 'Per-project (.npmrc)',
language: 'bash',
code: `echo 'registry=${proxy}/' >> .npmrc
npm install`,
},
];
case 'docker':
return [
{
title: 'Pull an image',
language: 'bash',
code: `docker pull ${host}/${name}/<image>:<tag>`,
note: 'The first path segment after the host is the artifactapi repo name; the rest is the image name.',
},
...(isLocal
? [
{
title: 'Push an image (this is a real Registry V2)',
language: 'bash',
code: `docker tag myapp:latest ${host}/${name}/myapp:latest
docker push ${host}/${name}/myapp:latest`,
note: 'Works with docker, podman, skopeo and buildah. If the registry requires auth, run `docker login ' + host + '` first.',
},
]
: []),
];
case 'terraform':
return [
{
title: 'Use as a provider source (bare address, no mirror config)',
language: 'hcl',
code: `terraform {
required_providers {
${name} = {
source = "${host}/${name}/<type>"
version = ">= 0.1.0"
}
}
}`,
note: 'The namespace segment is this repo name; <type> is the provider type. artifactapi signs SHA256SUMS server-side with its GPG key, so `terraform init` installs with no .terraformrc.',
},
...(isLocal
? [
{
title: 'Publish a provider build',
language: 'bash',
code: `curl -fsSL --upload-file terraform-provider-<type>_0.1.0_linux_amd64.zip \\
${url}/api/v2/remotes/${name}/files/${name}/<type>/terraform-provider-<type>_0.1.0_linux_amd64.zip`,
},
]
: []),
];
case 'helm':
return [
{
title: 'Add the Helm repo',
language: 'bash',
code: `helm repo add ${name} ${proxy}/
helm repo update
helm install <release> ${name}/<chart>`,
},
];
case 'alpine':
return [
{
title: 'Add the APK repository',
language: 'bash',
code: `echo '${proxy}/' | sudo tee -a /etc/apk/repositories
sudo apk update
sudo apk add <package>`,
note: 'If the index is unsigned over the proxy, add --allow-untrusted or install the signing key into /etc/apk/keys.',
},
];
case 'goproxy':
return [
{
title: 'Point the Go module proxy here',
language: 'bash',
code: `export GOPROXY=${proxy}
go mod download`,
note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.',
},
];
case 'puppet':
return [
{
title: 'Install a module from the Forge proxy',
language: 'bash',
code: `puppet module install <author>-<module> \\
--module_repository ${proxy}`,
},
];
case 'generic':
default:
return [
{
title: 'Download a file',
language: 'bash',
code: `curl -fsSLO ${proxy}/<path>`,
note:
packageType === 'generic'
? 'Generic repos are fetched as plain files at their upstream path.'
: `No tailored client instructions for "${packageType}" yet — fetch artifacts directly by path.`,
},
...(isLocal
? [
{
title: 'Publish a file',
language: 'bash',
code: `curl -fsSL --upload-file ./myfile \\
${url}/api/v2/remotes/${name}/files/<path>/myfile`,
},
]
: []),
];
}
}
function CodeBox({ snippet }: { snippet: Snippet }) {
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(snippet.code);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard API unavailable (e.g. non-secure context); silently ignore.
}
}
return (
<div className="usage-snippet">
<div className="usage-snippet-title">{snippet.title}</div>
<div className="usage-codebox">
<button className="usage-copy-btn" onClick={copy} type="button">
{copied ? 'copied' : 'copy'}
</button>
<pre className="mono">{snippet.code}</pre>
</div>
{snippet.note && <div className="usage-note">{snippet.note}</div>}
</div>
);
}
interface UsageInstructionsProps {
packageType: string;
repoClass: RepoClass;
name: string;
defaultOpen?: boolean;
}
export function UsageInstructions({ packageType, repoClass, name, defaultOpen = false }: UsageInstructionsProps) {
const [open, setOpen] = useState(defaultOpen);
const snippets = buildSnippets(packageType, repoClass, name);
return (
<div className="usage-panel">
<button className="usage-toggle" onClick={() => setOpen(o => !o)} type="button" aria-expanded={open}>
<span className={`usage-caret ${open ? 'open' : ''}`}>&#9656;</span>
How do I use this?
</button>
{open && (
<div className="usage-body">
{snippets.map((s, i) => (
<CodeBox key={i} snippet={s} />
))}
</div>
)}
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
// Per-repo-type "downloadable" capability map.
//
// When a repo's package_type has an entry here, file entries in the object
// browser render as direct-download links pointing at the URL the entry
// builds. Types absent from the map render as plain text. Enabling a new
// type is a one-line addition below.
//
// Download routes are same-origin, unauthenticated GETs (the API serves local
// repos as real registries with no token on reads), so a bare <a href download>
// works and carries no credentials.
// buildUrl receives the repo name and the artifact's full path (may contain
// slashes) and returns the direct-download URL for that file.
type DownloadUrlBuilder = (repo: string, path: string) => string;
export const downloadableTypes: Record<string, DownloadUrlBuilder> = {
// rpm locals are real yum repos; files are served at
// /api/v2/remotes/<repo>/files/<path>.
rpm: (repo, path) =>
`/api/v2/remotes/${encodeURIComponent(repo)}/files/${path
.split('/')
.map(encodeURIComponent)
.join('/')}`,
};
// downloadUrlFor returns the direct-download URL for a file when its repo type
// is downloadable, or null otherwise (render as plain text).
export function downloadUrlFor(
packageType: string | undefined,
repo: string,
path: string,
): string | null {
if (!packageType) return null;
const build = downloadableTypes[packageType];
return build ? build(repo, path) : null;
}
+3
View File
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import { api } from '../api/client';
import type { Remote } from '../api/types';
import { Badge } from '../components/Badge';
import { UsageInstructions } from '../components/UsageInstructions';
import './RemoteDetail.css';
export function LocalDetail() {
@@ -36,6 +37,8 @@ export function LocalDetail() {
<p className="detail-description">{remote.description}</p>
)}
<UsageInstructions packageType={remote.package_type} repoClass="local" name={remote.name} />
<div className="detail-actions">
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
Browse Files
+9
View File
@@ -37,6 +37,15 @@
word-break: break-all;
}
.tree-file-link {
color: var(--accent, #4c9aff);
text-decoration: none;
}
.tree-file-link:hover {
text-decoration: underline;
}
.tree-dir {
cursor: pointer;
}
+35 -4
View File
@@ -3,6 +3,7 @@ import { useParams, useLocation, Link } from 'react-router-dom';
import { api } from '../api/client';
import type { Artifact } from '../api/types';
import { formatBytes, timeAgo, truncateHash } from '../components/format';
import { downloadUrlFor } from '../components/downloads';
import './Objects.css';
interface TreeNode {
@@ -100,11 +101,16 @@ interface TreeRowProps {
expanded: Set<string>;
onToggle: (path: string) => void;
onEvict: (path: string) => void;
repo: string;
packageType?: string;
}
function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
function TreeRow({ node, depth, expanded, onToggle, onEvict, repo, packageType }: TreeRowProps) {
const isDir = node.children.size > 0 && !node.artifact;
const isExpanded = expanded.has(node.path);
const downloadUrl = node.artifact
? downloadUrlFor(packageType, repo, node.artifact.path)
: null;
const sortedChildren = useMemo(() => {
if (!isDir) return [];
@@ -124,9 +130,20 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
{isDir && (
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
)}
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
{node.name}{isDir ? '/' : ''}
</span>
{downloadUrl ? (
<a
className="mono tree-file-name tree-file-link"
href={downloadUrl}
download
onClick={(e) => e.stopPropagation()}
>
{node.name}
</a>
) : (
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
{node.name}{isDir ? '/' : ''}
</span>
)}
</span>
</td>
<td className="num-cell">{formatBytes(node.totalSize)}</td>
@@ -163,6 +180,8 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
expanded={expanded}
onToggle={onToggle}
onEvict={onEvict}
repo={repo}
packageType={packageType}
/>
))}
</>
@@ -175,6 +194,7 @@ export function Objects() {
const isLocal = location.pathname.startsWith('/locals/');
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [packageType, setPackageType] = useState<string | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(new Set());
@@ -190,6 +210,15 @@ export function Objects() {
useEffect(() => { load(); }, [load]);
// The repo's package_type is the modularity hook: it decides whether file
// names render as direct-download links (see downloadableTypes).
useEffect(() => {
if (!name) return;
api.getRemote(name)
.then(r => setPackageType(r.package_type))
.catch(() => setPackageType(undefined));
}, [name]);
const handleEvict = async (path: string) => {
if (!name || !confirm(`Evict ${path}?`)) return;
await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path));
@@ -287,6 +316,8 @@ export function Objects() {
expanded={expanded}
onToggle={toggleExpand}
onEvict={handleEvict}
repo={name!}
packageType={packageType}
/>
))
)}
+3
View File
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
import { api } from '../api/client';
import type { Remote } from '../api/types';
import { Badge } from '../components/Badge';
import { UsageInstructions } from '../components/UsageInstructions';
import './RemoteDetail.css';
export function RemoteDetail() {
@@ -109,6 +110,8 @@ export function RemoteDetail() {
)}
</div>
<UsageInstructions packageType={remote.package_type} repoClass="remote" name={remote.name} />
<div className="detail-actions">
<Link to={`/remotes/${remote.name}/objects`} className="btn btn-primary">
Browse Objects
+7
View File
@@ -4,6 +4,7 @@ import { api } from '../api/client';
import type { Remote, Virtual } from '../api/types';
import { Badge } from '../components/Badge';
import { DataTable } from '../components/DataTable';
import { UsageInstructions } from '../components/UsageInstructions';
import './Virtuals.css';
export function Virtuals() {
@@ -98,6 +99,12 @@ export function Virtuals() {
);
})}
</ul>
{(() => {
const v = virtuals.find(x => x.name === expanded);
return v ? (
<UsageInstructions packageType={v.package_type} repoClass="virtual" name={v.name} defaultOpen />
) : null;
})()}
</div>
)}
</div>