From eee8ee1c31a6999ca73a2f5a5a62bdd6bc03d609 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sat, 25 Jul 2026 14:16:22 +1000 Subject: [PATCH] feat(ui): add "How do I use this?" usage instructions to repo detail pages (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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//...`). - **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//...` 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: https://git.unkin.net/unkin/artifactapi/pulls/105 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- ui/src/components/UsageInstructions.css | 99 ++++++++ ui/src/components/UsageInstructions.tsx | 290 ++++++++++++++++++++++++ ui/src/pages/LocalDetail.tsx | 3 + ui/src/pages/RemoteDetail.tsx | 3 + ui/src/pages/Virtuals.tsx | 7 + 5 files changed, 402 insertions(+) create mode 100644 ui/src/components/UsageInstructions.css create mode 100644 ui/src/components/UsageInstructions.tsx diff --git a/ui/src/components/UsageInstructions.css b/ui/src/components/UsageInstructions.css new file mode 100644 index 0000000..6aa26fd --- /dev/null +++ b/ui/src/components/UsageInstructions.css @@ -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; +} diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx new file mode 100644 index 0000000..357dea4 --- /dev/null +++ b/ui/src/components/UsageInstructions.tsx @@ -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 `, + 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/ `, + }, + { + 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 `, + }, + ]; + + case 'npm': + return [ + { + title: 'Point npm at this registry', + language: 'bash', + code: `npm config set registry ${proxy}/ +npm install `, + }, + { + 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}/:`, + 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}/" + version = ">= 0.1.0" + } + } +}`, + note: 'The namespace segment is this repo name; 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-_0.1.0_linux_amd64.zip \\ + ${url}/api/v2/remotes/${name}/files/${name}//terraform-provider-_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 ${name}/`, + }, + ]; + + 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 `, + 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 - \\ + --module_repository ${proxy}`, + }, + ]; + + case 'generic': + default: + return [ + { + title: 'Download a file', + language: 'bash', + code: `curl -fsSLO ${proxy}/`, + 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//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 ( +
+
{snippet.title}
+
+ +
{snippet.code}
+
+ {snippet.note &&
{snippet.note}
} +
+ ); +} + +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 ( +
+ + {open && ( +
+ {snippets.map((s, i) => ( + + ))} +
+ )} +
+ ); +} diff --git a/ui/src/pages/LocalDetail.tsx b/ui/src/pages/LocalDetail.tsx index 7ecc784..cc76630 100644 --- a/ui/src/pages/LocalDetail.tsx +++ b/ui/src/pages/LocalDetail.tsx @@ -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() {

{remote.description}

)} + +
Browse Files diff --git a/ui/src/pages/RemoteDetail.tsx b/ui/src/pages/RemoteDetail.tsx index 7a2bf95..f8a5063 100644 --- a/ui/src/pages/RemoteDetail.tsx +++ b/ui/src/pages/RemoteDetail.tsx @@ -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() { )}
+ +
Browse Objects diff --git a/ui/src/pages/Virtuals.tsx b/ui/src/pages/Virtuals.tsx index 08e38ad..41dceb1 100644 --- a/ui/src/pages/Virtuals.tsx +++ b/ui/src/pages/Virtuals.tsx @@ -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() { ); })} + {(() => { + const v = virtuals.find(x => x.name === expanded); + return v ? ( + + ) : null; + })()}
)}