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:
@@ -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
@@ -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})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user