feat: volume scopes redesign — replace shared/isolated with 6 scopes

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.
This commit is contained in:
2026-03-31 23:22:43 +03:00
parent 1a8dfefa77
commit 8fb959f81f
12 changed files with 424 additions and 112 deletions
+3
View File
@@ -192,6 +192,9 @@ func (s *Server) Router() chi.Router {
r.Get("/settings", s.getSettings) r.Get("/settings", s.getSettings)
r.Get("/settings/npm-certificates", s.listNpmCertificates) r.Get("/settings/npm-certificates", s.listNpmCertificates)
// Volume scope metadata (read-only).
r.Get("/volumes/scopes", s.listVolumeScopes)
// Stale container endpoints (read). // Stale container endpoints (read).
r.Get("/containers/stale", s.listStaleContainers) r.Get("/containers/stale", s.listStaleContainers)
+129 -28
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"errors" "errors"
"log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -21,26 +22,98 @@ func validateVolumePath(source string) bool {
type volumeRequest struct { type volumeRequest struct {
Source string `json:"source"` Source string `json:"source"`
Target string `json:"target"` 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. // listVolumes handles GET /api/projects/{id}/volumes.
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) { func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id") projectID := chi.URLParam(r, "id")
// Verify project exists.
if _, err := s.store.GetProjectByID(projectID); err != nil { if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project") respondNotFound(w, "project")
return 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 return
} }
vols, err := s.store.GetVolumesByProjectID(projectID) vols, err := s.store.GetVolumesByProjectID(projectID)
if err != nil { 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 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) { func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
projectID := chi.URLParam(r, "id") projectID := chi.URLParam(r, "id")
// Verify project exists.
if _, err := s.store.GetProjectByID(projectID); err != nil { if _, err := s.store.GetProjectByID(projectID); err != nil {
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "project") respondNotFound(w, "project")
return 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 return
} }
@@ -66,27 +139,41 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
return return
} }
if req.Source == "" { // Ephemeral volumes don't need a source path.
respondError(w, http.StatusBadRequest, "source is required") if req.Scope != "ephemeral" {
return 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 == "" { if req.Target == "" {
respondError(w, http.StatusBadRequest, "target is required") respondError(w, http.StatusBadRequest, "target is required")
return return
} }
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
if !validateVolumePath(req.Target) { if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'") respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return 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 return
} }
@@ -94,10 +181,12 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
ProjectID: projectID, ProjectID: projectID,
Source: req.Source, Source: req.Source,
Target: req.Target, Target: req.Target,
Mode: req.Mode, Scope: scope,
Name: strings.TrimSpace(req.Name),
}) })
if err != nil { 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 return
} }
respondJSON(w, http.StatusCreated, vol) respondJSON(w, http.StatusCreated, vol)
@@ -113,7 +202,8 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "volume") respondNotFound(w, "volume")
return 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 return
} }
@@ -124,21 +214,31 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
updated := existing updated := existing
if req.Source != "" { if req.Source != "" {
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
updated.Source = req.Source updated.Source = req.Source
} }
if req.Target != "" { if req.Target != "" {
updated.Target = req.Target if !validateVolumePath(req.Target) {
} respondError(w, http.StatusBadRequest, "target path must not contain '..'")
if req.Mode != "" {
if req.Mode != "shared" && req.Mode != "isolated" {
respondError(w, http.StatusBadRequest, "mode must be 'shared' or 'isolated'")
return 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 { 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 return
} }
respondJSON(w, http.StatusOK, updated) respondJSON(w, http.StatusOK, updated)
@@ -152,7 +252,8 @@ func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
respondNotFound(w, "volume") respondNotFound(w, "volume")
return 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 return
} }
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID}) respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
+1 -1
View File
@@ -74,7 +74,7 @@ func (d *Deployer) blueGreenDeploy(
containerName := docker.ContainerName(project.Name, stage.Name, imageTag) containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port) portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID) 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{ containerCfg := docker.ContainerConfig{
Name: containerName, Name: containerName,
+45 -10
View File
@@ -282,7 +282,7 @@ func (d *Deployer) executeDeploy(
containerName := docker.ContainerName(project.Name, stage.Name, imageTag) containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port) portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID) 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{ containerCfg := docker.ContainerConfig{
Name: containerName, 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. // computeVolumeMounts builds Docker mount specifications from the project's volume config.
// For shared mode, source is used as-is. // Resolves the host path based on the volume's scope:
// For isolated mode, source gets /{stage}-{tag}/ appended. // - instance: {base}/{project}/{stage}-{tag}/{source}
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath string) []mount.Mount { // - 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) vols, err := d.store.GetVolumesByProjectID(projectID)
if err != nil { if err != nil {
slog.Warn("get project volumes", "project_id", projectID, "error", err) 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)) mounts := make([]mount.Mount, 0, len(vols))
for _, vol := range vols { for _, vol := range vols {
source := vol.Source // Resolve scope — use Scope field, fall back to Mode for backward compat.
// Prepend base path if source is relative (doesn't start with /). scope := vol.Scope
if basePath != "" && !filepath.IsAbs(source) { if scope == "" {
source = filepath.Join(basePath, source) 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{ mounts = append(mounts, mount.Mount{
Type: mount.TypeBind, Type: mount.TypeBind,
Source: source, Source: source,
+32 -1
View File
@@ -109,13 +109,44 @@ type StageEnv struct {
UpdatedAt string `json:"updated_at"` 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. // Volume represents a volume mount configuration for a project.
type Volume struct { type Volume struct {
ID string `json:"id"` ID string `json:"id"`
ProjectID string `json:"project_id"` ProjectID string `json:"project_id"`
Source string `json:"source"` Source string `json:"source"`
Target string `json:"target"` 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"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
+9
View File
@@ -85,6 +85,9 @@ func (s *Store) runMigrations() error {
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`, `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). // 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 ''`, `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 { 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 return nil
} }
+20 -13
View File
@@ -8,21 +8,30 @@ import (
"github.com/google/uuid" "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. // CreateVolume inserts a new volume configuration for a project.
func (s *Store) CreateVolume(vol Volume) (Volume, error) { func (s *Store) CreateVolume(vol Volume) (Volume, error) {
vol.ID = uuid.New().String() vol.ID = uuid.New().String()
vol.CreatedAt = Now() vol.CreatedAt = Now()
vol.UpdatedAt = vol.CreatedAt vol.UpdatedAt = vol.CreatedAt
if vol.Mode == "" { // Default scope for backward compatibility.
vol.Mode = "shared" if vol.Scope == "" {
switch vol.Mode {
case "isolated":
vol.Scope = "instance"
default:
vol.Scope = "project"
}
} }
_, err := s.db.Exec( _, err := s.db.Exec(
`INSERT INTO volumes (id, project_id, source, target, mode, created_at, updated_at) `INSERT INTO volumes (`+volumeColumns+`)
VALUES (?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode, 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 { if err != nil {
return Volume{}, fmt.Errorf("insert volume: %w", err) 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. // GetVolumesByProjectID returns all volume configurations for a project.
func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) { func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
rows, err := s.db.Query( rows, err := s.db.Query(
`SELECT id, project_id, source, target, mode, created_at, updated_at `SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("query volumes: %w", err) 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) { func (s *Store) GetVolumeByID(id string) (Volume, error) {
var vol Volume var vol Volume
err := s.db.QueryRow( err := s.db.QueryRow(
`SELECT id, project_id, source, target, mode, created_at, updated_at `SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id,
FROM volumes WHERE id = ?`, id,
).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, ).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) { if errors.Is(err, sql.ErrNoRows) {
return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound) 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 { func (s *Store) UpdateVolume(vol Volume) error {
vol.UpdatedAt = Now() vol.UpdatedAt = Now()
result, err := s.db.Exec( 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=?`, 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 { if err != nil {
return fmt.Errorf("update volume: %w", err) 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) { func scanVolume(rows *sql.Rows) (Volume, error) {
var vol Volume var vol Volume
err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode, 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 { if err != nil {
return Volume{}, fmt.Errorf("scan volume: %w", err) return Volume{}, fmt.Errorf("scan volume: %w", err)
} }
+8 -3
View File
@@ -20,7 +20,8 @@ import type {
StageEnv, StageEnv,
StandaloneProxy, StandaloneProxy,
ValidationResult, ValidationResult,
Volume Volume,
VolumeScopeInfo
} from './types'; } from './types';
// ── Helpers ───────────────────────────────────────────────────────── // ── Helpers ─────────────────────────────────────────────────────────
@@ -327,7 +328,7 @@ export function listVolumes(projectId: string): Promise<Volume[]> {
export function createVolume( export function createVolume(
projectId: string, projectId: string,
data: { source: string; target: string; mode?: string } data: { source: string; target: string; scope: string; name?: string; mode?: string }
): Promise<Volume> { ): Promise<Volume> {
return post<Volume>(`/api/projects/${projectId}/volumes`, data); return post<Volume>(`/api/projects/${projectId}/volumes`, data);
} }
@@ -335,11 +336,15 @@ export function createVolume(
export function updateVolume( export function updateVolume(
projectId: string, projectId: string,
volId: string, volId: string,
data: { source?: string; target?: string; mode?: string } data: { source?: string; target?: string; scope?: string; name?: string; mode?: string }
): Promise<Volume> { ): Promise<Volume> {
return put<Volume>(`/api/projects/${projectId}/volumes/${volId}`, data); return put<Volume>(`/api/projects/${projectId}/volumes/${volId}`, data);
} }
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
}
export function deleteVolume( export function deleteVolume(
projectId: string, projectId: string,
volId: string volId: string
+7 -6
View File
@@ -120,15 +120,16 @@
}, },
"volumeEditor": { "volumeEditor": {
"title": "Volume Mounts", "title": "Volume Mounts",
"description": "Configure volume mounts for containers.", "description": "Configure volume mounts for containers. Choose a scope to control how volumes are shared between deploys.",
"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.",
"sourceHost": "Source (Host)", "sourceHost": "Source (Host)",
"targetContainer": "Target (Container)", "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", "actions": "Actions",
"shared": "Shared",
"isolated": "Isolated",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
"save": "Save", "save": "Save",
+7 -6
View File
@@ -120,15 +120,16 @@
}, },
"volumeEditor": { "volumeEditor": {
"title": "Тома", "title": "Тома",
"description": "Настройка монтирования томов для контейнеров.", "description": "Настройка монтирования томов для контейнеров. Выберите область видимости для управления общим доступом между развёртываниями.",
"sharedDesc": "Режим «Общий» использует путь источника как есть для всех экземпляров.",
"isolatedDesc": "Режим «Изолированный» добавляет /{stage}-{tag}/ к источнику, создавая свою директорию для каждого экземпляра.",
"sourceHost": "Источник (хост)", "sourceHost": "Источник (хост)",
"targetContainer": "Цель (контейнер)", "targetContainer": "Цель (контейнер)",
"mode": "Режим", "scope": "Область",
"nameColumn": "Имя",
"namePlaceholder": "напр. shared-db",
"requiresName": "требуется имя",
"noHostPath": "нет пути на хосте",
"tmpfs": "tmpfs (в памяти)",
"actions": "Действия", "actions": "Действия",
"shared": "Общий",
"isolated": "Изолированный",
"edit": "Изменить", "edit": "Изменить",
"delete": "Удалить", "delete": "Удалить",
"save": "Сохранить", "save": "Сохранить",
+14 -1
View File
@@ -161,17 +161,30 @@ export interface EntityPickerItem {
disabledHint?: string; disabledHint?: string;
} }
/** Volume scope determines the sharing level. */
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral';
/** Volume mount configuration for a project. */ /** Volume mount configuration for a project. */
export interface Volume { export interface Volume {
id: string; id: string;
project_id: string; project_id: string;
source: string; source: string;
target: string; target: string;
mode: 'shared' | 'isolated'; mode?: string;
scope: VolumeScope;
name: string;
created_at: string; created_at: string;
updated_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. */ /** Docker daemon health check result. */
export interface DockerHealth { export interface DockerHealth {
connected: boolean; connected: boolean;
+149 -43
View File
@@ -1,34 +1,67 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/stores';
import type { Volume } from '$lib/types'; import type { Volume, VolumeScope, VolumeScopeInfo } from '$lib/types';
import * as api from '$lib/api'; import * as api from '$lib/api';
import { toasts } from '$lib/stores/toast'; import { toasts } from '$lib/stores/toast';
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLoader } from '$lib/components/icons'; import { IconChevronRight, IconPlus, IconEdit, IconTrash, IconCheck, IconX } from '$lib/components/icons';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
let volumes = $state<Volume[]>([]); let volumes = $state<Volume[]>([]);
let scopeInfos = $state<VolumeScopeInfo[]>([]);
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let newSource = $state(''); let newSource = $state('');
let newTarget = $state(''); let newTarget = $state('');
let newMode = $state<'shared' | 'isolated'>('shared'); let newScope = $state<VolumeScope>('project');
let newName = $state('');
let saving = $state(false); let saving = $state(false);
let editingId = $state(''); let editingId = $state('');
let editSource = $state(''); let editSource = $state('');
let editTarget = $state(''); let editTarget = $state('');
let editMode = $state<'shared' | 'isolated'>('shared'); let editScope = $state<VolumeScope>('project');
let editName = $state('');
const projectId = $derived($page.params.id); const projectId = $derived($page.params.id ?? '');
async function loadVolumes() { const newNeedsName = $derived(newScope === 'project_named' || newScope === 'named');
const editNeedsName = $derived(editScope === 'project_named' || editScope === 'named');
const newIsEphemeral = $derived(newScope === 'ephemeral');
const editIsEphemeral = $derived(editScope === 'ephemeral');
function scopeDescription(scope: VolumeScope): string {
return scopeInfos.find(s => s.scope === scope)?.description ?? '';
}
function scopePathExample(scope: VolumeScope): string {
return scopeInfos.find(s => s.scope === scope)?.path_example ?? '';
}
const scopeColors: Record<string, { bg: string; text: string }> = {
instance: { bg: 'bg-amber-50 dark:bg-amber-900/30', text: 'text-amber-700 dark:text-amber-400' },
stage: { bg: 'bg-blue-50 dark:bg-blue-900/30', text: 'text-blue-700 dark:text-blue-400' },
project: { bg: 'bg-emerald-50 dark:bg-emerald-900/30', text: 'text-emerald-700 dark:text-emerald-400' },
project_named: { bg: 'bg-violet-50 dark:bg-violet-900/30', text: 'text-violet-700 dark:text-violet-400' },
named: { bg: 'bg-purple-50 dark:bg-purple-900/30', text: 'text-purple-700 dark:text-purple-400' },
ephemeral: { bg: 'bg-gray-100 dark:bg-gray-800', text: 'text-gray-600 dark:text-gray-400' },
};
function scopeColor(scope: string) {
return scopeColors[scope] ?? scopeColors.project;
}
async function loadData() {
loading = true; loading = true;
error = ''; error = '';
try { try {
volumes = await api.listVolumes(projectId); const [vols, scopes] = await Promise.all([
api.listVolumes(projectId),
api.listVolumeScopes()
]);
volumes = vols;
scopeInfos = scopes;
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed'); error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
} finally { } finally {
@@ -37,15 +70,23 @@
} }
async function handleAdd() { async function handleAdd() {
if (!newSource.trim() || !newTarget.trim()) return; if (!newIsEphemeral && !newSource.trim()) return;
if (!newTarget.trim()) return;
if (newNeedsName && !newName.trim()) return;
saving = true; saving = true;
try { try {
await api.createVolume(projectId, { source: newSource.trim(), target: newTarget.trim(), mode: newMode }); await api.createVolume(projectId, {
source: newSource.trim(),
target: newTarget.trim(),
scope: newScope,
name: newNeedsName ? newName.trim() : undefined
});
newSource = ''; newSource = '';
newTarget = ''; newTarget = '';
newMode = 'shared'; newScope = 'project';
newName = '';
toasts.success($t('volumeEditor.volumeAdded')); toasts.success($t('volumeEditor.volumeAdded'));
await loadVolumes(); await loadData();
} catch (e) { } catch (e) {
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed')); toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
} finally { } finally {
@@ -57,19 +98,27 @@
editingId = vol.id; editingId = vol.id;
editSource = vol.source; editSource = vol.source;
editTarget = vol.target; editTarget = vol.target;
editMode = vol.mode; editScope = vol.scope || 'project';
editName = vol.name || '';
} }
function cancelEdit() { editingId = ''; } function cancelEdit() { editingId = ''; }
async function handleUpdate() { async function handleUpdate() {
if (!editSource.trim() || !editTarget.trim()) return; if (!editIsEphemeral && !editSource.trim()) return;
if (!editTarget.trim()) return;
if (editNeedsName && !editName.trim()) return;
saving = true; saving = true;
try { try {
await api.updateVolume(projectId, editingId, { source: editSource.trim(), target: editTarget.trim(), mode: editMode }); await api.updateVolume(projectId, editingId, {
source: editSource.trim(),
target: editTarget.trim(),
scope: editScope,
name: editNeedsName ? editName.trim() : ''
});
editingId = ''; editingId = '';
toasts.success($t('volumeEditor.volumeUpdated')); toasts.success($t('volumeEditor.volumeUpdated'));
await loadVolumes(); await loadData();
} catch (e) { } catch (e) {
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed')); toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
} finally { } finally {
@@ -81,7 +130,7 @@
try { try {
await api.deleteVolume(projectId, volId); await api.deleteVolume(projectId, volId);
toasts.success($t('volumeEditor.volumeDeleted')); toasts.success($t('volumeEditor.volumeDeleted'));
await loadVolumes(); await loadData();
} catch (e) { } catch (e) {
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed')); toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
} }
@@ -89,7 +138,7 @@
$effect(() => { $effect(() => {
void projectId; void projectId;
loadVolumes(); loadData();
}); });
</script> </script>
@@ -105,51 +154,79 @@
<IconChevronRight size={14} /> <IconChevronRight size={14} />
</div> </div>
<h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1> <h1 class="mt-1 text-2xl font-bold text-[var(--text-primary)]">{$t('volumeEditor.title')}</h1>
<p class="mt-1 text-sm text-[var(--text-secondary)]"> <p class="mt-1 text-sm text-[var(--text-secondary)]">{$t('volumeEditor.description')}</p>
{$t('volumeEditor.description')}
<strong>{$t('volumeEditor.shared')}</strong>{$t('volumeEditor.sharedDesc')}
<strong>{$t('volumeEditor.isolated')}</strong>{$t('volumeEditor.isolatedDesc')}
</p>
</div> </div>
{#if loading} <!-- Scope guide -->
<div class="space-y-4"> {#if scopeInfos.length > 0}
<Skeleton height="12rem" /> <div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
{#each scopeInfos as info}
{@const colors = scopeColor(info.scope)}
<div class="rounded-lg border border-[var(--border-primary)] p-3 {colors.bg}">
<div class="flex items-center gap-2">
<span class="rounded-full px-2 py-0.5 text-xs font-semibold {colors.text} {colors.bg}">{info.scope}</span>
{#if info.needs_name}
<span class="text-[10px] text-[var(--text-tertiary)]">({$t('volumeEditor.requiresName')})</span>
{/if}
</div>
<p class="mt-1.5 text-xs text-[var(--text-secondary)]">{info.description}</p>
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)]">{info.path_example}</code>
</div>
{/each}
</div> </div>
{/if}
{#if loading}
<Skeleton height="12rem" />
{:else if error} {:else if error}
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4"> <div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
<p class="text-sm text-[var(--color-danger)]">{error}</p> <p class="text-sm text-[var(--color-danger)]">{error}</p>
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadVolumes}> <button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadData}>
{$t('common.retry')} {$t('common.retry')}
</button> </button>
</div> </div>
{:else} {:else}
<!-- Volumes table -->
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]"> <div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
<table class="min-w-full divide-y divide-[var(--border-primary)]"> <table class="min-w-full divide-y divide-[var(--border-primary)]">
<thead class="bg-[var(--surface-card-hover)]"> <thead class="bg-[var(--surface-card-hover)]">
<tr> <tr>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.mode')}</th> <th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.scope')}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.nameColumn')}</th>
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th> <th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-[var(--border-secondary)]"> <tbody class="divide-y divide-[var(--border-secondary)]">
{#each volumes as vol (vol.id)} {#each volumes as vol (vol.id)}
{#if editingId === vol.id} {#if editingId === vol.id}
<!-- Edit row -->
<tr class="bg-[var(--color-brand-50)]/30"> <tr class="bg-[var(--color-brand-50)]/30">
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" /> {#if editIsEphemeral}
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.noHostPath')}</span>
{:else}
<input type="text" bind:value={editSource} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{/if}
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" /> <input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<select bind:value={editMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none"> <select bind:value={editScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="shared">{$t('volumeEditor.shared')}</option> {#each scopeInfos as info}
<option value="isolated">{$t('volumeEditor.isolated')}</option> <option value={info.scope}>{info.scope}</option>
{/each}
</select> </select>
</td> </td>
<td class="px-4 py-2.5">
{#if editNeedsName}
<input type="text" bind:value={editName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{:else}
<span class="text-xs text-[var(--text-tertiary)]"></span>
{/if}
</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button> <button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
@@ -158,15 +235,21 @@
</td> </td>
</tr> </tr>
{:else} {:else}
<!-- Display row -->
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors"> <tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{vol.source}</td> <td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
{#if vol.scope === 'ephemeral'}
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.tmpfs')}</span>
{:else}
{vol.source}
{/if}
</td>
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td> <td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
{#if vol.mode === 'shared'} <span class="rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope).bg} {scopeColor(vol.scope).text}">{vol.scope}</span>
<span class="rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700">{$t('volumeEditor.shared')}</span> </td>
{:else} <td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">
<span class="rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-700">{$t('volumeEditor.isolated')}</span> {vol.name || '—'}
{/if}
</td> </td>
<td class="whitespace-nowrap px-4 py-2.5 text-right"> <td class="whitespace-nowrap px-4 py-2.5 text-right">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
@@ -181,22 +264,34 @@
<!-- Add new row --> <!-- Add new row -->
<tr class="bg-[var(--surface-card-hover)]"> <tr class="bg-[var(--surface-card-hover)]">
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" /> {#if newIsEphemeral}
<span class="text-xs text-[var(--text-tertiary)] italic">{$t('volumeEditor.noHostPath')}</span>
{:else}
<input type="text" bind:value={newSource} placeholder="/data/my-app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{/if}
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" /> <input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
</td> </td>
<td class="px-4 py-2.5"> <td class="px-4 py-2.5">
<select bind:value={newMode} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none"> <select bind:value={newScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
<option value="shared">{$t('volumeEditor.shared')}</option> {#each scopeInfos as info}
<option value="isolated">{$t('volumeEditor.isolated')}</option> <option value={info.scope}>{info.scope}</option>
{/each}
</select> </select>
</td> </td>
<td class="px-4 py-2.5">
{#if newNeedsName}
<input type="text" bind:value={newName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
{:else}
<span class="text-xs text-[var(--text-tertiary)]"></span>
{/if}
</td>
<td class="px-4 py-2.5 text-right"> <td class="px-4 py-2.5 text-right">
<button <button
type="button" type="button"
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press" class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
disabled={!newSource.trim() || !newTarget.trim() || saving} disabled={(!newIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newNeedsName && !newName.trim()) || saving}
onclick={handleAdd} onclick={handleAdd}
> >
<IconPlus size={14} /> <IconPlus size={14} />
@@ -208,6 +303,17 @@
</table> </table>
</div> </div>
<!-- Scope hint for current selection -->
{#if scopeDescription(newScope)}
<div class="rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] p-3">
<p class="text-xs text-[var(--text-secondary)]">
<strong class="text-[var(--text-primary)]">{newScope}:</strong>
{scopeDescription(newScope)}
</p>
<code class="mt-1 block text-[10px] text-[var(--text-tertiary)]">{scopePathExample(newScope)}</code>
</div>
{/if}
{#if volumes.length === 0} {#if volumes.length === 0}
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p> <p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
{/if} {/if}