feat: UI separates locals, remotes, and virtuals
- New "Locals" nav item and pages (list + detail + browse files) - Remotes page filters out repo_type=local entries - LocalDetail shows simplified view (no upstream config fields) - Virtuals page resolves member links to /locals/ or /remotes/ based on repo_type, with a badge indicating the type - Objects page detects local/remote context for back navigation
This commit is contained in:
@@ -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() {
|
||||
<div className="sidebar-nav">
|
||||
<NavLink to="/" end>Dashboard</NavLink>
|
||||
<NavLink to="/remotes">Remotes</NavLink>
|
||||
<NavLink to="/locals">Locals</NavLink>
|
||||
<NavLink to="/virtuals">Virtuals</NavLink>
|
||||
<NavLink to="/probe">Test Remote</NavLink>
|
||||
</div>
|
||||
@@ -31,6 +34,9 @@ export function App() {
|
||||
<Route path="/remotes" element={<Remotes />} />
|
||||
<Route path="/remotes/:name" element={<RemoteDetail />} />
|
||||
<Route path="/remotes/:name/objects" element={<Objects />} />
|
||||
<Route path="/locals" element={<Locals />} />
|
||||
<Route path="/locals/:name" element={<LocalDetail />} />
|
||||
<Route path="/locals/:name/objects" element={<Objects />} />
|
||||
<Route path="/virtuals" element={<Virtuals />} />
|
||||
<Route path="/probe" element={<Probe />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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<Remote | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) return;
|
||||
api.getRemote(name)
|
||||
.then(setRemote)
|
||||
.catch(e => setError(e.message));
|
||||
}, [name]);
|
||||
|
||||
if (error) return <div className="error-banner">{error}</div>;
|
||||
if (!remote) return <div className="loading">Loading...</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="detail-header">
|
||||
<Link to="/locals" className="back-link">← Locals</Link>
|
||||
<h1 className="page-title">{remote.name}</h1>
|
||||
<div className="detail-badges">
|
||||
<Badge variant="blue">{remote.package_type}</Badge>
|
||||
<Badge variant="default">local</Badge>
|
||||
{remote.managed_by && <Badge variant="green">managed by {remote.managed_by}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{remote.description && (
|
||||
<p className="detail-description">{remote.description}</p>
|
||||
)}
|
||||
|
||||
<div className="detail-actions">
|
||||
<Link to={`/locals/${remote.name}/objects`} className="btn btn-primary">
|
||||
Browse Files
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, 'blue' | 'green' | 'yellow' | 'red' | 'default'> = {
|
||||
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<Remote[]>([]);
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="page-title">Local Repositories</h1>
|
||||
|
||||
<div className="remotes-toolbar">
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Filter by name..."
|
||||
value={filter}
|
||||
onChange={e => setFilter(e.target.value)}
|
||||
/>
|
||||
<span className="result-count">{filtered.length} locals</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="loading">Loading...</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (r: Remote) => <span className="mono">{r.name}</span>,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
render: (r: Remote) => (
|
||||
<Badge variant={typeColors[r.package_type] || 'default'}>
|
||||
{r.package_type}
|
||||
</Badge>
|
||||
),
|
||||
width: '110px',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: 'Description',
|
||||
render: (r: Remote) => r.description || <span className="text-muted">—</span>,
|
||||
},
|
||||
{
|
||||
key: 'managed',
|
||||
header: 'Managed',
|
||||
render: (r: Remote) =>
|
||||
r.managed_by ? <Badge variant="blue">{r.managed_by}</Badge> : <span className="text-muted">—</span>,
|
||||
width: '100px',
|
||||
},
|
||||
]}
|
||||
data={filtered}
|
||||
emptyMessage="No local repositories configured"
|
||||
onRowClick={(r) => navigate(`/locals/${r.name}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Artifact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
@@ -233,7 +236,7 @@ export function Objects() {
|
||||
return (
|
||||
<div>
|
||||
<div className="detail-header">
|
||||
<Link to={`/remotes/${name}`} className="back-link">← {name}</Link>
|
||||
<Link to={backLink} className="back-link">← {name}</Link>
|
||||
<h1 className="page-title">Cached Objects</h1>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+32
-10
@@ -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<Virtual[]>([]);
|
||||
const [remoteMap, setRemoteMap] = useState<Record<string, Remote>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.listVirtuals()
|
||||
.then(v => setVirtuals(v || []))
|
||||
Promise.all([api.listVirtuals(), api.listRemotes()])
|
||||
.then(([v, r]) => {
|
||||
setVirtuals(v || []);
|
||||
const map: Record<string, Remote> = {};
|
||||
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 (
|
||||
<div>
|
||||
<h1 className="page-title">Virtual Repositories</h1>
|
||||
@@ -40,7 +57,7 @@ export function Virtuals() {
|
||||
key: 'members',
|
||||
header: 'Members',
|
||||
render: (v: Virtual) => (
|
||||
<span className="member-count">{v.members?.length || 0} remotes</span>
|
||||
<span className="member-count">{v.members?.length || 0} repos</span>
|
||||
),
|
||||
width: '110px',
|
||||
},
|
||||
@@ -69,12 +86,17 @@ export function Virtuals() {
|
||||
<ul className="member-list">
|
||||
{virtuals
|
||||
.find(v => v.name === expanded)
|
||||
?.members?.map((m, i) => (
|
||||
<li key={m}>
|
||||
<span className="member-priority">{i + 1}</span>
|
||||
<a href={`/remotes/${m}`} className="mono">{m}</a>
|
||||
</li>
|
||||
))}
|
||||
?.members?.map((m, i) => {
|
||||
const remote = remoteMap[m];
|
||||
const typeLabel = remote?.repo_type === 'local' ? 'local' : 'remote';
|
||||
return (
|
||||
<li key={m}>
|
||||
<span className="member-priority">{i + 1}</span>
|
||||
<Link to={memberLink(m)} className="mono">{m}</Link>
|
||||
<Badge variant={typeLabel === 'local' ? 'yellow' : 'default'}>{typeLabel}</Badge>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user