Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee8ee1c31 | |||
| f6b0afc5d6 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' : ''}`}>▸</span>
|
||||||
|
How do I use this?
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="usage-body">
|
||||||
|
{snippets.map((s, i) => (
|
||||||
|
<CodeBox key={i} snippet={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Remote } from '../api/types';
|
import type { Remote } from '../api/types';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
|
import { UsageInstructions } from '../components/UsageInstructions';
|
||||||
import './RemoteDetail.css';
|
import './RemoteDetail.css';
|
||||||
|
|
||||||
export function LocalDetail() {
|
export function LocalDetail() {
|
||||||
@@ -36,6 +37,8 @@ export function LocalDetail() {
|
|||||||
<p className="detail-description">{remote.description}</p>
|
<p className="detail-description">{remote.description}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<UsageInstructions packageType={remote.package_type} repoClass="local" name={remote.name} />
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
|
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
|
||||||
Browse Files
|
Browse Files
|
||||||
|
|||||||
@@ -37,6 +37,15 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tree-file-link {
|
||||||
|
color: var(--accent, #4c9aff);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-file-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-dir {
|
.tree-dir {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, useLocation, Link } from 'react-router-dom';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Artifact } from '../api/types';
|
import type { Artifact } from '../api/types';
|
||||||
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
||||||
|
import { downloadUrlFor } from '../components/downloads';
|
||||||
import './Objects.css';
|
import './Objects.css';
|
||||||
|
|
||||||
interface TreeNode {
|
interface TreeNode {
|
||||||
@@ -100,11 +101,16 @@ interface TreeRowProps {
|
|||||||
expanded: Set<string>;
|
expanded: Set<string>;
|
||||||
onToggle: (path: string) => void;
|
onToggle: (path: string) => void;
|
||||||
onEvict: (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 isDir = node.children.size > 0 && !node.artifact;
|
||||||
const isExpanded = expanded.has(node.path);
|
const isExpanded = expanded.has(node.path);
|
||||||
|
const downloadUrl = node.artifact
|
||||||
|
? downloadUrlFor(packageType, repo, node.artifact.path)
|
||||||
|
: null;
|
||||||
|
|
||||||
const sortedChildren = useMemo(() => {
|
const sortedChildren = useMemo(() => {
|
||||||
if (!isDir) return [];
|
if (!isDir) return [];
|
||||||
@@ -124,9 +130,20 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
|
|||||||
{isDir && (
|
{isDir && (
|
||||||
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
|
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
|
||||||
)}
|
)}
|
||||||
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
|
{downloadUrl ? (
|
||||||
{node.name}{isDir ? '/' : ''}
|
<a
|
||||||
</span>
|
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>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="num-cell">{formatBytes(node.totalSize)}</td>
|
<td className="num-cell">{formatBytes(node.totalSize)}</td>
|
||||||
@@ -163,6 +180,8 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) {
|
|||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
onEvict={onEvict}
|
onEvict={onEvict}
|
||||||
|
repo={repo}
|
||||||
|
packageType={packageType}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -175,6 +194,7 @@ export function Objects() {
|
|||||||
const isLocal = location.pathname.startsWith('/locals/');
|
const isLocal = location.pathname.startsWith('/locals/');
|
||||||
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
|
const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`;
|
||||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
|
const [packageType, setPackageType] = useState<string | undefined>(undefined);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
@@ -190,6 +210,15 @@ export function Objects() {
|
|||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
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) => {
|
const handleEvict = async (path: string) => {
|
||||||
if (!name || !confirm(`Evict ${path}?`)) return;
|
if (!name || !confirm(`Evict ${path}?`)) return;
|
||||||
await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path));
|
await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path));
|
||||||
@@ -287,6 +316,8 @@ export function Objects() {
|
|||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={toggleExpand}
|
onToggle={toggleExpand}
|
||||||
onEvict={handleEvict}
|
onEvict={handleEvict}
|
||||||
|
repo={name!}
|
||||||
|
packageType={packageType}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom';
|
|||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Remote } from '../api/types';
|
import type { Remote } from '../api/types';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
|
import { UsageInstructions } from '../components/UsageInstructions';
|
||||||
import './RemoteDetail.css';
|
import './RemoteDetail.css';
|
||||||
|
|
||||||
export function RemoteDetail() {
|
export function RemoteDetail() {
|
||||||
@@ -109,6 +110,8 @@ export function RemoteDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UsageInstructions packageType={remote.package_type} repoClass="remote" name={remote.name} />
|
||||||
|
|
||||||
<div className="detail-actions">
|
<div className="detail-actions">
|
||||||
<Link to={`/remotes/${remote.name}/objects`} className="btn btn-primary">
|
<Link to={`/remotes/${remote.name}/objects`} className="btn btn-primary">
|
||||||
Browse Objects
|
Browse Objects
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api } from '../api/client';
|
|||||||
import type { Remote, Virtual } from '../api/types';
|
import type { Remote, Virtual } from '../api/types';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { DataTable } from '../components/DataTable';
|
import { DataTable } from '../components/DataTable';
|
||||||
|
import { UsageInstructions } from '../components/UsageInstructions';
|
||||||
import './Virtuals.css';
|
import './Virtuals.css';
|
||||||
|
|
||||||
export function Virtuals() {
|
export function Virtuals() {
|
||||||
@@ -98,6 +99,12 @@ export function Virtuals() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user