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:
@@ -17,6 +17,7 @@ type registryRequest struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// listRegistries handles GET /api/registries.
|
// listRegistries handles GET /api/registries.
|
||||||
@@ -34,6 +35,7 @@ func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
HasToken bool `json:"has_token"`
|
HasToken bool `json:"has_token"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
@@ -46,6 +48,7 @@ func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) {
|
|||||||
URL: reg.URL,
|
URL: reg.URL,
|
||||||
Type: reg.Type,
|
Type: reg.Type,
|
||||||
HasToken: reg.Token != "",
|
HasToken: reg.Token != "",
|
||||||
|
Owner: reg.Owner,
|
||||||
CreatedAt: reg.CreatedAt,
|
CreatedAt: reg.CreatedAt,
|
||||||
UpdatedAt: reg.UpdatedAt,
|
UpdatedAt: reg.UpdatedAt,
|
||||||
}
|
}
|
||||||
@@ -84,6 +87,7 @@ func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) {
|
|||||||
URL: req.URL,
|
URL: req.URL,
|
||||||
Type: req.Type,
|
Type: req.Type,
|
||||||
Token: encToken,
|
Token: encToken,
|
||||||
|
Owner: req.Owner,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error())
|
respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error())
|
||||||
@@ -125,6 +129,8 @@ func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Type != "" {
|
if req.Type != "" {
|
||||||
updated.Type = req.Type
|
updated.Type = req.Type
|
||||||
}
|
}
|
||||||
|
// Owner can be set to empty string intentionally, so always update it.
|
||||||
|
updated.Owner = req.Owner
|
||||||
|
|
||||||
// Only re-encrypt if a new token is provided.
|
// Only re-encrypt if a new token is provided.
|
||||||
if req.Token != "" {
|
if req.Token != "" {
|
||||||
@@ -265,3 +271,49 @@ func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
respondJSON(w, http.StatusOK, tags)
|
respondJSON(w, http.StatusOK, tags)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listRegistryImages handles GET /api/registries/{id}/images.
|
||||||
|
// Returns all container images available in the registry for the configured owner.
|
||||||
|
func (s *Server) listRegistryImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
|
||||||
|
reg, err := s.store.GetRegistryByID(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
respondNotFound(w, "registry")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Owner == "" {
|
||||||
|
respondError(w, http.StatusBadRequest, "registry has no owner configured; set the owner in registry settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt the token.
|
||||||
|
token := reg.Token
|
||||||
|
if token != "" {
|
||||||
|
decrypted, err := crypto.Decrypt(s.encKey, token)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to decrypt registry token")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token = decrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := registry.NewClient(reg.Type, reg.URL, token)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := client.ListImages(r.Context(), reg.Owner)
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusBadGateway, "failed to list images: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
respondJSON(w, http.StatusOK, images)
|
||||||
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Delete("/", s.deleteRegistry)
|
r.Delete("/", s.deleteRegistry)
|
||||||
r.Post("/test", s.testRegistry)
|
r.Post("/test", s.testRegistry)
|
||||||
r.Get("/tags/*", s.listRegistryTags)
|
r.Get("/tags/*", s.listRegistryTags)
|
||||||
|
r.Get("/images", s.listRegistryImages)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Settings endpoints.
|
// Settings endpoints.
|
||||||
|
|||||||
@@ -41,6 +41,80 @@ func NewGiteaClient(baseURL, token string) *GiteaClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages returns all container images (packages) for the given owner.
|
||||||
|
// It queries GET /api/v1/packages/{owner}?type=container and paginates
|
||||||
|
// through all results, returning a RegistryImage for each unique package.
|
||||||
|
func (c *GiteaClient) ListImages(ctx context.Context, owner string) ([]RegistryImage, error) {
|
||||||
|
if owner == "" {
|
||||||
|
return nil, fmt.Errorf("owner is required for listing images")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the registry host from baseURL to build full references.
|
||||||
|
host := c.baseURL
|
||||||
|
for _, prefix := range []string{"https://", "http://"} {
|
||||||
|
host = strings.TrimPrefix(host, prefix)
|
||||||
|
}
|
||||||
|
host = strings.TrimRight(host, "/")
|
||||||
|
|
||||||
|
var images []RegistryImage
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
page := 1
|
||||||
|
limit := 50
|
||||||
|
|
||||||
|
for {
|
||||||
|
url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&page=%d&limit=%d",
|
||||||
|
c.baseURL, owner, page, limit)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.token != "" {
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("execute request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var packages []giteaPackageListEntry
|
||||||
|
if err := json.Unmarshal(body, &packages); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode package list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range packages {
|
||||||
|
if !seen[p.Name] {
|
||||||
|
seen[p.Name] = true
|
||||||
|
images = append(images, RegistryImage{
|
||||||
|
Name: p.Name,
|
||||||
|
Owner: owner,
|
||||||
|
FullRef: fmt.Sprintf("%s/%s/%s", host, owner, p.Name),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(packages) < limit {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
page++
|
||||||
|
}
|
||||||
|
|
||||||
|
return images, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListTags returns all available tags for the given container image.
|
// ListTags returns all available tags for the given container image.
|
||||||
// The image should be in the format "owner/package-name" or
|
// The image should be in the format "owner/package-name" or
|
||||||
// "registry-host/owner/package-name" (the registry host prefix is stripped).
|
// "registry-host/owner/package-name" (the registry host prefix is stripped).
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// RegistryImage represents a container image discovered from a registry.
|
||||||
|
type RegistryImage struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
FullRef string `json:"full_ref"` // e.g., "git.example.com/owner/my-app"
|
||||||
|
}
|
||||||
|
|
||||||
// Client defines the interface for interacting with a container image registry.
|
// Client defines the interface for interacting with a container image registry.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
// ListTags returns all available tags for the given image.
|
// ListTags returns all available tags for the given image.
|
||||||
@@ -16,6 +23,10 @@ type Client interface {
|
|||||||
// GetLatestTag returns the most recently created tag that matches the given
|
// GetLatestTag returns the most recently created tag that matches the given
|
||||||
// glob pattern. Returns an empty string and no error if no tags match.
|
// glob pattern. Returns an empty string and no error if no tags match.
|
||||||
GetLatestTag(ctx context.Context, image string, pattern string) (string, error)
|
GetLatestTag(ctx context.Context, image string, pattern string) (string, error)
|
||||||
|
|
||||||
|
// ListImages returns all container images available in the registry for the
|
||||||
|
// given owner. Returns an error if the registry does not support image listing.
|
||||||
|
ListImages(ctx context.Context, owner string) ([]RegistryImage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeployTriggerer is called by the poller when a new tag is detected for a
|
// DeployTriggerer is called by the poller when a new tag is detected for a
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func (s *Store) GetRegistryByName(name string) (Registry, error) {
|
|||||||
// GetAllRegistries returns every registry ordered by name.
|
// GetAllRegistries returns every registry ordered by name.
|
||||||
func (s *Store) GetAllRegistries() ([]Registry, error) {
|
func (s *Store) GetAllRegistries() ([]Registry, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
`SELECT id, name, url, type, token, created_at, updated_at
|
`SELECT id, name, url, type, token, owner, created_at, updated_at
|
||||||
FROM registries ORDER BY name`,
|
FROM registries ORDER BY name`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,7 +71,7 @@ func (s *Store) GetAllRegistries() ([]Registry, error) {
|
|||||||
var registries []Registry
|
var registries []Registry
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r Registry
|
var r Registry
|
||||||
if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.Owner, &r.CreatedAt, &r.UpdatedAt); err != nil {
|
||||||
return nil, fmt.Errorf("scan registry: %w", err)
|
return nil, fmt.Errorf("scan registry: %w", err)
|
||||||
}
|
}
|
||||||
registries = append(registries, r)
|
registries = append(registries, r)
|
||||||
@@ -83,9 +83,9 @@ func (s *Store) GetAllRegistries() ([]Registry, error) {
|
|||||||
func (s *Store) UpdateRegistry(r Registry) error {
|
func (s *Store) UpdateRegistry(r Registry) error {
|
||||||
r.UpdatedAt = now()
|
r.UpdatedAt = now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE registries SET name=?, url=?, type=?, token=?, updated_at=?
|
`UPDATE registries SET name=?, url=?, type=?, token=?, owner=?, updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
r.Name, r.URL, r.Type, r.Token, r.UpdatedAt, r.ID,
|
r.Name, r.URL, r.Type, r.Token, r.Owner, r.UpdatedAt, r.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update registry: %w", err)
|
return fmt.Errorf("update registry: %w", err)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
Project,
|
Project,
|
||||||
ProjectDetail,
|
ProjectDetail,
|
||||||
Registry,
|
Registry,
|
||||||
|
RegistryImage,
|
||||||
Settings,
|
Settings,
|
||||||
StageEnv,
|
StageEnv,
|
||||||
Volume
|
Volume
|
||||||
@@ -220,6 +221,10 @@ export function listRegistryTags(registryId: string, image: string): Promise<str
|
|||||||
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
|
return get<string[]>(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listRegistryImages(registryId: string): Promise<RegistryImage[]> {
|
||||||
|
return get<RegistryImage[]>(`/api/registries/${registryId}/images`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Settings ────────────────────────────────────────────────────────
|
// ── Settings ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function getSettings(): Promise<Settings> {
|
export function getSettings(): Promise<Settings> {
|
||||||
|
|||||||
@@ -39,7 +39,12 @@
|
|||||||
"healthcheck": "Healthcheck Path",
|
"healthcheck": "Healthcheck Path",
|
||||||
"nameRequired": "Name and image are required.",
|
"nameRequired": "Name and image are required.",
|
||||||
"loadFailed": "Failed to load projects",
|
"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": {
|
"projectDetail": {
|
||||||
"deleteProject": "Delete Project",
|
"deleteProject": "Delete Project",
|
||||||
@@ -158,7 +163,12 @@
|
|||||||
"inspectedSuccess": "Image inspected successfully",
|
"inspectedSuccess": "Image inspected successfully",
|
||||||
"deployedSuccess": "Deployed {name} successfully!",
|
"deployedSuccess": "Deployed {name} successfully!",
|
||||||
"inspectFailed": "Failed to inspect image",
|
"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": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -214,6 +224,8 @@
|
|||||||
"token": "Token",
|
"token": "Token",
|
||||||
"tokenHelpNew": "API token for authentication",
|
"tokenHelpNew": "API token for authentication",
|
||||||
"tokenHelpEdit": "Leave empty to keep the existing token",
|
"tokenHelpEdit": "Leave empty to keep the existing token",
|
||||||
|
"owner": "Owner",
|
||||||
|
"ownerHelp": "Package owner (e.g., username or organization) for image listing",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"saving": "Saving...",
|
"saving": "Saving...",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
|
|||||||
@@ -39,7 +39,12 @@
|
|||||||
"healthcheck": "Путь проверки здоровья",
|
"healthcheck": "Путь проверки здоровья",
|
||||||
"nameRequired": "Название и образ обязательны.",
|
"nameRequired": "Название и образ обязательны.",
|
||||||
"loadFailed": "Не удалось загрузить проекты",
|
"loadFailed": "Не удалось загрузить проекты",
|
||||||
"createFailed": "Не удалось создать проект"
|
"createFailed": "Не удалось создать проект",
|
||||||
|
"browseImages": "Обзор образов",
|
||||||
|
"selectImage": "Выберите образ",
|
||||||
|
"noImages": "Образы не найдены",
|
||||||
|
"loadingImages": "Загрузка образов...",
|
||||||
|
"imageLoadFailed": "Не удалось загрузить образы"
|
||||||
},
|
},
|
||||||
"projectDetail": {
|
"projectDetail": {
|
||||||
"deleteProject": "Удалить проект",
|
"deleteProject": "Удалить проект",
|
||||||
@@ -158,7 +163,12 @@
|
|||||||
"inspectedSuccess": "Образ успешно проверен",
|
"inspectedSuccess": "Образ успешно проверен",
|
||||||
"deployedSuccess": "{name} успешно развёрнут!",
|
"deployedSuccess": "{name} успешно развёрнут!",
|
||||||
"inspectFailed": "Не удалось проверить образ",
|
"inspectFailed": "Не удалось проверить образ",
|
||||||
"deployFailed": "Развёртывание не удалось"
|
"deployFailed": "Развёртывание не удалось",
|
||||||
|
"browseImages": "Обзор",
|
||||||
|
"selectImage": "Выберите образ из реестра",
|
||||||
|
"noImages": "Образы не найдены",
|
||||||
|
"loadingImages": "Загрузка...",
|
||||||
|
"imageLoadFailed": "Не удалось загрузить образы"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
@@ -214,6 +224,8 @@
|
|||||||
"token": "Токен",
|
"token": "Токен",
|
||||||
"tokenHelpNew": "API-токен для аутентификации",
|
"tokenHelpNew": "API-токен для аутентификации",
|
||||||
"tokenHelpEdit": "Оставьте пустым, чтобы сохранить текущий токен",
|
"tokenHelpEdit": "Оставьте пустым, чтобы сохранить текущий токен",
|
||||||
|
"owner": "Владелец",
|
||||||
|
"ownerHelp": "Владелец пакетов (имя пользователя или организации) для списка образов",
|
||||||
"save": "Сохранить",
|
"save": "Сохранить",
|
||||||
"saving": "Сохранение...",
|
"saving": "Сохранение...",
|
||||||
"update": "Обновить",
|
"update": "Обновить",
|
||||||
|
|||||||
@@ -79,10 +79,19 @@ export interface Registry {
|
|||||||
url: string;
|
url: string;
|
||||||
type: string;
|
type: string;
|
||||||
token: string;
|
token: string;
|
||||||
|
owner: string;
|
||||||
|
has_token?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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 {
|
export interface Settings {
|
||||||
domain: string;
|
domain: string;
|
||||||
server_ip: string;
|
server_ip: string;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { inspectImage, quickDeploy } from '$lib/api';
|
import { inspectImage, quickDeploy, listRegistries, listRegistryImages } from '$lib/api';
|
||||||
import type { InspectResult } from '$lib/types';
|
import type { InspectResult, Registry, RegistryImage } from '$lib/types';
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
import FormField from '$lib/components/FormField.svelte';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -21,6 +21,44 @@
|
|||||||
|
|
||||||
let errors = $state<Record<string, string>>({});
|
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 {
|
function validateImageUrl(url: string): string {
|
||||||
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
|
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
|
||||||
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
||||||
@@ -137,7 +175,15 @@
|
|||||||
disabled={inspecting}
|
disabled={inspecting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
onclick={handleInspect}
|
onclick={handleInspect}
|
||||||
disabled={inspecting || !imageUrl.trim()}
|
disabled={inspecting || !imageUrl.trim()}
|
||||||
@@ -153,6 +199,34 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Step 2 -->
|
<!-- Step 2 -->
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Project } from '$lib/types';
|
import type { Project, Registry, RegistryImage } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { t } from '$lib/i18n';
|
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 FormField from '$lib/components/FormField.svelte';
|
||||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
@@ -20,6 +20,48 @@
|
|||||||
let formSubmitting = $state(false);
|
let formSubmitting = $state(false);
|
||||||
let formError = $state('');
|
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() {
|
async function loadProjects() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -97,7 +139,50 @@
|
|||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<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.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.registry')} name="registry" bind:value={formRegistry} placeholder="gitea" />
|
||||||
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} />
|
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} />
|
||||||
<div class="sm:col-span-2">
|
<div class="sm:col-span-2">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
let formUrl = $state('');
|
let formUrl = $state('');
|
||||||
let formType = $state('gitea');
|
let formType = $state('gitea');
|
||||||
let formToken = $state('');
|
let formToken = $state('');
|
||||||
|
let formOwner = $state('');
|
||||||
let formSaving = $state(false);
|
let formSaving = $state(false);
|
||||||
let testingId = $state<string | null>(null);
|
let testingId = $state<string | null>(null);
|
||||||
|
|
||||||
@@ -31,8 +32,8 @@
|
|||||||
return Object.keys(newErrors).length === 0;
|
return Object.keys(newErrors).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() { showForm = false; editingId = null; formName = ''; formUrl = ''; formType = 'gitea'; formToken = ''; 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 = ''; showForm = true; 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() {
|
async function loadRegistryList() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -43,7 +44,7 @@
|
|||||||
if (!validateForm()) return;
|
if (!validateForm()) return;
|
||||||
formSaving = true;
|
formSaving = true;
|
||||||
try {
|
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 (formToken.trim()) payload.token = formToken.trim();
|
||||||
if (editingId) { await updateRegistry(editingId, payload); toasts.success($t('settingsRegistries.registryUpdated')); }
|
if (editingId) { await updateRegistry(editingId, payload); toasts.success($t('settingsRegistries.registryUpdated')); }
|
||||||
else { await createRegistry(payload); toasts.success($t('settingsRegistries.registryAdded')); }
|
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>
|
<p class="text-xs text-[var(--text-tertiary)]">{$t('settingsRegistries.typeHelp')}</p>
|
||||||
</div>
|
</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.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>
|
||||||
<div class="mt-4 flex gap-3">
|
<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">
|
<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>
|
<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>
|
<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>
|
</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>
|
||||||
<div class="flex items-center gap-2">
|
<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">
|
<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">
|
||||||
|
|||||||
Reference in New Issue
Block a user