feat: auto-discover container images from registries

- Add ListImages() to registry interface, implement for Gitea
- Add owner field to registry config (needed for Gitea packages API)
- GET /api/registries/:id/images endpoint
- "Browse Images" button on Projects and Quick Deploy pages
- Image dropdown with registry grouping and search
- i18n support (EN/RU) for all new UI strings
This commit is contained in:
2026-03-28 14:04:11 +03:00
parent 77251c540b
commit 37e251da85
12 changed files with 355 additions and 18 deletions
+5
View File
@@ -7,6 +7,7 @@ import type {
Project,
ProjectDetail,
Registry,
RegistryImage,
Settings,
StageEnv,
Volume
@@ -220,6 +221,10 @@ export function listRegistryTags(registryId: string, image: string): Promise<str
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
}
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
return get<RegistryImage[]>(`/api/registries/${registryId}/images`);
}
// ── Settings ────────────────────────────────────────────────────────
export function getSettings(): Promise<Settings> {
+14 -2
View File
@@ -39,7 +39,12 @@
"healthcheck": "Healthcheck Path",
"nameRequired": "Name and image are required.",
"loadFailed": "Failed to load projects",
"createFailed": "Failed to create project"
"createFailed": "Failed to create project",
"browseImages": "Browse Images",
"selectImage": "Select an image",
"noImages": "No images found",
"loadingImages": "Loading images...",
"imageLoadFailed": "Failed to load images"
},
"projectDetail": {
"deleteProject": "Delete Project",
@@ -158,7 +163,12 @@
"inspectedSuccess": "Image inspected successfully",
"deployedSuccess": "Deployed {name} successfully!",
"inspectFailed": "Failed to inspect image",
"deployFailed": "Deployment failed"
"deployFailed": "Deployment failed",
"browseImages": "Browse",
"selectImage": "Select an image from a registry",
"noImages": "No images found",
"loadingImages": "Loading...",
"imageLoadFailed": "Failed to load images"
},
"settings": {
"title": "Settings",
@@ -214,6 +224,8 @@
"token": "Token",
"tokenHelpNew": "API token for authentication",
"tokenHelpEdit": "Leave empty to keep the existing token",
"owner": "Owner",
"ownerHelp": "Package owner (e.g., username or organization) for image listing",
"save": "Save",
"saving": "Saving...",
"update": "Update",
+14 -2
View File
@@ -39,7 +39,12 @@
"healthcheck": "Путь проверки здоровья",
"nameRequired": "Название и образ обязательны.",
"loadFailed": "Не удалось загрузить проекты",
"createFailed": "Не удалось создать проект"
"createFailed": "Не удалось создать проект",
"browseImages": "Обзор образов",
"selectImage": "Выберите образ",
"noImages": "Образы не найдены",
"loadingImages": "Загрузка образов...",
"imageLoadFailed": "Не удалось загрузить образы"
},
"projectDetail": {
"deleteProject": "Удалить проект",
@@ -158,7 +163,12 @@
"inspectedSuccess": "Образ успешно проверен",
"deployedSuccess": "{name} успешно развёрнут!",
"inspectFailed": "Не удалось проверить образ",
"deployFailed": "Развёртывание не удалось"
"deployFailed": "Развёртывание не удалось",
"browseImages": "Обзор",
"selectImage": "Выберите образ из реестра",
"noImages": "Образы не найдены",
"loadingImages": "Загрузка...",
"imageLoadFailed": "Не удалось загрузить образы"
},
"settings": {
"title": "Настройки",
@@ -214,6 +224,8 @@
"token": "Токен",
"tokenHelpNew": "API-токен для аутентификации",
"tokenHelpEdit": "Оставьте пустым, чтобы сохранить текущий токен",
"owner": "Владелец",
"ownerHelp": "Владелец пакетов (имя пользователя или организации) для списка образов",
"save": "Сохранить",
"saving": "Сохранение...",
"update": "Обновить",
+9
View File
@@ -79,10 +79,19 @@ export interface Registry {
url: string;
type: string;
token: string;
owner: string;
has_token?: boolean;
created_at: string;
updated_at: string;
}
/** A container image discovered from a registry. */
export interface RegistryImage {
name: string;
owner: string;
full_ref: string;
}
export interface Settings {
domain: string;
server_ip: string;
+77 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { inspectImage, quickDeploy } from '$lib/api';
import type { InspectResult } from '$lib/types';
import { inspectImage, quickDeploy, listRegistries, listRegistryImages } from '$lib/api';
import type { InspectResult, Registry, RegistryImage } from '$lib/types';
import FormField from '$lib/components/FormField.svelte';
import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n';
@@ -21,6 +21,44 @@
let errors = $state<Record<string, string>>({});
// Image browser state
let showImageBrowser = $state(false);
let browseImages = $state<(RegistryImage & { registryName: string })[]>([]);
let browseLoading = $state(false);
async function handleBrowseImages() {
showImageBrowser = !showImageBrowser;
if (!showImageBrowser) return;
browseLoading = true;
browseImages = [];
try {
const registries = await listRegistries();
const allImages: (RegistryImage & { registryName: string })[] = [];
for (const reg of registries) {
if (!reg.owner) continue;
try {
const images = await listRegistryImages(reg.id);
for (const img of images) {
allImages.push({ ...img, registryName: reg.name });
}
} catch {
// Skip registries that fail.
}
}
browseImages = allImages;
} catch {
toasts.error($t('quickDeploy.imageLoadFailed'));
} finally {
browseLoading = false;
}
}
function selectBrowsedImage(image: RegistryImage & { registryName: string }) {
imageUrl = image.full_ref + ':latest';
showImageBrowser = false;
}
function validateImageUrl(url: string): string {
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
@@ -137,7 +175,15 @@
disabled={inspecting}
/>
</div>
<div class="flex items-end">
<div class="flex items-end gap-2">
<button
type="button"
onclick={handleBrowseImages}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconSearch size={14} />
{$t('quickDeploy.browseImages')}
</button>
<button
onclick={handleInspect}
disabled={inspecting || !imageUrl.trim()}
@@ -153,6 +199,34 @@
</button>
</div>
</div>
{#if showImageBrowser}
<div class="mt-3 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3 shadow-[var(--shadow-md)] max-h-60 overflow-y-auto animate-scale-in">
{#if browseLoading}
<div class="flex items-center gap-2 py-2 text-sm text-[var(--text-secondary)]">
<IconLoader size={14} />
{$t('quickDeploy.loadingImages')}
</div>
{:else if browseImages.length === 0}
<p class="text-sm text-[var(--text-tertiary)]">{$t('quickDeploy.noImages')}</p>
{:else}
<p class="mb-2 text-xs font-medium text-[var(--text-tertiary)]">{$t('quickDeploy.selectImage')}</p>
<ul class="space-y-1">
{#each browseImages as image}
<li>
<button
type="button"
onclick={() => selectBrowsedImage(image)}
class="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
>
<span class="font-medium text-[var(--text-primary)]">{image.full_ref}</span>
<span class="ml-2 text-xs text-[var(--text-tertiary)]">({image.registryName})</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<!-- Step 2 -->
+88 -3
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import type { Project } from '$lib/types';
import type { Project, Registry, RegistryImage } from '$lib/types';
import * as api from '$lib/api';
import { t } from '$lib/i18n';
import { IconPlus } from '$lib/components/icons';
import { IconPlus, IconSearch, IconLoader } from '$lib/components/icons';
import FormField from '$lib/components/FormField.svelte';
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
@@ -20,6 +20,48 @@
let formSubmitting = $state(false);
let formError = $state('');
// Image browser state
let showImageBrowser = $state(false);
let registries = $state<Registry[]>([]);
let browseImages = $state<(RegistryImage & { registryName: string })[]>([]);
let browseLoading = $state(false);
let browseError = $state('');
async function handleBrowseImages() {
showImageBrowser = !showImageBrowser;
if (!showImageBrowser) return;
browseLoading = true;
browseError = '';
browseImages = [];
try {
registries = await api.listRegistries();
const allImages: (RegistryImage & { registryName: string })[] = [];
for (const reg of registries) {
if (!reg.owner) continue;
try {
const images = await api.listRegistryImages(reg.id);
for (const img of images) {
allImages.push({ ...img, registryName: reg.name });
}
} catch {
// Skip registries that fail (e.g., no owner configured).
}
}
browseImages = allImages;
} catch (e) {
browseError = e instanceof Error ? e.message : $t('projects.imageLoadFailed');
} finally {
browseLoading = false;
}
}
function selectBrowsedImage(image: RegistryImage & { registryName: string }) {
formImage = image.full_ref;
formRegistry = image.registryName;
showImageBrowser = false;
}
async function loadProjects() {
loading = true;
error = '';
@@ -97,7 +139,50 @@
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="{$t('projects.name')} *" name="name" bind:value={formName} placeholder="my-web-app" required />
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
<div class="flex flex-col gap-1.5">
<div class="flex items-end gap-2">
<div class="flex-1">
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
</div>
<button
type="button"
onclick={handleBrowseImages}
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
>
<IconSearch size={14} />
{$t('projects.browseImages')}
</button>
</div>
{#if showImageBrowser}
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3 shadow-[var(--shadow-md)] max-h-60 overflow-y-auto animate-scale-in">
{#if browseLoading}
<div class="flex items-center gap-2 py-2 text-sm text-[var(--text-secondary)]">
<IconLoader size={14} />
{$t('projects.loadingImages')}
</div>
{:else if browseError}
<p class="text-sm text-[var(--color-danger)]">{browseError}</p>
{:else if browseImages.length === 0}
<p class="text-sm text-[var(--text-tertiary)]">{$t('projects.noImages')}</p>
{:else}
<ul class="space-y-1">
{#each browseImages as image}
<li>
<button
type="button"
onclick={() => selectBrowsedImage(image)}
class="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
>
<span class="font-medium text-[var(--text-primary)]">{image.full_ref}</span>
<span class="ml-2 text-xs text-[var(--text-tertiary)]">({image.registryName})</span>
</button>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
<FormField label={$t('projects.registry')} name="registry" bind:value={formRegistry} placeholder="gitea" />
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} />
<div class="sm:col-span-2">
@@ -17,6 +17,7 @@
let formUrl = $state('');
let formType = $state('gitea');
let formToken = $state('');
let formOwner = $state('');
let formSaving = $state(false);
let testingId = $state<string | null>(null);
@@ -31,8 +32,8 @@
return Object.keys(newErrors).length === 0;
}
function resetForm() { showForm = false; editingId = null; formName = ''; formUrl = ''; formType = 'gitea'; formToken = ''; errors = {}; }
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; showForm = true; errors = {}; }
function resetForm() { showForm = false; editingId = null; formName = ''; formUrl = ''; formType = 'gitea'; formToken = ''; formOwner = ''; errors = {}; }
function startEdit(registry: Registry) { editingId = registry.id; formName = registry.name; formUrl = registry.url; formType = registry.type; formToken = ''; formOwner = registry.owner ?? ''; showForm = true; errors = {}; }
async function loadRegistryList() {
loading = true;
@@ -43,7 +44,7 @@
if (!validateForm()) return;
formSaving = true;
try {
const payload: Partial<Registry> = { name: formName.trim(), url: formUrl.trim(), type: formType };
const payload: Partial<Registry> = { name: formName.trim(), url: formUrl.trim(), type: formType, owner: formOwner.trim() };
if (formToken.trim()) payload.token = formToken.trim();
if (editingId) { await updateRegistry(editingId, payload); toasts.success($t('settingsRegistries.registryUpdated')); }
else { await createRegistry(payload); toasts.success($t('settingsRegistries.registryAdded')); }
@@ -105,6 +106,7 @@
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsRegistries.typeHelp')}</p>
</div>
<FormField label={$t('settingsRegistries.token')} name="registryToken" type="password" bind:value={formToken} placeholder={editingId ? '(leave empty to keep current)' : 'registry-access-token'} required={!editingId} error={errors.token ?? ''} helpText={editingId ? $t('settingsRegistries.tokenHelpEdit') : $t('settingsRegistries.tokenHelpNew')} />
<FormField label={$t('settingsRegistries.owner')} name="registryOwner" bind:value={formOwner} placeholder="alexei" helpText={$t('settingsRegistries.ownerHelp')} />
</div>
<div class="mt-4 flex gap-3">
<button onclick={handleSave} disabled={formSaving} class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press">
@@ -135,7 +137,7 @@
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{registry.name}</h3>
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">{registry.type}</span>
</div>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{registry.url}</p>
<p class="mt-1 text-sm text-[var(--text-secondary)]">{registry.url}{registry.owner ? `/${registry.owner}` : ''}</p>
</div>
<div class="flex items-center gap-2">
<button onclick={() => handleTestConnection(registry)} disabled={testingId === registry.id} class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] disabled:opacity-50 transition-colors">