From 8fb959f81f6ef0455b8da6b51426dbce1547d24a Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 31 Mar 2026 23:22:43 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20volume=20scopes=20redesign=20=E2=80=94?= =?UTF-8?q?=20replace=20shared/isolated=20with=206=20scopes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace confusing shared/isolated volume modes with explicit scopes: - instance: per-deploy isolated directory - stage: shared within a stage across deploys - project: shared across all stages - project_named: named group within a project - named: global named volume across projects - ephemeral: tmpfs in-memory mount Includes schema migration (shared→project, isolated→instance), backward-compatible deployer resolution, scope metadata API endpoint, and redesigned volume editor UI with scope guide cards and hints. --- internal/api/router.go | 3 + internal/api/volumes.go | 157 +++++++++++--- internal/deployer/bluegreen.go | 2 +- internal/deployer/deployer.go | 55 ++++- internal/store/models.go | 33 ++- internal/store/store.go | 9 + internal/store/volumes.go | 33 +-- web/src/lib/api.ts | 11 +- web/src/lib/i18n/en.json | 13 +- web/src/lib/i18n/ru.json | 13 +- web/src/lib/types.ts | 15 +- .../routes/projects/[id]/volumes/+page.svelte | 192 ++++++++++++++---- 12 files changed, 424 insertions(+), 112 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 0a431d6..cd478c4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -192,6 +192,9 @@ func (s *Server) Router() chi.Router { r.Get("/settings", s.getSettings) r.Get("/settings/npm-certificates", s.listNpmCertificates) + // Volume scope metadata (read-only). + r.Get("/volumes/scopes", s.listVolumeScopes) + // Stale container endpoints (read). r.Get("/containers/stale", s.listStaleContainers) diff --git a/internal/api/volumes.go b/internal/api/volumes.go index 94605ac..5df2644 100644 --- a/internal/api/volumes.go +++ b/internal/api/volumes.go @@ -2,6 +2,7 @@ package api import ( "errors" + "log/slog" "net/http" "path/filepath" "strings" @@ -21,26 +22,98 @@ func validateVolumePath(source string) bool { type volumeRequest struct { Source string `json:"source"` Target string `json:"target"` - Mode string `json:"mode"` + Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set + Scope string `json:"scope"` + Name string `json:"name"` +} + +// validScopes lists all accepted scope values. +var validScopes = map[string]bool{ + "instance": true, "stage": true, "project": true, + "project_named": true, "named": true, "ephemeral": true, +} + +// validateVolumeScope validates the scope and name combination. +func validateVolumeScope(scope, name string) string { + if !validScopes[scope] { + return "scope must be one of: instance, stage, project, project_named, named, ephemeral" + } + if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" { + return "name is required for " + scope + " scope" + } + return "" +} + +// scopeDescriptions returns metadata about each scope for the UI. +type scopeInfo struct { + Scope string `json:"scope"` + Description string `json:"description"` + NeedsName bool `json:"needs_name"` + PathExample string `json:"path_example"` +} + +// listVolumeScopes handles GET /api/volumes/scopes. +// Returns all available scopes with descriptions for UI hints. +func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) { + scopes := []scopeInfo{ + { + Scope: "instance", + Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.", + NeedsName: false, + PathExample: "{base}/{project}/{stage}-{tag}/{source}", + }, + { + Scope: "stage", + Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.", + NeedsName: false, + PathExample: "{base}/{project}/{stage}/{source}", + }, + { + Scope: "project", + Description: "Shared across all stages of the project. Good for common config or shared assets.", + NeedsName: false, + PathExample: "{base}/{project}/{source}", + }, + { + Scope: "project_named", + Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.", + NeedsName: true, + PathExample: "{base}/{project}/_named/{name}/{source}", + }, + { + Scope: "named", + Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.", + NeedsName: true, + PathExample: "{base}/_named/{name}/{source}", + }, + { + Scope: "ephemeral", + Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.", + NeedsName: false, + PathExample: "(tmpfs — no host path)", + }, + } + respondJSON(w, http.StatusOK, scopes) } // listVolumes handles GET /api/projects/{id}/volumes. func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - // Verify project exists. if _, err := s.store.GetProjectByID(projectID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } - respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + slog.Error("failed to get project", "project_id", projectID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get project") return } vols, err := s.store.GetVolumesByProjectID(projectID) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to list volumes: "+err.Error()) + slog.Error("failed to list volumes", "project_id", projectID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to list volumes") return } @@ -51,13 +124,13 @@ func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { projectID := chi.URLParam(r, "id") - // Verify project exists. if _, err := s.store.GetProjectByID(projectID); err != nil { if errors.Is(err, store.ErrNotFound) { respondNotFound(w, "project") return } - respondError(w, http.StatusInternalServerError, "failed to get project: "+err.Error()) + slog.Error("failed to get project", "project_id", projectID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get project") return } @@ -66,27 +139,41 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { return } - if req.Source == "" { - respondError(w, http.StatusBadRequest, "source is required") - return + // Ephemeral volumes don't need a source path. + if req.Scope != "ephemeral" { + if req.Source == "" { + respondError(w, http.StatusBadRequest, "source is required") + return + } + if !validateVolumePath(req.Source) { + respondError(w, http.StatusBadRequest, "source path must not contain '..'") + return + } } if req.Target == "" { respondError(w, http.StatusBadRequest, "target is required") return } - if !validateVolumePath(req.Source) { - respondError(w, http.StatusBadRequest, "source path must not contain '..'") - return - } if !validateVolumePath(req.Target) { respondError(w, http.StatusBadRequest, "target path must not contain '..'") return } - if req.Mode == "" { - req.Mode = "shared" + + // Resolve scope — support legacy mode field. + scope := req.Scope + if scope == "" { + switch req.Mode { + case "isolated": + scope = "instance" + case "shared": + scope = "project" + default: + scope = "project" + } } - if req.Mode != "shared" && req.Mode != "isolated" { - respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") + + if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" { + respondError(w, http.StatusBadRequest, errMsg) return } @@ -94,10 +181,12 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { ProjectID: projectID, Source: req.Source, Target: req.Target, - Mode: req.Mode, + Scope: scope, + Name: strings.TrimSpace(req.Name), }) if err != nil { - respondError(w, http.StatusInternalServerError, "failed to create volume: "+err.Error()) + slog.Error("failed to create volume", "project_id", projectID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to create volume") return } respondJSON(w, http.StatusCreated, vol) @@ -113,7 +202,8 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { respondNotFound(w, "volume") return } - respondError(w, http.StatusInternalServerError, "failed to get volume: "+err.Error()) + slog.Error("failed to get volume", "volume_id", volID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to get volume") return } @@ -124,21 +214,31 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { updated := existing if req.Source != "" { + if !validateVolumePath(req.Source) { + respondError(w, http.StatusBadRequest, "source path must not contain '..'") + return + } updated.Source = req.Source } if req.Target != "" { - updated.Target = req.Target - } - if req.Mode != "" { - if req.Mode != "shared" && req.Mode != "isolated" { - respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'") + if !validateVolumePath(req.Target) { + respondError(w, http.StatusBadRequest, "target path must not contain '..'") return } - updated.Mode = req.Mode + updated.Target = req.Target + } + if req.Scope != "" { + if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" { + respondError(w, http.StatusBadRequest, errMsg) + return + } + updated.Scope = req.Scope + updated.Name = strings.TrimSpace(req.Name) } if err := s.store.UpdateVolume(updated); err != nil { - respondError(w, http.StatusInternalServerError, "failed to update volume: "+err.Error()) + slog.Error("failed to update volume", "volume_id", volID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to update volume") return } respondJSON(w, http.StatusOK, updated) @@ -152,7 +252,8 @@ func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) { respondNotFound(w, "volume") return } - respondError(w, http.StatusInternalServerError, "failed to delete volume: "+err.Error()) + slog.Error("failed to delete volume", "volume_id", volID, "error", err) + respondError(w, http.StatusInternalServerError, "failed to delete volume") return } respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index fd7c593..66700f7 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -74,7 +74,7 @@ func (d *Deployer) blueGreenDeploy( containerName := docker.ContainerName(project.Name, stage.Name, imageTag) portStr := fmt.Sprintf("%d/tcp", project.Port) envVars := d.mergeEnvVars(project, stage.ID) - mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath) + mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath) containerCfg := docker.ContainerConfig{ Name: containerName, diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 4b0cda3..0d2cc71 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -282,7 +282,7 @@ func (d *Deployer) executeDeploy( containerName := docker.ContainerName(project.Name, stage.Name, imageTag) portStr := fmt.Sprintf("%d/tcp", project.Port) envVars := d.mergeEnvVars(project, stage.ID) - mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath) + mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath) containerCfg := docker.ContainerConfig{ Name: containerName, @@ -619,9 +619,14 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string } // computeVolumeMounts builds Docker mount specifications from the project's volume config. -// For shared mode, source is used as-is. -// For isolated mode, source gets /{stage}-{tag}/ appended. -func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath string) []mount.Mount { +// Resolves the host path based on the volume's scope: +// - instance: {base}/{project}/{stage}-{tag}/{source} +// - stage: {base}/{project}/{stage}/{source} +// - project: {base}/{project}/{source} +// - project_named: {base}/{project}/_named/{name}/{source} +// - named: {base}/_named/{name}/{source} +// - ephemeral: tmpfs mount (no host path) +func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount { vols, err := d.store.GetVolumesByProjectID(projectID) if err != nil { slog.Warn("get project volumes", "project_id", projectID, "error", err) @@ -634,14 +639,44 @@ func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath mounts := make([]mount.Mount, 0, len(vols)) for _, vol := range vols { - source := vol.Source - // Prepend base path if source is relative (doesn't start with /). - if basePath != "" && !filepath.IsAbs(source) { - source = filepath.Join(basePath, source) + // Resolve scope — use Scope field, fall back to Mode for backward compat. + scope := vol.Scope + if scope == "" { + switch vol.Mode { + case "isolated": + scope = "instance" + default: + scope = "project" + } } - if vol.Mode == "isolated" { - source = filepath.Join(source, fmt.Sprintf("%s-%s", stageName, imageTag)) + + // Ephemeral volumes use tmpfs — no host path. + if scope == "ephemeral" { + mounts = append(mounts, mount.Mount{ + Type: mount.TypeTmpfs, + Target: vol.Target, + }) + continue } + + // Build host path based on scope. + var source string + switch scope { + case "instance": + source = filepath.Join(basePath, projectName, fmt.Sprintf("%s-%s", stageName, imageTag), vol.Source) + case "stage": + source = filepath.Join(basePath, projectName, stageName, vol.Source) + case "project": + source = filepath.Join(basePath, projectName, vol.Source) + case "project_named": + source = filepath.Join(basePath, projectName, "_named", vol.Name, vol.Source) + case "named": + source = filepath.Join(basePath, "_named", vol.Name, vol.Source) + default: + // Fallback: treat as project scope. + source = filepath.Join(basePath, projectName, vol.Source) + } + mounts = append(mounts, mount.Mount{ Type: mount.TypeBind, Source: source, diff --git a/internal/store/models.go b/internal/store/models.go index cd4eedd..80b41a7 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -109,13 +109,44 @@ type StageEnv struct { UpdatedAt string `json:"updated_at"` } +// VolumeScope defines the sharing scope for a volume mount. +// Valid scopes: instance, stage, project, project_named, named, ephemeral. +type VolumeScope string + +const ( + VolumeScopeInstance VolumeScope = "instance" + VolumeScopeStage VolumeScope = "stage" + VolumeScopeProject VolumeScope = "project" + VolumeScopeProjectNamed VolumeScope = "project_named" + VolumeScopeNamed VolumeScope = "named" + VolumeScopeEphemeral VolumeScope = "ephemeral" +) + +// ValidVolumeScopes contains all valid scope values for validation. +var ValidVolumeScopes = []VolumeScope{ + VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject, + VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral, +} + +// IsValidVolumeScope returns true if the given string is a valid scope. +func IsValidVolumeScope(s string) bool { + for _, v := range ValidVolumeScopes { + if string(v) == s { + return true + } + } + return false +} + // Volume represents a volume mount configuration for a project. type Volume struct { ID string `json:"id"` ProjectID string `json:"project_id"` Source string `json:"source"` Target string `json:"target"` - Mode string `json:"mode"` // shared or isolated + Mode string `json:"mode,omitempty"` // legacy: shared/isolated — kept for DB compat + Scope string `json:"scope"` // instance, stage, project, project_named, named, ephemeral + Name string `json:"name"` // required for project_named and named scopes CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } diff --git a/internal/store/store.go b/internal/store/store.go index 0ea811f..ff524da 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -85,6 +85,9 @@ func (s *Store) runMigrations() error { `ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`, // Add last_alive_at to instances for stale container detection (2026-03-30). `ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`, + // Add name column and rename mode→scope for volume scopes redesign (2026-03-31). + `ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`, } for _, m := range migrations { @@ -112,6 +115,12 @@ func (s *Store) runMigrations() error { } } + // Data migration: copy mode→scope for volumes that have scope still empty. + // shared→project, isolated→instance. + _, _ = s.db.Exec(`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`) + _, _ = s.db.Exec(`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`) + _, _ = s.db.Exec(`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`) + return nil } diff --git a/internal/store/volumes.go b/internal/store/volumes.go index 2a2b754..be4534d 100644 --- a/internal/store/volumes.go +++ b/internal/store/volumes.go @@ -8,21 +8,30 @@ import ( "github.com/google/uuid" ) +// volumeColumns is the canonical column list for volume queries. +const volumeColumns = `id, project_id, source, target, mode, scope, name, created_at, updated_at` + // CreateVolume inserts a new volume configuration for a project. func (s *Store) CreateVolume(vol Volume) (Volume, error) { vol.ID = uuid.New().String() vol.CreatedAt = Now() vol.UpdatedAt = vol.CreatedAt - if vol.Mode == "" { - vol.Mode = "shared" + // Default scope for backward compatibility. + if vol.Scope == "" { + switch vol.Mode { + case "isolated": + vol.Scope = "instance" + default: + vol.Scope = "project" + } } _, err := s.db.Exec( - `INSERT INTO volumes (id, project_id, source, target, mode, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO volumes (`+volumeColumns+`) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode, - vol.CreatedAt, vol.UpdatedAt, + vol.Scope, vol.Name, vol.CreatedAt, vol.UpdatedAt, ) if err != nil { return Volume{}, fmt.Errorf("insert volume: %w", err) @@ -33,8 +42,7 @@ func (s *Store) CreateVolume(vol Volume) (Volume, error) { // GetVolumesByProjectID returns all volume configurations for a project. func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) { rows, err := s.db.Query( - `SELECT id, project_id, source, target, mode, created_at, updated_at - FROM volumes WHERE project_id = ? ORDER BY target`, projectID, + `SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID, ) if err != nil { return nil, fmt.Errorf("query volumes: %w", err) @@ -56,10 +64,9 @@ func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) { func (s *Store) GetVolumeByID(id string) (Volume, error) { var vol Volume err := s.db.QueryRow( - `SELECT id, project_id, source, target, mode, created_at, updated_at - FROM volumes WHERE id = ?`, id, + `SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id, ).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, - &vol.CreatedAt, &vol.UpdatedAt) + &vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt) if errors.Is(err, sql.ErrNoRows) { return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound) } @@ -73,9 +80,9 @@ func (s *Store) GetVolumeByID(id string) (Volume, error) { func (s *Store) UpdateVolume(vol Volume) error { vol.UpdatedAt = Now() result, err := s.db.Exec( - `UPDATE volumes SET source=?, target=?, mode=?, updated_at=? + `UPDATE volumes SET source=?, target=?, mode=?, scope=?, name=?, updated_at=? WHERE id=?`, - vol.Source, vol.Target, vol.Mode, vol.UpdatedAt, vol.ID, + vol.Source, vol.Target, vol.Mode, vol.Scope, vol.Name, vol.UpdatedAt, vol.ID, ) if err != nil { return fmt.Errorf("update volume: %w", err) @@ -104,7 +111,7 @@ func (s *Store) DeleteVolume(id string) error { func scanVolume(rows *sql.Rows) (Volume, error) { var vol Volume err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, - &vol.CreatedAt, &vol.UpdatedAt) + &vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt) if err != nil { return Volume{}, fmt.Errorf("scan volume: %w", err) } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 43be503..ef1b321 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -20,7 +20,8 @@ import type { StageEnv, StandaloneProxy, ValidationResult, - Volume + Volume, + VolumeScopeInfo } from './types'; // ── Helpers ───────────────────────────────────────────────────────── @@ -327,7 +328,7 @@ export function listVolumes(projectId: string): Promise { export function createVolume( projectId: string, - data: { source: string; target: string; mode?: string } + data: { source: string; target: string; scope: string; name?: string; mode?: string } ): Promise { return post(`/api/projects/${projectId}/volumes`, data); } @@ -335,11 +336,15 @@ export function createVolume( export function updateVolume( projectId: string, volId: string, - data: { source?: string; target?: string; mode?: string } + data: { source?: string; target?: string; scope?: string; name?: string; mode?: string } ): Promise { return put(`/api/projects/${projectId}/volumes/${volId}`, data); } +export function listVolumeScopes(): Promise { + return get('/api/volumes/scopes'); +} + export function deleteVolume( projectId: string, volId: string diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index fb861f5..efda799 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -120,15 +120,16 @@ }, "volumeEditor": { "title": "Volume Mounts", - "description": "Configure volume mounts for containers.", - "sharedDesc": "Shared mode uses the source path as-is for all instances.", - "isolatedDesc": "Isolated mode appends /{stage}-{tag}/ to the source, giving each instance its own directory.", + "description": "Configure volume mounts for containers. Choose a scope to control how volumes are shared between deploys.", "sourceHost": "Source (Host)", "targetContainer": "Target (Container)", - "mode": "Mode", + "scope": "Scope", + "nameColumn": "Name", + "namePlaceholder": "e.g. shared-db", + "requiresName": "requires name", + "noHostPath": "no host path", + "tmpfs": "tmpfs (in-memory)", "actions": "Actions", - "shared": "Shared", - "isolated": "Isolated", "edit": "Edit", "delete": "Delete", "save": "Save", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 4f129ee..4bb6005 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -120,15 +120,16 @@ }, "volumeEditor": { "title": "Тома", - "description": "Настройка монтирования томов для контейнеров.", - "sharedDesc": "Режим «Общий» использует путь источника как есть для всех экземпляров.", - "isolatedDesc": "Режим «Изолированный» добавляет /{stage}-{tag}/ к источнику, создавая свою директорию для каждого экземпляра.", + "description": "Настройка монтирования томов для контейнеров. Выберите область видимости для управления общим доступом между развёртываниями.", "sourceHost": "Источник (хост)", "targetContainer": "Цель (контейнер)", - "mode": "Режим", + "scope": "Область", + "nameColumn": "Имя", + "namePlaceholder": "напр. shared-db", + "requiresName": "требуется имя", + "noHostPath": "нет пути на хосте", + "tmpfs": "tmpfs (в памяти)", "actions": "Действия", - "shared": "Общий", - "isolated": "Изолированный", "edit": "Изменить", "delete": "Удалить", "save": "Сохранить", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 34b0dbb..ac7b78a 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -161,17 +161,30 @@ export interface EntityPickerItem { disabledHint?: string; } +/** Volume scope determines the sharing level. */ +export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral'; + /** Volume mount configuration for a project. */ export interface Volume { id: string; project_id: string; source: string; target: string; - mode: 'shared' | 'isolated'; + mode?: string; + scope: VolumeScope; + name: string; created_at: string; updated_at: string; } +/** Scope metadata returned by GET /api/volumes/scopes. */ +export interface VolumeScopeInfo { + scope: VolumeScope; + description: string; + needs_name: boolean; + path_example: string; +} + /** Docker daemon health check result. */ export interface DockerHealth { connected: boolean; diff --git a/web/src/routes/projects/[id]/volumes/+page.svelte b/web/src/routes/projects/[id]/volumes/+page.svelte index d52220d..add58fd 100644 --- a/web/src/routes/projects/[id]/volumes/+page.svelte +++ b/web/src/routes/projects/[id]/volumes/+page.svelte @@ -1,34 +1,67 @@ @@ -105,51 +154,79 @@

{$t('volumeEditor.title')}

-

- {$t('volumeEditor.description')} - {$t('volumeEditor.shared')} — {$t('volumeEditor.sharedDesc')} - {$t('volumeEditor.isolated')} — {$t('volumeEditor.isolatedDesc')} -

+

{$t('volumeEditor.description')}

- {#if loading} -
- + + {#if scopeInfos.length > 0} +
+ {#each scopeInfos as info} + {@const colors = scopeColor(info.scope)} +
+
+ {info.scope} + {#if info.needs_name} + ({$t('volumeEditor.requiresName')}) + {/if} +
+

{info.description}

+ {info.path_example} +
+ {/each}
+ {/if} + + {#if loading} + {:else if error}

{error}

-
{:else} +
- + + {#each volumes as vol (vol.id)} {#if editingId === vol.id} + + {:else} + - + + +
{$t('volumeEditor.sourceHost')} {$t('volumeEditor.targetContainer')}{$t('volumeEditor.mode')}{$t('volumeEditor.scope')}{$t('volumeEditor.nameColumn')} {$t('volumeEditor.actions')}
- + {#if editIsEphemeral} + {$t('volumeEditor.noHostPath')} + {:else} + + {/if} - + {#each scopeInfos as info} + + {/each} + {#if editNeedsName} + + {:else} + + {/if} +
@@ -158,15 +235,21 @@
{vol.source} + {#if vol.scope === 'ephemeral'} + {$t('volumeEditor.tmpfs')} + {:else} + {vol.source} + {/if} + {vol.target} - {#if vol.mode === 'shared'} - {$t('volumeEditor.shared')} - {:else} - {$t('volumeEditor.isolated')} - {/if} + {vol.scope} + + {vol.name || '—'}
@@ -181,22 +264,34 @@
- + {#if newIsEphemeral} + {$t('volumeEditor.noHostPath')} + {:else} + + {/if} - + {#each scopeInfos as info} + + {/each} + {#if newNeedsName} + + {:else} + + {/if} +
+ + {#if scopeDescription(newScope)} +
+

+ {newScope}: + {scopeDescription(newScope)} +

+ {scopePathExample(newScope)} +
+ {/if} + {#if volumes.length === 0}

{$t('volumeEditor.noVolumes')}

{/if}