From 097fbf0016b963a636b9ca01695c48ef6ee431ba Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Tue, 23 Jun 2026 23:20:18 +1000 Subject: [PATCH] feat: UI separates locals, remotes, and virtuals (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - New "Locals" sidebar nav item with list + detail + browse pages - Remotes page filters out local repos (repo_type=local hidden) - LocalDetail: simplified view — just name, type, description + "Browse Files" button - Virtuals: member links resolve to /locals/ or /remotes/ based on repo_type - Objects page detects context for correct back-navigation ## Test plan - [ ] Visual check: locals page shows only local repos - [ ] Remotes page hides local repos - [ ] Virtual member links point to correct pages - [ ] Browse files works from local detail page Reviewed-on: https://git.unkin.net/unkin/artifactapi/pulls/54 Co-authored-by: Ben Vincent Co-committed-by: Ben Vincent --- ui/src/App.tsx | 6 +++ ui/src/pages/LocalDetail.tsx | 46 ++++++++++++++++++ ui/src/pages/Locals.tsx | 93 ++++++++++++++++++++++++++++++++++++ ui/src/pages/Objects.tsx | 7 ++- ui/src/pages/Remotes.tsx | 5 +- ui/src/pages/Virtuals.tsx | 42 ++++++++++++---- 6 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 ui/src/pages/LocalDetail.tsx create mode 100644 ui/src/pages/Locals.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 422f3e4..655d30f 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -2,6 +2,8 @@ import { Routes, Route, NavLink } from 'react-router-dom'; import { Dashboard } from './pages/Dashboard'; import { Remotes } from './pages/Remotes'; import { RemoteDetail } from './pages/RemoteDetail'; +import { Locals } from './pages/Locals'; +import { LocalDetail } from './pages/LocalDetail'; import { Virtuals } from './pages/Virtuals'; import { Objects } from './pages/Objects'; import { Probe } from './pages/Probe'; @@ -18,6 +20,7 @@ export function App() {
Dashboard Remotes + Locals Virtuals Test Remote
@@ -31,6 +34,9 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> diff --git a/ui/src/pages/LocalDetail.tsx b/ui/src/pages/LocalDetail.tsx new file mode 100644 index 0000000..7ecc784 --- /dev/null +++ b/ui/src/pages/LocalDetail.tsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import { useParams, Link } from 'react-router-dom'; +import { api } from '../api/client'; +import type { Remote } from '../api/types'; +import { Badge } from '../components/Badge'; +import './RemoteDetail.css'; + +export function LocalDetail() { + const { name } = useParams<{ name: string }>(); + const [remote, setRemote] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!name) return; + api.getRemote(name) + .then(setRemote) + .catch(e => setError(e.message)); + }, [name]); + + if (error) return
{error}
; + if (!remote) return
Loading...
; + + return ( +
+
+ ← Locals +

{remote.name}

+
+ {remote.package_type} + local + {remote.managed_by && managed by {remote.managed_by}} +
+
+ + {remote.description && ( +

{remote.description}

+ )} + +
+ + Browse Files + +
+
+ ); +} diff --git a/ui/src/pages/Locals.tsx b/ui/src/pages/Locals.tsx new file mode 100644 index 0000000..2f5cff9 --- /dev/null +++ b/ui/src/pages/Locals.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { api } from '../api/client'; +import type { Remote } from '../api/types'; +import { Badge } from '../components/Badge'; +import { DataTable } from '../components/DataTable'; +import './Remotes.css'; + +const typeColors: Record = { + docker: 'blue', + helm: 'green', + rpm: 'yellow', + pypi: 'blue', + npm: 'red', + generic: 'default', + alpine: 'green', + puppet: 'yellow', + terraform: 'blue', + goproxy: 'green', +}; + +export function Locals() { + const navigate = useNavigate(); + const [remotes, setRemotes] = useState([]); + const [filter, setFilter] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.listRemotes() + .then(r => setRemotes((r || []).filter(x => x.repo_type === 'local'))) + .finally(() => setLoading(false)); + }, []); + + const filtered = remotes.filter(r => { + if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false; + return true; + }); + + return ( +
+

Local Repositories

+ +
+ setFilter(e.target.value)} + /> + {filtered.length} locals +
+ + {loading ? ( +
Loading...
+ ) : ( + {r.name}, + }, + { + key: 'type', + header: 'Type', + render: (r: Remote) => ( + + {r.package_type} + + ), + width: '110px', + }, + { + key: 'description', + header: 'Description', + render: (r: Remote) => r.description || , + }, + { + key: 'managed', + header: 'Managed', + render: (r: Remote) => + r.managed_by ? {r.managed_by} : , + width: '100px', + }, + ]} + data={filtered} + emptyMessage="No local repositories configured" + onRowClick={(r) => navigate(`/locals/${r.name}`)} + /> + )} +
+ ); +} diff --git a/ui/src/pages/Objects.tsx b/ui/src/pages/Objects.tsx index e9b4c96..f664f8b 100644 --- a/ui/src/pages/Objects.tsx +++ b/ui/src/pages/Objects.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; -import { useParams, Link } from 'react-router-dom'; +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'; @@ -171,6 +171,9 @@ function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) { export function Objects() { const { name } = useParams<{ name: string }>(); + const location = useLocation(); + const isLocal = location.pathname.startsWith('/locals/'); + const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`; const [artifacts, setArtifacts] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); @@ -233,7 +236,7 @@ export function Objects() { return (
- ← {name} + ← {name}

Cached Objects

diff --git a/ui/src/pages/Remotes.tsx b/ui/src/pages/Remotes.tsx index 77dcd4f..d80e8c9 100644 --- a/ui/src/pages/Remotes.tsx +++ b/ui/src/pages/Remotes.tsx @@ -32,9 +32,10 @@ export function Remotes() { .finally(() => setLoading(false)); }, []); - const types = [...new Set(remotes.map(r => r.package_type))].sort(); + const remoteOnly = remotes.filter(r => r.repo_type !== 'local'); + const types = [...new Set(remoteOnly.map(r => r.package_type))].sort(); - const filtered = remotes.filter(r => { + const filtered = remoteOnly.filter(r => { if (typeFilter && r.package_type !== typeFilter) return false; if (filter && !r.name.toLowerCase().includes(filter.toLowerCase())) return false; return true; diff --git a/ui/src/pages/Virtuals.tsx b/ui/src/pages/Virtuals.tsx index fa51f82..08e38ad 100644 --- a/ui/src/pages/Virtuals.tsx +++ b/ui/src/pages/Virtuals.tsx @@ -1,21 +1,38 @@ import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; import { api } from '../api/client'; -import type { Virtual } from '../api/types'; +import type { Remote, Virtual } from '../api/types'; import { Badge } from '../components/Badge'; import { DataTable } from '../components/DataTable'; import './Virtuals.css'; export function Virtuals() { const [virtuals, setVirtuals] = useState([]); + const [remoteMap, setRemoteMap] = useState>({}); const [loading, setLoading] = useState(true); const [expanded, setExpanded] = useState(null); useEffect(() => { - api.listVirtuals() - .then(v => setVirtuals(v || [])) + Promise.all([api.listVirtuals(), api.listRemotes()]) + .then(([v, r]) => { + setVirtuals(v || []); + const map: Record = {}; + for (const remote of r || []) { + map[remote.name] = remote; + } + setRemoteMap(map); + }) .finally(() => setLoading(false)); }, []); + function memberLink(name: string) { + const remote = remoteMap[name]; + if (remote?.repo_type === 'local') { + return `/locals/${name}`; + } + return `/remotes/${name}`; + } + return (

Virtual Repositories

@@ -40,7 +57,7 @@ export function Virtuals() { key: 'members', header: 'Members', render: (v: Virtual) => ( - {v.members?.length || 0} remotes + {v.members?.length || 0} repos ), width: '110px', }, @@ -69,12 +86,17 @@ export function Virtuals() {
    {virtuals .find(v => v.name === expanded) - ?.members?.map((m, i) => ( -
  • - {i + 1} - {m} -
  • - ))} + ?.members?.map((m, i) => { + const remote = remoteMap[m]; + const typeLabel = remote?.repo_type === 'local' ? 'local' : 'remote'; + return ( +
  • + {i + 1} + {m} + {typeLabel} +
  • + ); + })}
)}