Skip to content

Commit 1c7c5f5

Browse files
authored
Merge pull request #97 from nebari-dev/aktech/browse-registries
Add OCI registry browser
2 parents 07c216d + 1f6486d commit 1c7c5f5

File tree

17 files changed

+1076
-18
lines changed

17 files changed

+1076
-18
lines changed

docs/docs.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2013,6 +2013,9 @@ const docTemplate = `{
20132013
"url"
20142014
],
20152015
"properties": {
2016+
"api_token": {
2017+
"type": "string"
2018+
},
20162019
"default_repository": {
20172020
"type": "string"
20182021
},
@@ -2246,6 +2249,9 @@ const docTemplate = `{
22462249
"default_repository": {
22472250
"type": "string"
22482251
},
2252+
"has_api_token": {
2253+
"type": "boolean"
2254+
},
22492255
"id": {
22502256
"type": "string"
22512257
},
@@ -2304,6 +2310,9 @@ const docTemplate = `{
23042310
"handlers.UpdateRegistryRequest": {
23052311
"type": "object",
23062312
"properties": {
2313+
"api_token": {
2314+
"type": "string"
2315+
},
23072316
"default_repository": {
23082317
"type": "string"
23092318
},

docs/swagger.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2007,6 +2007,9 @@
20072007
"url"
20082008
],
20092009
"properties": {
2010+
"api_token": {
2011+
"type": "string"
2012+
},
20102013
"default_repository": {
20112014
"type": "string"
20122015
},
@@ -2240,6 +2243,9 @@
22402243
"default_repository": {
22412244
"type": "string"
22422245
},
2246+
"has_api_token": {
2247+
"type": "boolean"
2248+
},
22432249
"id": {
22442250
"type": "string"
22452251
},
@@ -2298,6 +2304,9 @@
22982304
"handlers.UpdateRegistryRequest": {
22992305
"type": "object",
23002306
"properties": {
2307+
"api_token": {
2308+
"type": "string"
2309+
},
23012310
"default_repository": {
23022311
"type": "string"
23032312
},

docs/swagger.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ definitions:
3333
type: object
3434
handlers.CreateRegistryRequest:
3535
properties:
36+
api_token:
37+
type: string
3638
default_repository:
3739
type: string
3840
is_default:
@@ -190,6 +192,8 @@ definitions:
190192
type: string
191193
default_repository:
192194
type: string
195+
has_api_token:
196+
type: boolean
193197
id:
194198
type: string
195199
is_default:
@@ -228,6 +232,8 @@ definitions:
228232
type: object
229233
handlers.UpdateRegistryRequest:
230234
properties:
235+
api_token:
236+
type: string
231237
default_repository:
232238
type: string
233239
is_default:

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Workspaces } from './pages/Workspaces';
99
import { WorkspaceDetail } from './pages/WorkspaceDetail';
1010
import { RemoteWorkspaceDetail } from './pages/RemoteWorkspaceDetail';
1111
import { Jobs } from './pages/Jobs';
12+
import { Registries } from './pages/Registries';
1213
import { Settings } from './pages/Settings';
1314
import { AdminDashboard } from './pages/admin/AdminDashboard';
1415
import { UserManagement } from './pages/admin/UserManagement';
@@ -96,6 +97,7 @@ function App() {
9697
<Route path="workspaces/:id" element={<WorkspaceDetail />} />
9798
<Route path="remote/workspaces/:id" element={<RemoteWorkspaceDetail />} />
9899
<Route path="jobs" element={<Jobs />} />
100+
<Route path="registries" element={<Registries />} />
99101
<Route path="settings" element={<Settings />} />
100102

101103
<Route element={<AdminRoute />}>

frontend/src/api/registries.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { apiClient } from './client';
2-
import type { OCIRegistry, CreateRegistryRequest, UpdateRegistryRequest, Publication, PublishRequest, Job } from '@/types';
2+
import type { OCIRegistry, CreateRegistryRequest, UpdateRegistryRequest, Publication, PublishRequest, Job, RegistryRepository, RegistryTag, ImportEnvironmentRequest, Workspace } from '@/types';
33

44
export const registriesApi = {
55
// Public endpoints (for all authenticated users)
@@ -43,4 +43,21 @@ export const registriesApi = {
4343
const { data } = await apiClient.get(`/workspaces/${workspaceId}/publications`);
4444
return data;
4545
},
46+
47+
// Browse endpoints (for all authenticated users)
48+
listRepositories: async (registryId: string, search?: string): Promise<{ repositories: RegistryRepository[]; fallback: boolean }> => {
49+
const params = search ? { search } : {};
50+
const { data } = await apiClient.get(`/registries/${registryId}/repositories`, { params });
51+
return data;
52+
},
53+
54+
listTags: async (registryId: string, repo: string): Promise<{ tags: RegistryTag[] }> => {
55+
const { data } = await apiClient.get(`/registries/${registryId}/tags`, { params: { repo } });
56+
return data;
57+
},
58+
59+
importEnvironment: async (registryId: string, req: ImportEnvironmentRequest): Promise<Workspace> => {
60+
const { data } = await apiClient.post(`/registries/${registryId}/import`, req);
61+
return data;
62+
},
4663
};

frontend/src/components/admin/CreateRegistryDialog.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export const CreateRegistryDialog = () => {
1111
const [url, setUrl] = useState('');
1212
const [username, setUsername] = useState('');
1313
const [password, setPassword] = useState('');
14+
const [apiToken, setApiToken] = useState('');
1415
const [defaultRepository, setDefaultRepository] = useState('');
1516
const [isDefault, setIsDefault] = useState(false);
1617
const [error, setError] = useState('');
@@ -27,6 +28,7 @@ export const CreateRegistryDialog = () => {
2728
url,
2829
username: username || undefined,
2930
password: password || undefined,
31+
api_token: apiToken || undefined,
3032
default_repository: defaultRepository || undefined,
3133
is_default: isDefault,
3234
});
@@ -35,6 +37,7 @@ export const CreateRegistryDialog = () => {
3537
setUrl('');
3638
setUsername('');
3739
setPassword('');
40+
setApiToken('');
3841
setDefaultRepository('');
3942
setIsDefault(false);
4043
setError('');
@@ -106,6 +109,22 @@ export const CreateRegistryDialog = () => {
106109
/>
107110
</div>
108111

112+
<div className="space-y-2">
113+
<label className="text-sm font-medium">
114+
API Token <span className="text-muted-foreground">(optional)</span>
115+
</label>
116+
<Input
117+
type="password"
118+
value={apiToken}
119+
onChange={(e) => setApiToken(e.target.value)}
120+
placeholder="Registry API token for browsing private repos"
121+
/>
122+
<p className="text-xs text-muted-foreground">
123+
For Quay.io: generate an OAuth Application Token to list private repositories.
124+
This is separate from the push/pull credentials above.
125+
</p>
126+
</div>
127+
109128
<div className="space-y-2">
110129
<label className="text-sm font-medium">
111130
Default Repository <span className="text-muted-foreground">(optional)</span>

frontend/src/components/admin/EditRegistryDialog.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export const EditRegistryDialog = ({ registry, open, onOpenChange }: EditRegistr
1717
const [url, setUrl] = useState('');
1818
const [username, setUsername] = useState('');
1919
const [password, setPassword] = useState('');
20+
const [apiToken, setApiToken] = useState('');
2021
const [defaultRepository, setDefaultRepository] = useState('');
2122
const [isDefault, setIsDefault] = useState(false);
2223
const [error, setError] = useState('');
@@ -30,6 +31,7 @@ export const EditRegistryDialog = ({ registry, open, onOpenChange }: EditRegistr
3031
setUrl(registry.url);
3132
setUsername(registry.username || '');
3233
setPassword(''); // Don't pre-fill password for security
34+
setApiToken(''); // Don't pre-fill token for security
3335
setDefaultRepository(registry.default_repository || '');
3436
setIsDefault(registry.is_default);
3537
}
@@ -47,6 +49,7 @@ export const EditRegistryDialog = ({ registry, open, onOpenChange }: EditRegistr
4749
url,
4850
username: username || undefined,
4951
password: password || undefined, // Only update if provided
52+
api_token: apiToken || undefined, // Only update if provided
5053
default_repository: defaultRepository || undefined,
5154
is_default: isDefault,
5255
},
@@ -115,6 +118,22 @@ export const EditRegistryDialog = ({ registry, open, onOpenChange }: EditRegistr
115118
/>
116119
</div>
117120

121+
<div className="space-y-2">
122+
<label className="text-sm font-medium">
123+
API Token <span className="text-muted-foreground">(leave blank to keep current)</span>
124+
</label>
125+
<Input
126+
type="password"
127+
value={apiToken}
128+
onChange={(e) => setApiToken(e.target.value)}
129+
placeholder="Leave blank to keep current token"
130+
/>
131+
<p className="text-xs text-muted-foreground">
132+
For Quay.io: an OAuth Application Token to list private repositories.
133+
{registry.has_api_token && ' A token is currently configured.'}
134+
</p>
135+
</div>
136+
118137
<div className="space-y-2">
119138
<label className="text-sm font-medium">
120139
Default Repository <span className="text-muted-foreground">(optional)</span>

frontend/src/components/layout/Layout.tsx

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,36 +52,47 @@ export const Layout = () => {
5252
</Button>
5353
)}
5454
</NavLink>
55-
{isAdmin && (
56-
<NavLink to="/admin">
55+
<NavLink to="/registries">
56+
{({ isActive }) => (
57+
<Button
58+
variant={isActive ? 'secondary' : 'ghost'}
59+
className="gap-2"
60+
>
61+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4"><path d="M11.5 20h-6.5a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v5.5" /><path d="M9 17h2" /><path d="M18 18m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M20.2 20.2l1.8 1.8" /></svg>
62+
Registries
63+
</Button>
64+
)}
65+
</NavLink>
66+
{isLocalMode && (
67+
<NavLink to="/settings">
5768
{({ isActive }) => (
5869
<Button
5970
variant={isActive ? 'secondary' : 'ghost'}
6071
className="gap-2"
6172
>
62-
<Shield className="h-4 w-4" />
63-
Admin
73+
<Settings className="h-4 w-4" />
74+
Settings
6475
</Button>
6576
)}
6677
</NavLink>
6778
)}
68-
{isLocalMode && (
69-
<NavLink to="/settings">
79+
</nav>
80+
</div>
81+
{!isLocalMode && (
82+
<div className="flex items-center gap-4">
83+
{isAdmin && (
84+
<NavLink to="/admin">
7085
{({ isActive }) => (
7186
<Button
7287
variant={isActive ? 'secondary' : 'ghost'}
7388
className="gap-2"
7489
>
75-
<Settings className="h-4 w-4" />
76-
Settings
90+
<Shield className="h-4 w-4" />
91+
Admin
7792
</Button>
7893
)}
7994
</NavLink>
8095
)}
81-
</nav>
82-
</div>
83-
{!isLocalMode && (
84-
<div className="flex items-center gap-4">
8596
{user?.avatar_url && !avatarError ? (
8697
<img
8798
src={user.avatar_url}

frontend/src/hooks/useRegistries.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
22
import { registriesApi } from '@/api/registries';
3-
import type { CreateRegistryRequest, UpdateRegistryRequest, PublishRequest } from '@/types';
3+
import type { CreateRegistryRequest, UpdateRegistryRequest, PublishRequest, ImportEnvironmentRequest } from '@/types';
44

55
// Query hook for public registries (all authenticated users)
66
export const usePublicRegistries = () => {
@@ -87,3 +87,35 @@ export const usePublications = (workspaceId: string) => {
8787
enabled: !!workspaceId,
8888
});
8989
};
90+
91+
// Query hook for registry repositories (browse)
92+
export const useRegistryRepositories = (registryId: string, search?: string) => {
93+
return useQuery({
94+
queryKey: ['registries', registryId, 'repositories', search],
95+
queryFn: () => registriesApi.listRepositories(registryId, search),
96+
enabled: !!registryId,
97+
});
98+
};
99+
100+
// Query hook for repository tags (browse)
101+
export const useRepositoryTags = (registryId: string, repo: string) => {
102+
return useQuery({
103+
queryKey: ['registries', registryId, 'tags', repo],
104+
queryFn: () => registriesApi.listTags(registryId, repo),
105+
enabled: !!registryId && !!repo,
106+
});
107+
};
108+
109+
// Mutation hook for importing an environment from a registry
110+
export const useImportEnvironment = () => {
111+
const queryClient = useQueryClient();
112+
113+
return useMutation({
114+
mutationFn: ({ registryId, data }: { registryId: string; data: ImportEnvironmentRequest }) =>
115+
registriesApi.importEnvironment(registryId, data),
116+
onSuccess: () => {
117+
queryClient.invalidateQueries({ queryKey: ['workspaces'] });
118+
queryClient.invalidateQueries({ queryKey: ['jobs'] });
119+
},
120+
});
121+
};

0 commit comments

Comments
 (0)