Adds a new Stacks feature: upload/edit docker-compose YAML, deploy as atomic units, browse revisions, roll back, and stream logs. Backend in internal/stack + internal/api/stacks.go, persistent storage in internal/store/stacks.go. Stacks pages (list, new, detail) use a modern Forge aesthetic — Instrument Serif display type, JetBrains Mono for meta/code, indigo ember accents, dot-grid hero, registration marks on hover, terminal panel for logs. Palette is sourced from the app's existing design tokens so the feature remains consistent with the rest of Tinyforge. Fonts self-hosted via @fontsource/instrument-serif and @fontsource/jetbrains-mono to satisfy the strict CSP.
This commit is contained in:
@@ -2,18 +2,6 @@
|
||||
|
||||
Feature ideas for evolving the project from a Docker container watcher into a self-hosted mini CI/deployment platform for local developers.
|
||||
|
||||
## Name Candidates
|
||||
|
||||
| Name | Vibe | Domain feel |
|
||||
|---|---|---|
|
||||
| **Shipyard** | Where you build and launch ships (deployments). Nautical, memorable. | `shipyard.dev` |
|
||||
| **Dockside** | Nods to Docker heritage, but broader — "the place beside the dock." | `dockside.dev` |
|
||||
| **Launchpad** | CI/CD connotation, action-oriented. | `launchpad.run` |
|
||||
| **Portside** | Same nautical lane as Portainer, but fresh. | `portside.dev` |
|
||||
| **Homeport** | Self-hosted feel, "home" + "port" (Docker). | `homeport.dev` |
|
||||
| **Tinyforge** | Small but powerful — a forge for building/deploying. | `tinyforge.dev` |
|
||||
| **Deployr** | Blunt, says exactly what it does. | `deployr.dev` |
|
||||
| **Runwell** | "Run things well." Simple, positive. | `runwell.dev` |
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
@@ -29,8 +17,8 @@ Feature ideas for evolving the project from a Docker container watcher into a se
|
||||
|
||||
## Developer Experience
|
||||
|
||||
- **CLI tool** — `shipyard deploy`, `shipyard logs`, `shipyard status` from the terminal for developers who prefer the shell.
|
||||
- **`.shipyard.yml` project config** — a declarative file in the repo root that defines how to build, which env vars to inject, health check paths, proxy rules. One file, full deploy config.
|
||||
- **CLI tool** — `Tinyforge deploy`, `Tinyforge logs`, `Tinyforge status` from the terminal for developers who prefer the shell.
|
||||
- **`.Tinyforge.yml` project config** — a declarative file in the repo root that defines how to build, which env vars to inject, health check paths, proxy rules. One file, full deploy config.
|
||||
- **Environment promotion** — one-click promote from `dev` to `staging` to `prod`. Builds on the existing multi-stage project model by adding a promotion workflow.
|
||||
|
||||
## Observability
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/registry"
|
||||
"github.com/alexei/tinyforge/internal/stale"
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
"github.com/alexei/tinyforge/internal/webhook"
|
||||
@@ -288,9 +289,24 @@ func main() {
|
||||
slog.Warn("failed to start static site health checker", "error", err)
|
||||
}
|
||||
|
||||
// Initialize stack (docker-compose) manager. Disabled gracefully if
|
||||
// `docker compose` is not available on the host.
|
||||
stackWorkDir := filepath.Join(filepath.Dir(dbPath), "stacks")
|
||||
stackMgr, err := stack.NewManager(db, stack.NewCompose(""), eventBus, stackWorkDir)
|
||||
if err != nil {
|
||||
slog.Warn("failed to init stack manager", "error", err)
|
||||
stackMgr = nil
|
||||
} else if err := stackMgr.Available(context.Background()); err != nil {
|
||||
slog.Warn("docker compose not available — stacks feature disabled", "error", err)
|
||||
stackMgr = nil
|
||||
}
|
||||
|
||||
// Build API server.
|
||||
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, webhookHandler, eventBus, encKey)
|
||||
apiServer.SetStaticSiteManager(staticSiteMgr)
|
||||
if stackMgr != nil {
|
||||
apiServer.SetStackManager(stackMgr)
|
||||
}
|
||||
apiServer.SetStaleScanner(staleScanner)
|
||||
apiServer.SetBackupEngine(backupEngine)
|
||||
apiServer.SetDBPath(dbPath)
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/npm"
|
||||
"github.com/alexei/tinyforge/internal/proxy"
|
||||
"github.com/alexei/tinyforge/internal/stack"
|
||||
"github.com/alexei/tinyforge/internal/stale"
|
||||
"github.com/alexei/tinyforge/internal/staticsite"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
@@ -44,6 +45,7 @@ type Server struct {
|
||||
onDNSProviderChanged DNSProviderChangedFunc
|
||||
|
||||
staticSiteManager *staticsite.Manager
|
||||
stackManager *stack.Manager
|
||||
backupEngine *backup.Engine
|
||||
dbPath string
|
||||
shutdownFunc func() // called after restore to trigger graceful shutdown
|
||||
@@ -90,6 +92,11 @@ func (s *Server) SetStaticSiteManager(mgr *staticsite.Manager) {
|
||||
s.staticSiteManager = mgr
|
||||
}
|
||||
|
||||
// SetStackManager sets the docker-compose stack manager on the server.
|
||||
func (s *Server) SetStackManager(mgr *stack.Manager) {
|
||||
s.stackManager = mgr
|
||||
}
|
||||
|
||||
// SetStaleScanner sets the stale scanner on the server.
|
||||
// Called after both the API server and scanner are initialized.
|
||||
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
||||
@@ -250,6 +257,27 @@ func (s *Server) Router() chi.Router {
|
||||
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
|
||||
})
|
||||
})
|
||||
// Stacks (docker-compose).
|
||||
r.Get("/stacks", s.listStacks)
|
||||
r.Route("/stacks/{id}", func(r chi.Router) {
|
||||
r.Get("/", s.getStack)
|
||||
r.Get("/revisions", s.listStackRevisions)
|
||||
r.Get("/revisions/{revId}", s.getStackRevision)
|
||||
r.Get("/services", s.getStackServices)
|
||||
r.Get("/logs", s.getStackLogs)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.AdminOnly)
|
||||
r.Put("/", s.updateStack)
|
||||
r.Delete("/", s.deleteStack)
|
||||
r.Post("/revisions", s.createStackRevision)
|
||||
r.Post("/rollback/{revId}", s.rollbackStack)
|
||||
r.Post("/stop", s.stopStack)
|
||||
r.Post("/start", s.startStack)
|
||||
})
|
||||
})
|
||||
r.With(auth.AdminOnly).Post("/stacks", s.createStack)
|
||||
|
||||
// Static sites.
|
||||
r.Get("/sites", s.listStaticSites)
|
||||
r.Route("/sites/{id}", func(r chi.Router) {
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/auth"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// ── List / Get ─────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) listStacks(w http.ResponseWriter, r *http.Request) {
|
||||
stacks, err := s.store.GetAllStacks()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list stacks")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, stacks)
|
||||
}
|
||||
|
||||
func (s *Server) getStack(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
st, err := s.store.GetStackByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stack")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get stack")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, st)
|
||||
}
|
||||
|
||||
// ── Create ──────────────────────────────────────────────────────────
|
||||
|
||||
type createStackRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
YAML string `json:"yaml"`
|
||||
Deploy bool `json:"deploy"` // if true, deploy immediately after create
|
||||
}
|
||||
|
||||
func (s *Server) createStack(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available (docker compose missing?)")
|
||||
return
|
||||
}
|
||||
var req createStackRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name == "" || req.YAML == "" {
|
||||
respondError(w, http.StatusBadRequest, "name and yaml are required")
|
||||
return
|
||||
}
|
||||
|
||||
author := authorFromRequest(r)
|
||||
ctx := r.Context()
|
||||
st, rev, err := s.stackManager.Create(ctx, req.Name, req.Description, req.YAML, author)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.Deploy {
|
||||
// Deploy asynchronously so the client gets a fast response.
|
||||
go func(stackID, revID string) {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
_ = s.stackManager.Deploy(bgCtx, stackID, revID)
|
||||
}(st.ID, rev.ID)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, map[string]any{
|
||||
"stack": st,
|
||||
"revision": rev,
|
||||
})
|
||||
}
|
||||
|
||||
// ── Update (metadata only) ─────────────────────────────────────────
|
||||
|
||||
type updateStackRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func (s *Server) updateStack(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
existing, err := s.store.GetStackByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stack")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get stack")
|
||||
return
|
||||
}
|
||||
var req updateStackRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Name != "" {
|
||||
existing.Name = req.Name
|
||||
}
|
||||
existing.Description = req.Description
|
||||
if err := s.store.UpdateStack(existing); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update stack")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, existing)
|
||||
}
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) deleteStack(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
removeVolumes := r.URL.Query().Get("remove_volumes") == "true"
|
||||
if err := s.stackManager.Delete(r.Context(), id, removeVolumes); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "stack")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete stack: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
|
||||
// ── Revisions ──────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) listStackRevisions(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
revs, err := s.store.GetStackRevisionsByStackID(id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list revisions")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, revs)
|
||||
}
|
||||
|
||||
func (s *Server) getStackRevision(w http.ResponseWriter, r *http.Request) {
|
||||
revID := chi.URLParam(r, "revId")
|
||||
rev, err := s.store.GetStackRevisionByID(revID)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "revision")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get revision")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, rev)
|
||||
}
|
||||
|
||||
type newRevisionRequest struct {
|
||||
YAML string `json:"yaml"`
|
||||
}
|
||||
|
||||
func (s *Server) createStackRevision(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
var req newRevisionRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.YAML == "" {
|
||||
respondError(w, http.StatusBadRequest, "yaml is required")
|
||||
return
|
||||
}
|
||||
author := authorFromRequest(r)
|
||||
|
||||
// Deploy asynchronously; return the revision immediately.
|
||||
ctx := r.Context()
|
||||
rev, err := s.stackManager.NewRevisionAndDeployAsync(ctx, id, req.YAML, author)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusAccepted, rev)
|
||||
}
|
||||
|
||||
func (s *Server) rollbackStack(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
revID := chi.URLParam(r, "revId")
|
||||
author := authorFromRequest(r)
|
||||
|
||||
rev, err := s.stackManager.RollbackAsync(r.Context(), id, revID, author)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusAccepted, rev)
|
||||
}
|
||||
|
||||
// ── Control ──────────────────────────────────────────────────────
|
||||
|
||||
func (s *Server) stopStack(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.stackManager.Stop(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
||||
}
|
||||
|
||||
func (s *Server) startStack(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := s.stackManager.Start(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"status": "running"})
|
||||
}
|
||||
|
||||
func (s *Server) getStackServices(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
services, err := s.stackManager.Services(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, services)
|
||||
}
|
||||
|
||||
func (s *Server) getStackLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if s.stackManager == nil {
|
||||
respondError(w, http.StatusServiceUnavailable, "stack manager not available")
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
service := r.URL.Query().Get("service")
|
||||
tail := 200
|
||||
if t := r.URL.Query().Get("tail"); t != "" {
|
||||
if n, err := strconv.Atoi(t); err == nil && n > 0 {
|
||||
tail = n
|
||||
}
|
||||
}
|
||||
logs, err := s.stackManager.Logs(r.Context(), id, service, tail)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = io.WriteString(w, logs)
|
||||
}
|
||||
|
||||
// authorFromRequest best-effort returns the username of the acting user.
|
||||
// Falls back to "system" if no auth context is present.
|
||||
func authorFromRequest(r *http.Request) string {
|
||||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok && claims.Username != "" {
|
||||
return claims.Username
|
||||
}
|
||||
return "system"
|
||||
}
|
||||
@@ -24,6 +24,9 @@ const (
|
||||
|
||||
// EventStaticSiteStatus is emitted when a static site status changes.
|
||||
EventStaticSiteStatus EventType = "static_site_status"
|
||||
|
||||
// EventStackStatus is emitted when a compose stack status changes.
|
||||
EventStackStatus EventType = "stack_status"
|
||||
)
|
||||
|
||||
// Event is a single event published on the bus.
|
||||
@@ -74,6 +77,14 @@ type StaticSiteStatusPayload struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// StackStatusPayload is the payload for EventStackStatus events.
|
||||
type StackStatusPayload struct {
|
||||
StackID string `json:"stack_id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Subscriber is a channel that receives events.
|
||||
type Subscriber chan Event
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Package stack provides docker-compose ("stack") management for Tinyforge.
|
||||
//
|
||||
// Stacks are a first-class concept distinct from single-container Projects:
|
||||
// users upload a docker-compose YAML, which is stored as an append-only
|
||||
// revision and deployed via the `docker compose` CLI. Rollback is a new
|
||||
// revision whose YAML is copied from an older one, redeployed.
|
||||
package stack
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compose is a thin wrapper around the `docker compose` CLI.
|
||||
// It intentionally shells out rather than using the Docker SDK: compose has
|
||||
// no first-class Go SDK, and the CLI is the canonical interface.
|
||||
type Compose struct {
|
||||
binary string // path to `docker` binary; defaults to "docker"
|
||||
}
|
||||
|
||||
// NewCompose returns a Compose wrapper. If binary is empty, "docker" is used.
|
||||
func NewCompose(binary string) *Compose {
|
||||
if binary == "" {
|
||||
binary = "docker"
|
||||
}
|
||||
return &Compose{binary: binary}
|
||||
}
|
||||
|
||||
// Available returns nil if `docker compose version` succeeds.
|
||||
func (c *Compose) Available(ctx context.Context) error {
|
||||
cmd := exec.CommandContext(ctx, c.binary, "compose", "version")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("docker compose not available: %w (output: %s)", err, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Up runs `docker compose -p <projectName> -f <yamlPath> up -d`.
|
||||
// Returns combined stdout+stderr for log persistence.
|
||||
func (c *Compose) Up(ctx context.Context, projectName, yamlPath string) (string, error) {
|
||||
return c.run(ctx, projectName, "-f", yamlPath, "up", "-d", "--remove-orphans")
|
||||
}
|
||||
|
||||
// Down runs `docker compose -p <projectName> down`.
|
||||
// removeVolumes controls whether named volumes are also removed (`-v`).
|
||||
func (c *Compose) Down(ctx context.Context, projectName string, removeVolumes bool) (string, error) {
|
||||
args := []string{"down", "--remove-orphans"}
|
||||
if removeVolumes {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
return c.run(ctx, projectName, args...)
|
||||
}
|
||||
|
||||
// Stop runs `docker compose -p <projectName> stop`.
|
||||
func (c *Compose) Stop(ctx context.Context, projectName string) (string, error) {
|
||||
return c.run(ctx, projectName, "stop")
|
||||
}
|
||||
|
||||
// Start runs `docker compose -p <projectName> start`.
|
||||
func (c *Compose) Start(ctx context.Context, projectName string) (string, error) {
|
||||
return c.run(ctx, projectName, "start")
|
||||
}
|
||||
|
||||
// Service is a single row of `docker compose ps --format json`.
|
||||
type Service struct {
|
||||
Name string `json:"Name"`
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
Status string `json:"Status"`
|
||||
Health string `json:"Health"`
|
||||
ExitCode int `json:"ExitCode"`
|
||||
}
|
||||
|
||||
// Ps runs `docker compose -p <projectName> -f <yamlPath> ps --format json`
|
||||
// and returns one Service per running+stopped service. yamlPath may be empty
|
||||
// (compose uses stored state when known).
|
||||
func (c *Compose) Ps(ctx context.Context, projectName, yamlPath string) ([]Service, error) {
|
||||
args := []string{}
|
||||
if yamlPath != "" {
|
||||
args = append(args, "-f", yamlPath)
|
||||
}
|
||||
args = append(args, "ps", "--format", "json", "--all")
|
||||
out, err := c.run(ctx, projectName, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parsePsOutput(out), nil
|
||||
}
|
||||
|
||||
// Logs runs `docker compose -p <projectName> logs --no-color --tail=<n> <service>`.
|
||||
// If service is empty, logs for all services are returned.
|
||||
func (c *Compose) Logs(ctx context.Context, projectName, service string, tail int) (string, error) {
|
||||
args := []string{"logs", "--no-color", fmt.Sprintf("--tail=%d", tail)}
|
||||
if service != "" {
|
||||
args = append(args, service)
|
||||
}
|
||||
return c.run(ctx, projectName, args...)
|
||||
}
|
||||
|
||||
// run executes `docker compose -p <projectName> <args...>` and returns combined output.
|
||||
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
|
||||
full := append([]string{"compose", "-p", projectName}, args...)
|
||||
cmd := exec.CommandContext(ctx, c.binary, full...)
|
||||
var buf bytes.Buffer
|
||||
cmd.Stdout = &buf
|
||||
cmd.Stderr = &buf
|
||||
err := cmd.Run()
|
||||
out := buf.String()
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("docker compose %s: %w (output: %s)",
|
||||
strings.Join(args, " "), err, strings.TrimSpace(out))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/events"
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// Manager orchestrates the stack deployment pipeline: validate YAML, persist
|
||||
// a revision, write YAML to disk, run `docker compose up`, update status.
|
||||
type Manager struct {
|
||||
store *store.Store
|
||||
compose *Compose
|
||||
eventBus *events.Bus
|
||||
workDir string // where per-stack YAML files are written
|
||||
}
|
||||
|
||||
// NewManager constructs a stack Manager. workDir is the directory where
|
||||
// per-stack YAML files are written; it is created if missing.
|
||||
func NewManager(st *store.Store, compose *Compose, eventBus *events.Bus, workDir string) (*Manager, error) {
|
||||
if workDir == "" {
|
||||
workDir = filepath.Join(os.TempDir(), "tinyforge-stacks")
|
||||
}
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create stack workdir: %w", err)
|
||||
}
|
||||
return &Manager{
|
||||
store: st,
|
||||
compose: compose,
|
||||
eventBus: eventBus,
|
||||
workDir: workDir,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Available reports whether the underlying `docker compose` CLI is usable.
|
||||
func (m *Manager) Available(ctx context.Context) error {
|
||||
return m.compose.Available(ctx)
|
||||
}
|
||||
|
||||
// Create inserts a new stack + its initial revision. Does NOT deploy.
|
||||
func (m *Manager) Create(ctx context.Context, name, description, yamlText, author string) (store.Stack, store.StackRevision, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return store.Stack{}, store.StackRevision{}, fmt.Errorf("name is required")
|
||||
}
|
||||
spec, err := Parse(yamlText)
|
||||
if err != nil {
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
if err := Validate(spec); err != nil {
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
|
||||
st := store.Stack{
|
||||
Name: name,
|
||||
Description: description,
|
||||
ComposeProjectName: composeProjectName(name),
|
||||
Status: "stopped",
|
||||
}
|
||||
st, err = m.store.CreateStack(st)
|
||||
if err != nil {
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
|
||||
rev, err := m.store.CreateStackRevision(store.StackRevision{
|
||||
StackID: st.ID,
|
||||
YAML: yamlText,
|
||||
Author: author,
|
||||
})
|
||||
if err != nil {
|
||||
// Best-effort cleanup of the stack row.
|
||||
_ = m.store.DeleteStack(st.ID)
|
||||
return store.Stack{}, store.StackRevision{}, err
|
||||
}
|
||||
return st, rev, nil
|
||||
}
|
||||
|
||||
// Deploy brings up the stack for the given revision. Updates stack + revision
|
||||
// status transitions: deploying → running | failed. Blocking.
|
||||
func (m *Manager) Deploy(ctx context.Context, stackID, revisionID string) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rev, err := m.store.GetStackRevisionByID(revisionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rev.StackID != stackID {
|
||||
return fmt.Errorf("revision %s does not belong to stack %s", revisionID, stackID)
|
||||
}
|
||||
|
||||
deploy, err := m.store.CreateStackDeploy(store.StackDeploy{
|
||||
StackID: stackID,
|
||||
RevisionID: revisionID,
|
||||
Status: "deploying",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = m.store.UpdateStackRevisionStatus(rev.ID, "deploying", deploy.ID)
|
||||
m.setStatus(st, "deploying", "")
|
||||
|
||||
yamlPath, err := m.writeYAML(st.ID, rev.Revision, rev.YAML)
|
||||
if err != nil {
|
||||
m.failDeploy(st, deploy, rev, fmt.Sprintf("write yaml: %v", err))
|
||||
return err
|
||||
}
|
||||
|
||||
out, upErr := m.compose.Up(ctx, st.ComposeProjectName, yamlPath)
|
||||
if upErr != nil {
|
||||
m.failDeploy(st, deploy, rev, fmt.Sprintf("compose up: %v\n%s", upErr, out))
|
||||
return upErr
|
||||
}
|
||||
|
||||
// Success.
|
||||
deploy.Status = "success"
|
||||
deploy.Log = out
|
||||
deploy.FinishedAt = store.Now()
|
||||
_ = m.store.UpdateStackDeploy(deploy)
|
||||
_ = m.store.UpdateStackRevisionStatus(rev.ID, "success", deploy.ID)
|
||||
_ = m.store.SetStackCurrentRevision(st.ID, rev.ID)
|
||||
m.setStatus(st, "running", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRevisionAndDeploy appends a new revision (validating YAML first) and deploys it.
|
||||
func (m *Manager) NewRevisionAndDeploy(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
|
||||
spec, err := Parse(yamlText)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if err := Validate(spec); err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
rev, err := m.store.CreateStackRevision(store.StackRevision{
|
||||
StackID: stackID,
|
||||
YAML: yamlText,
|
||||
Author: author,
|
||||
})
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if err := m.Deploy(ctx, stackID, rev.ID); err != nil {
|
||||
return rev, err
|
||||
}
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// NewRevisionAndDeployAsync creates a revision and triggers deploy in a goroutine.
|
||||
// Returns the created revision immediately.
|
||||
func (m *Manager) NewRevisionAndDeployAsync(ctx context.Context, stackID, yamlText, author string) (store.StackRevision, error) {
|
||||
spec, err := Parse(yamlText)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if err := Validate(spec); err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
rev, err := m.store.CreateStackRevision(store.StackRevision{
|
||||
StackID: stackID,
|
||||
YAML: yamlText,
|
||||
Author: author,
|
||||
})
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
go func(stackID, revID string) {
|
||||
bgCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
if err := m.Deploy(bgCtx, stackID, revID); err != nil {
|
||||
slog.Warn("stack: async deploy failed", "stack", stackID, "revision", revID, "error", err)
|
||||
}
|
||||
}(stackID, rev.ID)
|
||||
return rev, nil
|
||||
}
|
||||
|
||||
// RollbackAsync creates a copy-revision from a target and deploys asynchronously.
|
||||
func (m *Manager) RollbackAsync(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
|
||||
target, err := m.store.GetStackRevisionByID(targetRevisionID)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if target.StackID != stackID {
|
||||
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
|
||||
}
|
||||
return m.NewRevisionAndDeployAsync(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
|
||||
}
|
||||
|
||||
// Rollback creates a new revision whose YAML is copied from the given prior
|
||||
// revision, then deploys it. Keeps history append-only.
|
||||
func (m *Manager) Rollback(ctx context.Context, stackID, targetRevisionID, author string) (store.StackRevision, error) {
|
||||
target, err := m.store.GetStackRevisionByID(targetRevisionID)
|
||||
if err != nil {
|
||||
return store.StackRevision{}, err
|
||||
}
|
||||
if target.StackID != stackID {
|
||||
return store.StackRevision{}, fmt.Errorf("revision %s does not belong to stack %s", targetRevisionID, stackID)
|
||||
}
|
||||
return m.NewRevisionAndDeploy(ctx, stackID, target.YAML, author+" (rollback to rev "+itoa(target.Revision)+")")
|
||||
}
|
||||
|
||||
// Stop runs `docker compose stop` without removing containers.
|
||||
func (m *Manager) Stop(ctx context.Context, stackID string) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.compose.Stop(ctx, st.ComposeProjectName); err != nil {
|
||||
return err
|
||||
}
|
||||
m.setStatus(st, "stopped", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start runs `docker compose start` on existing containers.
|
||||
func (m *Manager) Start(ctx context.Context, stackID string) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.compose.Start(ctx, st.ComposeProjectName); err != nil {
|
||||
return err
|
||||
}
|
||||
m.setStatus(st, "running", "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete tears down the stack and removes the DB row. If removeVolumes is
|
||||
// true, named volumes are also deleted (`compose down -v`). Destructive.
|
||||
func (m *Manager) Delete(ctx context.Context, stackID string, removeVolumes bool) error {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := m.compose.Down(ctx, st.ComposeProjectName, removeVolumes); err != nil {
|
||||
// Log but continue — DB row must not be orphaned.
|
||||
slog.Warn("stack: compose down failed", "stack", st.Name, "error", err)
|
||||
}
|
||||
// Best-effort YAML cleanup.
|
||||
_ = os.RemoveAll(filepath.Join(m.workDir, st.ID))
|
||||
return m.store.DeleteStack(stackID)
|
||||
}
|
||||
|
||||
// Services returns current service state for a stack.
|
||||
func (m *Manager) Services(ctx context.Context, stackID string) ([]Service, error) {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
yamlPath := ""
|
||||
if st.CurrentRevisionID != "" {
|
||||
if rev, err := m.store.GetStackRevisionByID(st.CurrentRevisionID); err == nil {
|
||||
yamlPath, _ = m.writeYAML(st.ID, rev.Revision, rev.YAML)
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return m.compose.Ps(ctx, st.ComposeProjectName, yamlPath)
|
||||
}
|
||||
|
||||
// Logs returns the last `tail` log lines for a service (or all services if empty).
|
||||
func (m *Manager) Logs(ctx context.Context, stackID, service string, tail int) (string, error) {
|
||||
st, err := m.store.GetStackByID(stackID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tail <= 0 {
|
||||
tail = 200
|
||||
}
|
||||
return m.compose.Logs(ctx, st.ComposeProjectName, service, tail)
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
func (m *Manager) setStatus(st store.Stack, status, errMsg string) {
|
||||
_ = m.store.UpdateStackStatus(st.ID, status, errMsg)
|
||||
if m.eventBus != nil {
|
||||
m.eventBus.Publish(events.Event{
|
||||
Type: events.EventStackStatus,
|
||||
Payload: events.StackStatusPayload{
|
||||
StackID: st.ID,
|
||||
Name: st.Name,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) failDeploy(st store.Stack, d store.StackDeploy, rev store.StackRevision, errMsg string) {
|
||||
d.Status = "failed"
|
||||
d.Error = errMsg
|
||||
d.FinishedAt = store.Now()
|
||||
_ = m.store.UpdateStackDeploy(d)
|
||||
_ = m.store.UpdateStackRevisionStatus(rev.ID, "failed", d.ID)
|
||||
m.setStatus(st, "failed", errMsg)
|
||||
}
|
||||
|
||||
// writeYAML writes yaml to <workDir>/<stackID>/rev-<n>.yml and returns the path.
|
||||
func (m *Manager) writeYAML(stackID string, revision int, yamlText string) (string, error) {
|
||||
dir := filepath.Join(m.workDir, stackID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(dir, fmt.Sprintf("rev-%d.yml", revision))
|
||||
if err := os.WriteFile(path, []byte(yamlText), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// composeProjectName sanitises a user-provided stack name into something
|
||||
// `docker compose -p` will accept: lowercase, digits, dashes only.
|
||||
func composeProjectName(name string) string {
|
||||
name = strings.ToLower(name)
|
||||
name = nonProjectChars.ReplaceAllString(name, "-")
|
||||
name = strings.Trim(name, "-")
|
||||
if name == "" {
|
||||
name = "stack"
|
||||
}
|
||||
return "tinyforge-" + name
|
||||
}
|
||||
|
||||
var nonProjectChars = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
func itoa(n int) string { return fmt.Sprintf("%d", n) }
|
||||
@@ -0,0 +1,38 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parsePsOutput handles both formats emitted by `docker compose ps --format json`:
|
||||
// newer versions emit NDJSON (one object per line); older versions emit a single JSON array.
|
||||
func parsePsOutput(out string) []Service {
|
||||
out = strings.TrimSpace(out)
|
||||
if out == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Array form.
|
||||
if strings.HasPrefix(out, "[") {
|
||||
var arr []Service
|
||||
if err := json.Unmarshal([]byte(out), &arr); err == nil {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
// NDJSON form: one object per line.
|
||||
var services []Service
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
var svc Service
|
||||
if err := json.Unmarshal([]byte(line), &svc); err != nil {
|
||||
continue
|
||||
}
|
||||
services = append(services, svc)
|
||||
}
|
||||
return services
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package stack
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// ComposeSpec is a minimal, lenient representation of a compose file.
|
||||
// We only decode fields we need for validation + label-based proxy routing;
|
||||
// everything else is preserved as-is and passed to `docker compose`.
|
||||
type ComposeSpec struct {
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Services map[string]ServiceSpec `yaml:"services"`
|
||||
}
|
||||
|
||||
// ServiceSpec captures the subset of compose service fields we inspect.
|
||||
type ServiceSpec struct {
|
||||
Image string `yaml:"image,omitempty"`
|
||||
Ports []any `yaml:"ports,omitempty"`
|
||||
Labels map[string]string `yaml:"labels,omitempty"`
|
||||
Privileged bool `yaml:"privileged,omitempty"`
|
||||
}
|
||||
|
||||
// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
|
||||
func Parse(yamlText string) (ComposeSpec, error) {
|
||||
var spec ComposeSpec
|
||||
if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil {
|
||||
return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err)
|
||||
}
|
||||
if len(spec.Services) == 0 {
|
||||
return ComposeSpec{}, fmt.Errorf("compose file has no services")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Validate enforces Tinyforge-level constraints beyond compose schema validity.
|
||||
// Current rules:
|
||||
// - No service may set `privileged: true`.
|
||||
// - Every service must declare an image (compose supports build: too, but
|
||||
// Tinyforge v1 disallows building from context to avoid arbitrary-code exec).
|
||||
func Validate(spec ComposeSpec) error {
|
||||
for name, svc := range spec.Services {
|
||||
if svc.Privileged {
|
||||
return fmt.Errorf("service %q: privileged mode is not allowed", name)
|
||||
}
|
||||
if svc.Image == "" {
|
||||
return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -234,6 +234,44 @@ type StaticSiteSecret struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Stack represents a docker-compose stack managed as a single deployable unit.
|
||||
type Stack struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
ComposeProjectName string `json:"compose_project_name"` // `-p` arg for docker compose
|
||||
Status string `json:"status"` // stopped, deploying, running, failed
|
||||
Error string `json:"error"`
|
||||
CurrentRevisionID string `json:"current_revision_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StackRevision is an append-only record of a YAML version for a stack.
|
||||
// Rollback = insert a new revision whose YAML is copied from an older one.
|
||||
type StackRevision struct {
|
||||
ID string `json:"id"`
|
||||
StackID string `json:"stack_id"`
|
||||
Revision int `json:"revision"` // monotonic per stack
|
||||
YAML string `json:"yaml"`
|
||||
Author string `json:"author"`
|
||||
DeployID string `json:"deploy_id"`
|
||||
Status string `json:"status"` // pending, success, failed
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// StackDeploy records a deployment attempt of a specific revision.
|
||||
type StackDeploy struct {
|
||||
ID string `json:"id"`
|
||||
StackID string `json:"stack_id"`
|
||||
RevisionID string `json:"revision_id"`
|
||||
Status string `json:"status"` // pending, deploying, success, failed, rolled_back
|
||||
Log string `json:"log"`
|
||||
Error string `json:"error"`
|
||||
StartedAt string `json:"started_at"`
|
||||
FinishedAt string `json:"finished_at"`
|
||||
}
|
||||
|
||||
// EventLog represents a persistent event log entry.
|
||||
type EventLog struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const stackCols = `id, name, description, compose_project_name, status, error,
|
||||
current_revision_id, created_at, updated_at`
|
||||
|
||||
// CreateStack inserts a new stack and returns it.
|
||||
func (s *Store) CreateStack(st Stack) (Stack, error) {
|
||||
st.ID = uuid.New().String()
|
||||
st.CreatedAt = Now()
|
||||
st.UpdatedAt = st.CreatedAt
|
||||
if st.Status == "" {
|
||||
st.Status = "stopped"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO stacks (`+stackCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
|
||||
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Stack{}, fmt.Errorf("insert stack: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GetStackByID returns a single stack by its ID.
|
||||
func (s *Store) GetStackByID(id string) (Stack, error) {
|
||||
st, err := scanStackRow(s.db.QueryRow(
|
||||
`SELECT `+stackCols+` FROM stacks WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Stack{}, fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return Stack{}, fmt.Errorf("query stack: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GetAllStacks returns every stack ordered by name.
|
||||
func (s *Store) GetAllStacks() ([]Stack, error) {
|
||||
rows, err := s.db.Query(`SELECT ` + stackCols + ` FROM stacks ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query stacks: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Stack{}
|
||||
for rows.Next() {
|
||||
st, err := scanStackRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStack updates the mutable metadata fields (name, description).
|
||||
func (s *Store) UpdateStack(st Stack) error {
|
||||
st.UpdatedAt = Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stacks SET name=?, description=?, updated_at=? WHERE id=?`,
|
||||
st.Name, st.Description, st.UpdatedAt, st.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStackStatus updates the deployment status + error fields.
|
||||
func (s *Store) UpdateStackStatus(id, status, errMsg string) error {
|
||||
now := Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stacks SET status=?, error=?, updated_at=? WHERE id=?`,
|
||||
status, errMsg, now, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack status: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetStackCurrentRevision updates the current_revision_id pointer.
|
||||
func (s *Store) SetStackCurrentRevision(id, revisionID string) error {
|
||||
now := Now()
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stacks SET current_revision_id=?, updated_at=? WHERE id=?`,
|
||||
revisionID, now, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack revision pointer: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStack removes a stack by ID. Cascading deletes handle revisions + deploys.
|
||||
func (s *Store) DeleteStack(id string) error {
|
||||
result, err := s.db.Exec(`DELETE FROM stacks WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete stack: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStackRow(row *sql.Row) (Stack, error) {
|
||||
var st Stack
|
||||
err := row.Scan(
|
||||
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
|
||||
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
|
||||
)
|
||||
return st, err
|
||||
}
|
||||
|
||||
func scanStackRows(rows *sql.Rows) (Stack, error) {
|
||||
var st Stack
|
||||
err := rows.Scan(
|
||||
&st.ID, &st.Name, &st.Description, &st.ComposeProjectName,
|
||||
&st.Status, &st.Error, &st.CurrentRevisionID, &st.CreatedAt, &st.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Stack{}, fmt.Errorf("scan stack: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// --- Stack revisions ---
|
||||
|
||||
const stackRevisionCols = `id, stack_id, revision, yaml, author, deploy_id, status, created_at`
|
||||
|
||||
// CreateStackRevision inserts a new revision with the next monotonic revision number.
|
||||
func (s *Store) CreateStackRevision(r StackRevision) (StackRevision, error) {
|
||||
r.ID = uuid.New().String()
|
||||
r.CreatedAt = Now()
|
||||
if r.Status == "" {
|
||||
r.Status = "pending"
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return StackRevision{}, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var next int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT COALESCE(MAX(revision), 0) + 1 FROM stack_revisions WHERE stack_id = ?`,
|
||||
r.StackID,
|
||||
).Scan(&next); err != nil {
|
||||
return StackRevision{}, fmt.Errorf("next revision: %w", err)
|
||||
}
|
||||
r.Revision = next
|
||||
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO stack_revisions (`+stackRevisionCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.ID, r.StackID, r.Revision, r.YAML, r.Author, r.DeployID, r.Status, r.CreatedAt,
|
||||
); err != nil {
|
||||
return StackRevision{}, fmt.Errorf("insert revision: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return StackRevision{}, fmt.Errorf("commit revision: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetStackRevisionByID returns a single revision by ID.
|
||||
func (s *Store) GetStackRevisionByID(id string) (StackRevision, error) {
|
||||
r, err := scanStackRevisionRow(s.db.QueryRow(
|
||||
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StackRevision{}, fmt.Errorf("revision %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return StackRevision{}, fmt.Errorf("query revision: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// GetStackRevisionsByStackID returns revisions newest-first.
|
||||
func (s *Store) GetStackRevisionsByStackID(stackID string) ([]StackRevision, error) {
|
||||
rows, err := s.db.Query(
|
||||
`SELECT `+stackRevisionCols+` FROM stack_revisions WHERE stack_id = ?
|
||||
ORDER BY revision DESC`,
|
||||
stackID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query revisions: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []StackRevision{}
|
||||
for rows.Next() {
|
||||
r, err := scanStackRevisionRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateStackRevisionStatus updates status + deploy_id linkage.
|
||||
func (s *Store) UpdateStackRevisionStatus(id, status, deployID string) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stack_revisions SET status=?, deploy_id=? WHERE id=?`,
|
||||
status, deployID, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update revision status: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("revision %s: %w", id, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStackRevisionRow(row *sql.Row) (StackRevision, error) {
|
||||
var r StackRevision
|
||||
err := row.Scan(
|
||||
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
|
||||
)
|
||||
return r, err
|
||||
}
|
||||
|
||||
func scanStackRevisionRows(rows *sql.Rows) (StackRevision, error) {
|
||||
var r StackRevision
|
||||
err := rows.Scan(
|
||||
&r.ID, &r.StackID, &r.Revision, &r.YAML, &r.Author, &r.DeployID, &r.Status, &r.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StackRevision{}, fmt.Errorf("scan revision: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// --- Stack deploys ---
|
||||
|
||||
const stackDeployCols = `id, stack_id, revision_id, status, log, error, started_at, finished_at`
|
||||
|
||||
// CreateStackDeploy inserts a new deploy record.
|
||||
func (s *Store) CreateStackDeploy(d StackDeploy) (StackDeploy, error) {
|
||||
d.ID = uuid.New().String()
|
||||
d.StartedAt = Now()
|
||||
if d.Status == "" {
|
||||
d.Status = "pending"
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(
|
||||
`INSERT INTO stack_deploys (`+stackDeployCols+`)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
d.ID, d.StackID, d.RevisionID, d.Status, d.Log, d.Error, d.StartedAt, d.FinishedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return StackDeploy{}, fmt.Errorf("insert stack deploy: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// GetStackDeployByID returns a single deploy by ID.
|
||||
func (s *Store) GetStackDeployByID(id string) (StackDeploy, error) {
|
||||
d, err := scanStackDeployRow(s.db.QueryRow(
|
||||
`SELECT `+stackDeployCols+` FROM stack_deploys WHERE id = ?`, id,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return StackDeploy{}, fmt.Errorf("stack deploy %s: %w", id, ErrNotFound)
|
||||
}
|
||||
if err != nil {
|
||||
return StackDeploy{}, fmt.Errorf("query stack deploy: %w", err)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// UpdateStackDeploy updates status, log, error, finished_at.
|
||||
func (s *Store) UpdateStackDeploy(d StackDeploy) error {
|
||||
result, err := s.db.Exec(
|
||||
`UPDATE stack_deploys SET status=?, log=?, error=?, finished_at=? WHERE id=?`,
|
||||
d.Status, d.Log, d.Error, d.FinishedAt, d.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update stack deploy: %w", err)
|
||||
}
|
||||
n, _ := result.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("stack deploy %s: %w", d.ID, ErrNotFound)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStackDeployRow(row *sql.Row) (StackDeploy, error) {
|
||||
var d StackDeploy
|
||||
err := row.Scan(
|
||||
&d.ID, &d.StackID, &d.RevisionID, &d.Status, &d.Log, &d.Error, &d.StartedAt, &d.FinishedAt,
|
||||
)
|
||||
return d, err
|
||||
}
|
||||
@@ -130,6 +130,48 @@ func (s *Store) runMigrations() error {
|
||||
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
|
||||
}
|
||||
|
||||
// Additive stack tables (2026-04-16). Created here rather than in the
|
||||
// schema constant so older databases pick them up on restart.
|
||||
stackTables := []string{
|
||||
`CREATE TABLE IF NOT EXISTS stacks (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
compose_project_name TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'stopped',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
current_revision_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS stack_revisions (
|
||||
id TEXT PRIMARY KEY,
|
||||
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL,
|
||||
yaml TEXT NOT NULL,
|
||||
author TEXT NOT NULL DEFAULT '',
|
||||
deploy_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(stack_id, revision)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS stack_deploys (
|
||||
id TEXT PRIMARY KEY,
|
||||
stack_id TEXT NOT NULL REFERENCES stacks(id) ON DELETE CASCADE,
|
||||
revision_id TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
log TEXT NOT NULL DEFAULT '',
|
||||
error TEXT NOT NULL DEFAULT '',
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
finished_at TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
}
|
||||
for _, t := range stackTables {
|
||||
if _, err := s.db.Exec(t); err != nil {
|
||||
return fmt.Errorf("create stack table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
// Ignore errors from already-applied migrations (duplicate column).
|
||||
_, _ = s.db.Exec(m)
|
||||
@@ -150,6 +192,8 @@ func (s *Store) runMigrations() error {
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_static_site_secrets_site_id ON static_site_secrets(site_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_stack_revisions_stack_id ON stack_revisions(stack_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_stack_deploys_stack_id ON stack_deploys(stack_id)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
|
||||
Generated
+30
@@ -7,6 +7,10 @@
|
||||
"": {
|
||||
"name": "tinyforge-web",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fontsource/instrument-serif": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.15.0",
|
||||
@@ -435,6 +439,22 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/instrument-serif": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz",
|
||||
"integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -2275,6 +2295,16 @@
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"@fontsource/instrument-serif": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/instrument-serif/-/instrument-serif-5.2.8.tgz",
|
||||
"integrity": "sha512-s+bkz+syj2rO00Rmq9g0P+PwuLig33DR1xDR8pTWmovH1pUjwnncrFk++q9mmOex8fUQ7oW80gPpPDaw7V1MMw=="
|
||||
},
|
||||
"@fontsource/jetbrains-mono": {
|
||||
"version": "5.2.8",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
|
||||
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ=="
|
||||
},
|
||||
"@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
|
||||
+5
-1
@@ -19,5 +19,9 @@
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"type": "module"
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fontsource/instrument-serif": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
@import 'tailwindcss';
|
||||
@import '$lib/styles/tokens.css';
|
||||
@import '@fontsource/instrument-serif/400.css';
|
||||
@import '@fontsource/instrument-serif/400-italic.css';
|
||||
@import '@fontsource/jetbrains-mono/400.css';
|
||||
@import '@fontsource/jetbrains-mono/500.css';
|
||||
@import '@fontsource/jetbrains-mono/700.css';
|
||||
|
||||
/* ── Base Styles ──────────────────────────────────────────────────── */
|
||||
|
||||
|
||||
@@ -771,4 +771,80 @@ export function getStaticSiteStorage(
|
||||
return get<import('./types').StaticSiteStorageUsage>(`/api/sites/${siteId}/storage`);
|
||||
}
|
||||
|
||||
// ── Stacks (docker-compose) ─────────────────────────────────────────
|
||||
|
||||
import type { Stack, StackRevision, StackService } from './types';
|
||||
|
||||
export function listStacks(signal?: AbortSignal): Promise<Stack[]> {
|
||||
return get<Stack[]>('/api/stacks', signal);
|
||||
}
|
||||
|
||||
export function getStack(id: string, signal?: AbortSignal): Promise<Stack> {
|
||||
return get<Stack>(`/api/stacks/${id}`, signal);
|
||||
}
|
||||
|
||||
export function createStack(data: {
|
||||
name: string;
|
||||
description?: string;
|
||||
yaml: string;
|
||||
deploy?: boolean;
|
||||
}): Promise<{ stack: Stack; revision: StackRevision }> {
|
||||
return post<{ stack: Stack; revision: StackRevision }>('/api/stacks', data);
|
||||
}
|
||||
|
||||
export function updateStack(id: string, data: { name?: string; description?: string }): Promise<Stack> {
|
||||
return put<Stack>(`/api/stacks/${id}`, data);
|
||||
}
|
||||
|
||||
export function deleteStack(id: string, removeVolumes = false): Promise<{ deleted: string }> {
|
||||
const qs = removeVolumes ? '?remove_volumes=true' : '';
|
||||
return del<{ deleted: string }>(`/api/stacks/${id}${qs}`);
|
||||
}
|
||||
|
||||
export function listStackRevisions(id: string, signal?: AbortSignal): Promise<StackRevision[]> {
|
||||
return get<StackRevision[]>(`/api/stacks/${id}/revisions`, signal);
|
||||
}
|
||||
|
||||
export function getStackRevision(id: string, revId: string): Promise<StackRevision> {
|
||||
return get<StackRevision>(`/api/stacks/${id}/revisions/${revId}`);
|
||||
}
|
||||
|
||||
export function createStackRevision(id: string, yaml: string): Promise<StackRevision> {
|
||||
return post<StackRevision>(`/api/stacks/${id}/revisions`, { yaml });
|
||||
}
|
||||
|
||||
export function rollbackStack(id: string, revId: string): Promise<StackRevision> {
|
||||
return post<StackRevision>(`/api/stacks/${id}/rollback/${revId}`);
|
||||
}
|
||||
|
||||
export function stopStack(id: string): Promise<{ status: string }> {
|
||||
return post<{ status: string }>(`/api/stacks/${id}/stop`);
|
||||
}
|
||||
|
||||
export function startStack(id: string): Promise<{ status: string }> {
|
||||
return post<{ status: string }>(`/api/stacks/${id}/start`);
|
||||
}
|
||||
|
||||
export function getStackServices(id: string, signal?: AbortSignal): Promise<StackService[]> {
|
||||
return get<StackService[]>(`/api/stacks/${id}/services`, signal);
|
||||
}
|
||||
|
||||
export async function getStackLogs(
|
||||
id: string,
|
||||
service?: string,
|
||||
tail = 200
|
||||
): Promise<string> {
|
||||
const params = new URLSearchParams();
|
||||
if (service) params.set('service', service);
|
||||
params.set('tail', String(tail));
|
||||
const token = getAuthToken();
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch(`/api/stacks/${id}/logs?${params.toString()}`, { headers });
|
||||
if (!res.ok) {
|
||||
throw new ApiError(`Failed to fetch logs: ${res.status}`, res.status);
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
|
||||
export { ApiError };
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"settings": "Settings",
|
||||
"logout": "Log out",
|
||||
"dns": "DNS Records",
|
||||
"sites": "Sites"
|
||||
"sites": "Sites",
|
||||
"stacks": "Stacks"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"settings": "Настройки",
|
||||
"logout": "Выйти",
|
||||
"dns": "DNS-записи",
|
||||
"sites": "Сайты"
|
||||
"sites": "Сайты",
|
||||
"stacks": "Стеки"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Панель управления",
|
||||
|
||||
@@ -372,6 +372,40 @@ export interface StaticSiteStorageUsage {
|
||||
|
||||
export type StaticSiteStatus = 'idle' | 'syncing' | 'deployed' | 'failed' | 'stopped';
|
||||
|
||||
export type StackStatus = 'stopped' | 'deploying' | 'running' | 'failed';
|
||||
|
||||
export interface Stack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
compose_project_name: string;
|
||||
status: StackStatus;
|
||||
error: string;
|
||||
current_revision_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface StackRevision {
|
||||
id: string;
|
||||
stack_id: string;
|
||||
revision: number;
|
||||
yaml: string;
|
||||
author: string;
|
||||
deploy_id: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface StackService {
|
||||
Name: string;
|
||||
Service: string;
|
||||
State: string;
|
||||
Status: string;
|
||||
Health: string;
|
||||
ExitCode: number;
|
||||
}
|
||||
|
||||
export type GitProvider = '' | 'gitea' | 'github' | 'gitlab';
|
||||
|
||||
/** An encrypted environment variable for a static site's Deno backend. */
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import Toast from '$lib/components/Toast.svelte';
|
||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe } from '$lib/components/icons';
|
||||
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox } from '$lib/components/icons';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||
@@ -24,6 +24,7 @@
|
||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects' },
|
||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe' },
|
||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks' },
|
||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies' },
|
||||
{ href: '/events', labelKey: 'nav.events', icon: 'events' },
|
||||
@@ -163,6 +164,8 @@
|
||||
<IconProjects size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'globe'}
|
||||
<IconGlobe size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'stacks'}
|
||||
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'deploy'}
|
||||
<IconDeploy size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
||||
{:else if item.icon === 'proxies'}
|
||||
|
||||
@@ -0,0 +1,582 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Stack } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconPlus, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
let stacks = $state<Stack[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let confirmDelete = $state<Stack | null>(null);
|
||||
let deleteRemoveVolumes = $state(false);
|
||||
|
||||
async function loadStacks() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try { stacks = await api.listStacks(); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Failed to load stacks'; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
|
||||
async function handleStop(s: Stack) {
|
||||
try { await api.stopStack(s.id); setTimeout(loadStacks, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
|
||||
}
|
||||
async function handleStart(s: Stack) {
|
||||
try { await api.startStack(s.id); setTimeout(loadStacks, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
|
||||
}
|
||||
async function handleDelete() {
|
||||
if (!confirmDelete) return;
|
||||
const id = confirmDelete.id;
|
||||
const removeVolumes = deleteRemoveVolumes;
|
||||
confirmDelete = null; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(id, removeVolumes); await loadStacks(); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: 'RUNNING', cls: 'st-running' };
|
||||
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
|
||||
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
|
||||
default: return { label: 'COLD', cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function fmtTime(ts: string): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
|
||||
onMount(loadStacks);
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<header class="head">
|
||||
<div class="head-top">
|
||||
<span class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span>STACKS</span>
|
||||
</span>
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost" onclick={loadStacks} aria-label="Refresh">
|
||||
<IconRefresh size={16} />
|
||||
</button>
|
||||
<a href="/stacks/new" class="btn-primary">
|
||||
<IconPlus size={16} />
|
||||
<span>New stack</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="display">
|
||||
Stacks<span class="title-accent">.</span>
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Compose blueprints, forged as <em>atomic units</em>.
|
||||
Spin up services, iterate on revisions, roll back without breaking a sweat.
|
||||
</p>
|
||||
|
||||
<dl class="runners">
|
||||
<div><dt>TOTAL</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
|
||||
<div><dt>RUNNING</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
|
||||
<div><dt>FORGING</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
|
||||
<div><dt>FAILED</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
|
||||
</dl>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="grid">
|
||||
{#each Array(3) as _, i}
|
||||
<div class="skeleton" style:--i={i}></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if stacks.length === 0}
|
||||
<div class="empty">
|
||||
<div class="empty-mark">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<h2>The anvil is cold.</h2>
|
||||
<p>Upload a <code>docker-compose.yml</code> to forge your first stack.</p>
|
||||
<a href="/stacks/new" class="btn-primary">
|
||||
<IconPlus size={16} /><span>New stack</span>
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each stacks as s, i (s.id)}
|
||||
{@const sm = statusMeta(s.status)}
|
||||
<article class="card {sm.cls}">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
<header class="card-head">
|
||||
<span class="card-ref">[{String(i + 1).padStart(2, '0')} / {String(stacks.length).padStart(2, '0')}]</span>
|
||||
<span class="status-pill">
|
||||
<span class="pulse"></span>
|
||||
{sm.label}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<a href="/stacks/{s.id}" class="card-title">{s.name}</a>
|
||||
{#if s.description}
|
||||
<p class="card-desc">{s.description}</p>
|
||||
{:else}
|
||||
<p class="card-desc dim">No description</p>
|
||||
{/if}
|
||||
|
||||
{#if s.error}
|
||||
<div class="card-err" title={s.error}>{s.error}</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="meta-k">Updated</span>
|
||||
<span class="meta-v">{fmtTime(s.updated_at)}</span>
|
||||
</div>
|
||||
|
||||
<footer class="card-foot">
|
||||
{#if s.status === 'running'}
|
||||
<button class="act" onclick={() => handleStop(s)} aria-label="Stop">
|
||||
<IconStop size={13} /><span>Stop</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button class="act" onclick={() => handleStart(s)} aria-label="Start">
|
||||
<IconPlay size={13} /><span>Start</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label="Delete">
|
||||
<IconTrash size={13} /><span>Delete</span>
|
||||
</button>
|
||||
<a class="act-link" href="/stacks/{s.id}">Open <span class="arrow">→</span></a>
|
||||
</footer>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete !== null}
|
||||
title="Delete stack?"
|
||||
message={confirmDelete ? `This runs 'docker compose down' and removes "${confirmDelete.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onconfirm={handleDelete}
|
||||
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* subtle workshop dot grid behind hero */
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 20% 0%, #000 0%, transparent 70%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.head-top {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1.5rem; gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(3.75rem, 9vw, 6rem);
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
letter-spacing: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.title-accent {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 52ch;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.lede em {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.runners {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0;
|
||||
margin: 1.75rem 0 0;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.runners > div {
|
||||
padding: 0.85rem 1.1rem;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
}
|
||||
.runners > div:last-child { border-right: 0; }
|
||||
.runners dt {
|
||||
font-family: var(--mono); font-size: 0.62rem;
|
||||
letter-spacing: 0.2em; color: var(--text-tertiary);
|
||||
text-transform: uppercase; margin: 0 0 0.25rem;
|
||||
}
|
||||
.runners dd {
|
||||
margin: 0;
|
||||
font-family: var(--serif); font-size: 1.75rem; line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.runners dd.accent { color: var(--accent); }
|
||||
.runners dd.warn { color: var(--color-danger); }
|
||||
|
||||
/* ── Alert ─────────────────────────────────────── */
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Empty ─────────────────────────────────────── */
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.empty-mark {
|
||||
display: inline-flex; gap: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.empty-mark span {
|
||||
width: 10px; height: 10px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.empty-mark span:nth-child(2) { background: var(--accent); animation: breathe 2.4s ease-in-out infinite; }
|
||||
.empty h2 {
|
||||
font-family: var(--serif); font-weight: 400;
|
||||
font-size: 2.25rem; margin: 0 0 0.5rem;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
|
||||
.empty code {
|
||||
font-family: var(--mono); font-size: 0.85em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.empty .btn-primary { display: inline-flex; }
|
||||
|
||||
/* ── Grid & Cards ──────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
.skeleton {
|
||||
height: 230px;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
background: linear-gradient(110deg,
|
||||
var(--surface-card) 20%,
|
||||
var(--surface-card-hover) 50%,
|
||||
var(--surface-card) 80%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.6s linear infinite;
|
||||
animation-delay: calc(var(--i) * 120ms);
|
||||
}
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 1.25rem 1.25rem 1.1rem;
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute; left: 0; top: 18px; bottom: 18px;
|
||||
width: 3px; border-radius: 0 3px 3px 0;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.card.st-running::before { background: var(--color-success); }
|
||||
.card.st-deploying::before{
|
||||
background: repeating-linear-gradient(0deg,
|
||||
var(--color-info) 0 6px,
|
||||
color-mix(in srgb, var(--color-info) 35%, transparent) 6px 12px);
|
||||
}
|
||||
.card.st-failed::before { background: var(--color-danger); }
|
||||
.card:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
box-shadow: 0 0 0 1px var(--color-brand-400), 0 14px 30px -18px var(--glow);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* registration corners (precision marks) */
|
||||
.reg {
|
||||
position: absolute; width: 8px; height: 8px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid; border-width: 0;
|
||||
opacity: 0; transition: opacity 180ms ease;
|
||||
}
|
||||
.card:hover .reg { opacity: 1; }
|
||||
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; }
|
||||
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; }
|
||||
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; }
|
||||
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; }
|
||||
|
||||
.card-head {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.card-ref {
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.st-running .status-pill { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.st-running .status-pill .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
|
||||
.st-deploying .status-pill { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.st-deploying .status-pill .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
|
||||
.st-failed .status-pill { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.st-failed .status-pill .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
|
||||
:global([data-theme='dark']) .st-running .status-pill { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .st-deploying .status-pill { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
:global([data-theme='dark']) .st-failed .status-pill { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.85rem; line-height: 1.1;
|
||||
color: var(--text-primary);
|
||||
text-decoration: none;
|
||||
letter-spacing: 0;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.card-title:hover {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 0.9rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.card-desc.dim { color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.card-err {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-danger-dark);
|
||||
padding: 0.4rem 0.55rem;
|
||||
margin-bottom: 0.85rem;
|
||||
border-left: 2px solid var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
:global([data-theme='dark']) .card-err {
|
||||
background: color-mix(in srgb, var(--color-danger) 12%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex; gap: 0.5rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
padding: 0.55rem 0;
|
||||
margin-bottom: 0.9rem;
|
||||
border-top: 1px dashed var(--border-primary);
|
||||
border-bottom: 1px dashed var(--border-primary);
|
||||
}
|
||||
.card-meta .meta-k {
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.1em; text-transform: uppercase; font-size: 0.62rem;
|
||||
align-self: center;
|
||||
}
|
||||
.card-meta .meta-v { color: var(--text-secondary); }
|
||||
|
||||
.card-foot {
|
||||
display: flex; gap: 0.4rem; align-items: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
.act {
|
||||
display: inline-flex; align-items: center; gap: 0.35rem;
|
||||
padding: 0.38rem 0.7rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.act:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.act.danger { color: var(--color-danger); }
|
||||
.act.danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .act.danger:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.act-link {
|
||||
margin-left: auto;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-link .arrow { display: inline-block; transition: transform 150ms ease; }
|
||||
.act-link:hover { color: var(--color-brand-700); }
|
||||
.act-link:hover .arrow { transform: translateX(3px); }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head-top { align-items: flex-start; }
|
||||
.display { font-size: 3rem; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,952 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { Stack, StackRevision, StackService } from '$lib/types';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft, IconRefresh, IconPlay, IconStop, IconTrash } from '$lib/components/icons';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
|
||||
const id = $derived($page.params.id ?? '');
|
||||
|
||||
let stack = $state<Stack | null>(null);
|
||||
let revisions = $state<StackRevision[]>([]);
|
||||
let services = $state<StackService[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
|
||||
let editing = $state(false);
|
||||
let editYaml = $state('');
|
||||
let submitting = $state(false);
|
||||
|
||||
let logsService = $state('');
|
||||
let logsText = $state('');
|
||||
let logsLoading = $state(false);
|
||||
|
||||
let confirmRollback = $state<StackRevision | null>(null);
|
||||
let confirmDelete = $state(false);
|
||||
let deleteRemoveVolumes = $state(false);
|
||||
|
||||
let tab = $state<'yaml' | 'revisions' | 'logs'>('yaml');
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function loadAll() {
|
||||
loading = true; error = '';
|
||||
try {
|
||||
const [s, revs, svcs] = await Promise.all([
|
||||
api.getStack(id),
|
||||
api.listStackRevisions(id),
|
||||
api.getStackServices(id).catch(() => [] as StackService[])
|
||||
]);
|
||||
stack = s; revisions = revs; services = svcs;
|
||||
if (!editing && revs.length > 0) editYaml = revs[0].yaml;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load stack';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
if (!stack) return;
|
||||
try { await api.stopStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Stop failed'; }
|
||||
}
|
||||
async function handleStart() {
|
||||
if (!stack) return;
|
||||
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Start failed'; }
|
||||
}
|
||||
async function submitNewRevision() {
|
||||
if (!stack) return;
|
||||
submitting = true; error = '';
|
||||
try { await api.createStackRevision(stack.id, editYaml); editing = false; setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Update failed'; }
|
||||
finally { submitting = false; }
|
||||
}
|
||||
async function doRollback() {
|
||||
if (!stack || !confirmRollback) return;
|
||||
const revId = confirmRollback.id;
|
||||
confirmRollback = null;
|
||||
try { await api.rollbackStack(stack.id, revId); setTimeout(loadAll, 1500); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Rollback failed'; }
|
||||
}
|
||||
async function doDelete() {
|
||||
if (!stack) return;
|
||||
const sid = stack.id;
|
||||
const rm = deleteRemoveVolumes;
|
||||
confirmDelete = false; deleteRemoveVolumes = false;
|
||||
try { await api.deleteStack(sid, rm); await goto('/stacks'); }
|
||||
catch (e) { error = e instanceof Error ? e.message : 'Delete failed'; }
|
||||
}
|
||||
async function loadLogs() {
|
||||
if (!stack) return;
|
||||
logsLoading = true;
|
||||
try { logsText = await api.getStackLogs(stack.id, logsService || undefined, 300); }
|
||||
catch (e) { logsText = e instanceof Error ? e.message : 'Failed to load logs'; }
|
||||
finally { logsLoading = false; }
|
||||
}
|
||||
|
||||
function statusMeta(status: string) {
|
||||
switch (status) {
|
||||
case 'running': return { label: 'RUNNING', cls: 'st-running' };
|
||||
case 'deploying':return { label: 'FORGING', cls: 'st-deploying' };
|
||||
case 'failed': return { label: 'FAILED', cls: 'st-failed' };
|
||||
default: return { label: 'COLD', cls: 'st-stopped' };
|
||||
}
|
||||
}
|
||||
function fmtTime(ts: string): string {
|
||||
if (!ts) return '—';
|
||||
try { return new Date(ts).toLocaleString(); } catch { return ts; }
|
||||
}
|
||||
function serviceState(s: string): string {
|
||||
if (!s) return 'unknown';
|
||||
return s.toLowerCase();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadAll();
|
||||
refreshTimer = setInterval(() => { if (!editing) loadAll(); }, 5000);
|
||||
});
|
||||
onDestroy(() => { if (refreshTimer) clearInterval(refreshTimer); });
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>STACKS</span>
|
||||
</a>
|
||||
|
||||
{#if loading && !stack}
|
||||
<div class="loading">
|
||||
<span class="spinner"></span>
|
||||
<span>Loading blueprint…</span>
|
||||
</div>
|
||||
{:else if error && !stack}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
{:else if stack}
|
||||
{@const sm = statusMeta(stack.status)}
|
||||
<header class="head">
|
||||
<div class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="mono-id">{stack.id.slice(0, 16)}</span>
|
||||
<span class="sep">//</span>
|
||||
<span class="status-pill {sm.cls}">
|
||||
<span class="pulse"></span>{sm.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="head-row">
|
||||
<div class="head-left">
|
||||
<h1 class="display">{stack.name}</h1>
|
||||
{#if stack.description}
|
||||
<p class="lede">{stack.description}</p>
|
||||
{:else}
|
||||
<p class="lede dim">No description</p>
|
||||
{/if}
|
||||
<span class="project-chip">
|
||||
<span class="chip-k">COMPOSE PROJECT</span>
|
||||
<code>{stack.compose_project_name}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn-ghost" onclick={loadAll} aria-label="Refresh">
|
||||
<IconRefresh size={15} />
|
||||
</button>
|
||||
{#if stack.status === 'running'}
|
||||
<button onclick={handleStop} class="chip-btn">
|
||||
<IconStop size={13} /> <span>Stop</span>
|
||||
</button>
|
||||
{:else}
|
||||
<button onclick={handleStart} class="chip-btn primary">
|
||||
<IconPlay size={13} /> <span>Start</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
|
||||
<IconTrash size={13} /> <span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stack.error}
|
||||
<div class="alert">
|
||||
<span class="alert-tag">FAULT</span>
|
||||
<span>{stack.error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<!-- ── Stat tiles ─────────────────────────────── -->
|
||||
<section class="stats">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Services</span>
|
||||
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">in blueprint</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Running</span>
|
||||
<span class="stat-value accent">
|
||||
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">active containers</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Revisions</span>
|
||||
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
|
||||
<span class="stat-sub">in history</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Current</span>
|
||||
<span class="stat-value">
|
||||
R{(revisions.find(r => r.id === stack?.current_revision_id)?.revision ?? 0).toString().padStart(2,'0')}
|
||||
</span>
|
||||
<span class="stat-sub">deployed</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Services ───────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<header class="panel-head">
|
||||
<h2 class="panel-title">Services<span class="title-accent">.</span></h2>
|
||||
<span class="panel-count">{services.length} on the floor</span>
|
||||
</header>
|
||||
{#if services.length === 0}
|
||||
<p class="panel-empty">— no containers running —</p>
|
||||
{:else}
|
||||
<ul class="svc-list">
|
||||
{#each services as svc (svc.Name)}
|
||||
{@const st = serviceState(svc.State)}
|
||||
<li class="svc-row" data-state={st}>
|
||||
<span class="svc-dot"></span>
|
||||
<div class="svc-main">
|
||||
<div class="svc-name">{svc.Service}</div>
|
||||
<div class="svc-id">{svc.Name}</div>
|
||||
</div>
|
||||
<div class="svc-status">
|
||||
<span class="svc-state">{svc.State}</span>
|
||||
<span class="svc-detail">{svc.Status}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- ── Tabs ───────────────────────────────────── -->
|
||||
<section class="panel">
|
||||
<div class="tabs" role="tablist">
|
||||
<button role="tab" class="tab" class:active={tab==='yaml'} onclick={() => tab='yaml'}>
|
||||
<span class="tab-num">I</span><span>Blueprint</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
|
||||
<span class="tab-num">II</span><span>Revisions</span>
|
||||
<span class="tab-badge">{revisions.length}</span>
|
||||
</button>
|
||||
<button role="tab" class="tab" class:active={tab==='logs'} onclick={() => tab='logs'}>
|
||||
<span class="tab-num">III</span><span>Logs</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if tab === 'yaml'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<span class="dim">Current revision</span>
|
||||
{#if !editing}
|
||||
<button class="chip" onclick={() => (editing = true)}>Edit & redeploy</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if editing}
|
||||
<textarea
|
||||
bind:value={editYaml}
|
||||
rows="20"
|
||||
class="yaml-edit"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="panel-foot">
|
||||
<button class="btn-ghost" onclick={() => (editing = false)}>Cancel</button>
|
||||
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
|
||||
<span>{submitting ? 'Forging…' : 'Deploy new revision'}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
{:else if revisions[0]}
|
||||
<div class="yaml-frame">
|
||||
<div class="yaml-frame-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="yaml-title">docker-compose.yml</span>
|
||||
</div>
|
||||
<pre class="yaml-view">{revisions[0].yaml}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if tab === 'revisions'}
|
||||
<div class="panel-body">
|
||||
<ol class="timeline">
|
||||
{#each revisions as rev (rev.id)}
|
||||
<li class="tl-entry" class:current={rev.id === stack.current_revision_id}>
|
||||
<div class="tl-dot"></div>
|
||||
<div class="tl-content">
|
||||
<div class="tl-head">
|
||||
<span class="tl-rev">R{rev.revision.toString().padStart(2, '0')}</span>
|
||||
{#if rev.id === stack.current_revision_id}
|
||||
<span class="tl-badge">CURRENT</span>
|
||||
{/if}
|
||||
<span class="tl-status">{rev.status}</span>
|
||||
<span class="tl-time">{fmtTime(rev.created_at)}</span>
|
||||
</div>
|
||||
<div class="tl-meta">
|
||||
by <strong>{rev.author || 'operator'}</strong>
|
||||
</div>
|
||||
{#if rev.id !== stack.current_revision_id}
|
||||
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
|
||||
← Rollback to this revision
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{:else if tab === 'logs'}
|
||||
<div class="panel-body">
|
||||
<div class="panel-toolbar">
|
||||
<label class="log-select">
|
||||
<span class="dim">Service:</span>
|
||||
<select bind:value={logsService}>
|
||||
<option value="">All services</option>
|
||||
{#each services as svc (svc.Service)}
|
||||
<option value={svc.Service}>{svc.Service}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<button onclick={loadLogs} class="chip" disabled={logsLoading}>
|
||||
{logsLoading ? 'Fetching…' : 'Fetch logs'}
|
||||
</button>
|
||||
</div>
|
||||
{#if logsText}
|
||||
<div class="terminal">
|
||||
<div class="terminal-head">
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-dot"></span>
|
||||
<span class="t-title">~/forge/{stack.name}{logsService ? '/' + logsService : ''}.log</span>
|
||||
</div>
|
||||
<pre class="terminal-body">{logsText}</pre>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="panel-empty">— no logs loaded. tap fetch. —</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRollback !== null}
|
||||
title="Rollback to revision?"
|
||||
message={confirmRollback ? `Create a new revision from rev ${confirmRollback.revision} and redeploy the stack.` : ''}
|
||||
confirmLabel="Rollback"
|
||||
confirmVariant="primary"
|
||||
onconfirm={doRollback}
|
||||
oncancel={() => (confirmRollback = null)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title="Delete stack?"
|
||||
message={stack ? `This runs 'docker compose down' and removes "${stack.name}".${deleteRemoveVolumes ? ' Named volumes will also be removed.' : ''}` : ''}
|
||||
confirmLabel="Delete"
|
||||
confirmVariant="danger"
|
||||
onconfirm={doDelete}
|
||||
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 480px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 50% 0%, #000 0%, transparent 65%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
.loading {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.82rem; color: var(--text-tertiary);
|
||||
}
|
||||
.spinner {
|
||||
width: 12px; height: 12px;
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@keyframes blink {
|
||||
0%, 60%, 100% { opacity: 1; }
|
||||
70%, 90% { opacity: 0.3; }
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.eyebrow {
|
||||
display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.mono-id { color: var(--text-secondary); }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-card-hover);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.62rem; font-weight: 600; letter-spacing: 0.12em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.status-pill .pulse {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.status-pill.st-running { background: var(--color-success-light); color: var(--color-success-dark); }
|
||||
.status-pill.st-running .pulse { background: var(--color-success); animation: blink 1.8s infinite; }
|
||||
.status-pill.st-deploying { background: var(--color-info-light); color: var(--color-info-dark); }
|
||||
.status-pill.st-deploying .pulse { background: var(--color-info); animation: blink 0.8s infinite; }
|
||||
.status-pill.st-failed { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
||||
.status-pill.st-failed .pulse { background: var(--color-danger); animation: blink 0.5s infinite; }
|
||||
:global([data-theme='dark']) .status-pill.st-running { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
||||
:global([data-theme='dark']) .status-pill.st-deploying { background: color-mix(in srgb, var(--color-info) 16%, transparent); color: #93c5fd; }
|
||||
:global([data-theme='dark']) .status-pill.st-failed { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
||||
|
||||
.head-row {
|
||||
display: flex; justify-content: space-between; align-items: flex-end;
|
||||
gap: 1.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.head-left { flex: 1; min-width: 280px; }
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(2.75rem, 7vw, 4.5rem);
|
||||
font-weight: 400; line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.45;
|
||||
max-width: 56ch;
|
||||
}
|
||||
.lede.dim { color: var(--text-tertiary); font-style: italic; }
|
||||
|
||||
.project-chip {
|
||||
display: inline-flex; gap: 0.55rem; align-items: center;
|
||||
margin-top: 0.85rem;
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.chip-k {
|
||||
font-family: var(--mono); font-size: 0.6rem;
|
||||
letter-spacing: 0.15em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.project-chip code {
|
||||
font-family: var(--mono); font-size: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar { display: flex; gap: 0.45rem; align-items: center; flex-wrap: wrap; }
|
||||
.btn-ghost {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 38px; height: 38px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.chip-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.85rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip-btn:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.chip-btn.primary {
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border-color: var(--text-primary);
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.chip-btn.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 3px var(--glow);
|
||||
}
|
||||
.chip-btn.danger { color: var(--color-danger); }
|
||||
.chip-btn.danger:hover {
|
||||
background: var(--color-danger-light);
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger-dark);
|
||||
}
|
||||
:global([data-theme='dark']) .chip-btn.danger:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
margin-top: 1.25rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Stats ─────────────────────────────────────── */
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.stat {
|
||||
padding: 1rem 1.15rem;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
display: flex; flex-direction: column; gap: 0.2rem;
|
||||
}
|
||||
.stat:last-child { border-right: 0; }
|
||||
.stat-label {
|
||||
font-family: var(--mono); font-size: 0.62rem;
|
||||
letter-spacing: 0.2em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.stat-value {
|
||||
font-family: var(--serif); font-size: 2.5rem; line-height: 1;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat-value.accent { color: var(--accent); }
|
||||
.stat-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Panels ────────────────────────────────────── */
|
||||
.panel {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-head {
|
||||
display: flex; align-items: flex-end; justify-content: space-between;
|
||||
padding: 1rem 1.35rem 0.85rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.panel-title {
|
||||
font-family: var(--serif); font-size: 1.75rem;
|
||||
margin: 0; font-weight: 400; line-height: 1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.title-accent { color: var(--accent); font-style: italic; }
|
||||
.panel-count {
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
letter-spacing: 0.12em; color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.panel-empty {
|
||||
padding: 1.75rem; margin: 0;
|
||||
font-family: var(--serif); font-style: italic; color: var(--text-tertiary);
|
||||
text-align: center; font-size: 1rem;
|
||||
}
|
||||
.panel-body { padding: 1.15rem 1.35rem 1.35rem; }
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.9rem; flex-wrap: wrap;
|
||||
}
|
||||
.dim {
|
||||
font-family: var(--mono);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.7rem; letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip:hover:not(:disabled) {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.chip:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Services list ─────────────────────────────── */
|
||||
.svc-list { list-style: none; margin: 0; padding: 0; }
|
||||
.svc-row {
|
||||
display: grid;
|
||||
grid-template-columns: 14px 1fr auto;
|
||||
gap: 1rem; align-items: center;
|
||||
padding: 0.85rem 1.35rem;
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.svc-row:last-child { border-bottom: 0; }
|
||||
.svc-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.svc-row[data-state='running'] .svc-dot {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-success) 22%, transparent);
|
||||
}
|
||||
.svc-row[data-state='exited'] .svc-dot,
|
||||
.svc-row[data-state='dead'] .svc-dot { background: var(--color-danger); }
|
||||
.svc-row[data-state='restarting'] .svc-dot { background: var(--color-warning); animation: blink 0.6s infinite; }
|
||||
.svc-name {
|
||||
font-family: var(--serif); font-size: 1.2rem;
|
||||
color: var(--text-primary); line-height: 1.2;
|
||||
}
|
||||
.svc-id {
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-tertiary); margin-top: 0.1rem;
|
||||
}
|
||||
.svc-status { text-align: right; }
|
||||
.svc-state {
|
||||
display: inline-block;
|
||||
font-family: var(--mono); font-size: 0.66rem;
|
||||
font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: var(--text-primary);
|
||||
padding: 0.2rem 0.55rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.svc-detail {
|
||||
display: block; margin-top: 0.25rem;
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* ── Tabs ──────────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex; gap: 0;
|
||||
border-bottom: 1px solid var(--border-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
.tab {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.95rem 1.25rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer; position: relative;
|
||||
transition: color 150ms ease, background 150ms ease;
|
||||
}
|
||||
.tab:hover { color: var(--text-secondary); }
|
||||
.tab.active {
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.tab.active::after {
|
||||
content: '';
|
||||
position: absolute; left: 0; right: 0; bottom: -1px;
|
||||
height: 2px; background: var(--accent);
|
||||
}
|
||||
.tab-num {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.15rem;
|
||||
font-style: italic;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
.tab-badge {
|
||||
font-size: 0.58rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border-radius: var(--radius-full);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
/* ── YAML view / edit ──────────────────────────── */
|
||||
.yaml-frame {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.yaml-frame-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.yaml-frame-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.yaml-frame-head .dot:nth-child(2) { background: var(--color-warning); }
|
||||
.yaml-frame-head .dot:nth-child(3) { background: var(--color-success); }
|
||||
.yaml-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.yaml-view {
|
||||
max-height: 440px; overflow: auto;
|
||||
padding: 0.9rem 1rem; margin: 0;
|
||||
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
white-space: pre;
|
||||
}
|
||||
.yaml-edit {
|
||||
width: 100%;
|
||||
padding: 0.85rem 1rem;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--mono); font-size: 0.78rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.yaml-edit:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.panel-foot {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.arrow { transition: transform 150ms ease; }
|
||||
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||
|
||||
/* ── Timeline ──────────────────────────────────── */
|
||||
.timeline { list-style: none; margin: 0; padding: 0.25rem 0 0; position: relative; }
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute; top: 1rem; bottom: 1rem; left: 8px;
|
||||
width: 1px; background: var(--border-primary);
|
||||
}
|
||||
.tl-entry {
|
||||
position: relative;
|
||||
padding: 0.6rem 0 0.6rem 2rem;
|
||||
}
|
||||
.tl-dot {
|
||||
position: absolute; left: 3px; top: 1.05rem;
|
||||
width: 11px; height: 11px;
|
||||
background: var(--surface-card);
|
||||
border: 2px solid var(--text-tertiary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
.tl-entry.current .tl-dot {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px var(--accent-soft);
|
||||
}
|
||||
.tl-head {
|
||||
display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap;
|
||||
font-family: var(--mono); font-size: 0.68rem;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tl-rev {
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
letter-spacing: 0; color: var(--text-primary); line-height: 1;
|
||||
}
|
||||
.tl-badge {
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: var(--accent); color: #fff;
|
||||
font-size: 0.58rem; font-weight: 600; letter-spacing: 0.16em;
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.tl-status { color: var(--text-secondary); }
|
||||
.tl-time { color: var(--text-tertiary); }
|
||||
.tl-meta {
|
||||
font-size: 0.82rem; color: var(--text-tertiary);
|
||||
margin-top: 0.25rem; font-family: var(--serif);
|
||||
}
|
||||
.tl-meta strong { color: var(--text-secondary); font-weight: 500; }
|
||||
.tl-action {
|
||||
margin-top: 0.5rem;
|
||||
background: transparent; border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent); font-family: var(--mono);
|
||||
font-size: 0.68rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tl-action:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ── Logs / Terminal ───────────────────────────── */
|
||||
.log-select { display: inline-flex; align-items: center; gap: 0.55rem; }
|
||||
.log-select select {
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.35rem 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.72rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.terminal {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #0b1020;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global([data-theme='dark']) .terminal { background: #05070f; }
|
||||
.terminal-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
background: #141a2e;
|
||||
border-bottom: 1px solid #0a0e1c;
|
||||
}
|
||||
:global([data-theme='dark']) .terminal-head { background: #0a0e1c; }
|
||||
.t-dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
.t-dot:nth-child(1) { background: color-mix(in srgb, var(--color-danger) 70%, black); }
|
||||
.t-dot:nth-child(2) { background: color-mix(in srgb, var(--color-warning) 70%, black); }
|
||||
.t-dot:nth-child(3) { background: color-mix(in srgb, var(--color-success) 70%, black); }
|
||||
.t-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono); font-size: 0.7rem;
|
||||
color: rgba(255,255,255,0.45);
|
||||
}
|
||||
.terminal-body {
|
||||
max-height: 480px; overflow: auto;
|
||||
margin: 0; padding: 1rem 1.1rem;
|
||||
font-family: var(--mono); font-size: 0.76rem; line-height: 1.55;
|
||||
color: #c7d0e0;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.head-row { flex-direction: column; align-items: stretch; }
|
||||
.display { font-size: 2.5rem; }
|
||||
.svc-row { grid-template-columns: 14px 1fr; }
|
||||
.svc-status { grid-column: 2; text-align: left; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,621 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import * as api from '$lib/api';
|
||||
import { IconArrowLeft } from '$lib/components/icons';
|
||||
|
||||
let name = $state('');
|
||||
let description = $state('');
|
||||
let yaml = $state('');
|
||||
let deployNow = $state(true);
|
||||
let submitting = $state(false);
|
||||
let error = $state('');
|
||||
let fileInput = $state<HTMLInputElement | null>(null);
|
||||
let dragOver = $state(false);
|
||||
|
||||
const sample = `services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
ports:
|
||||
- "8080:80"
|
||||
cache:
|
||||
image: redis:7-alpine`;
|
||||
|
||||
async function handleFile(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
yaml = await file.text();
|
||||
}
|
||||
async function handleDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
dragOver = false;
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
yaml = await file.text();
|
||||
}
|
||||
function loadSample() { yaml = sample; }
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!name.trim() || !yaml.trim()) {
|
||||
error = 'Name and compose YAML are required.';
|
||||
return;
|
||||
}
|
||||
submitting = true; error = '';
|
||||
try {
|
||||
const { stack } = await api.createStack({
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
yaml,
|
||||
deploy: deployNow
|
||||
});
|
||||
await goto(`/stacks/${stack.id}`);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create stack';
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
const lineNumbers = $derived(
|
||||
yaml.split('\n').map((_, i) => String(i + 1).padStart(3, '0')).join('\n')
|
||||
);
|
||||
const lineCount = $derived(yaml ? yaml.split('\n').length : 0);
|
||||
const byteCount = $derived(new Blob([yaml]).size);
|
||||
|
||||
function syncScroll(e: Event) {
|
||||
const ta = e.target as HTMLTextAreaElement;
|
||||
const gutter = ta.parentElement?.querySelector('.gutter') as HTMLElement | null;
|
||||
if (gutter) gutter.scrollTop = ta.scrollTop;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="forge">
|
||||
<div class="dot-grid" aria-hidden="true"></div>
|
||||
|
||||
<a href="/stacks" class="back">
|
||||
<IconArrowLeft size={13} />
|
||||
<span>STACKS</span>
|
||||
</a>
|
||||
|
||||
<header class="head">
|
||||
<span class="eyebrow">
|
||||
<span class="ember"></span>
|
||||
<span>THE FORGE</span>
|
||||
<span class="sep">//</span>
|
||||
<span>NEW BLUEPRINT</span>
|
||||
</span>
|
||||
<h1 class="display">
|
||||
Forge a<br/>new <em>stack</em>.
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint
|
||||
deploy as a single atomic unit.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<form onsubmit={submit} class="form">
|
||||
<span class="reg reg-tl" aria-hidden="true"></span>
|
||||
<span class="reg reg-tr" aria-hidden="true"></span>
|
||||
<span class="reg reg-bl" aria-hidden="true"></span>
|
||||
<span class="reg reg-br" aria-hidden="true"></span>
|
||||
|
||||
{#if error}
|
||||
<div class="alert"><span class="alert-tag">ERR</span><span>{error}</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-name" class="field-label">
|
||||
<span class="num">01</span>
|
||||
<span class="lbl">Name</span>
|
||||
<span class="req">required</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-name"
|
||||
type="text"
|
||||
bind:value={name}
|
||||
required
|
||||
placeholder="my-app-stack"
|
||||
class="input"
|
||||
/>
|
||||
<p class="hint">Lowercase, hyphenated. Used as the compose project name.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="stack-desc" class="field-label">
|
||||
<span class="num">02</span>
|
||||
<span class="lbl">Description</span>
|
||||
<span class="opt">optional</span>
|
||||
</label>
|
||||
<input
|
||||
id="stack-desc"
|
||||
type="text"
|
||||
bind:value={description}
|
||||
placeholder="What does this stack do?"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<span class="num">03</span>
|
||||
<span class="lbl">Compose YAML</span>
|
||||
<span class="req">required</span>
|
||||
<span class="spacer"></span>
|
||||
<button type="button" class="chip" onclick={loadSample}>Load sample</button>
|
||||
<button type="button" class="chip" onclick={() => fileInput?.click()}>Upload file</button>
|
||||
<input
|
||||
bind:this={fileInput}
|
||||
type="file"
|
||||
accept=".yml,.yaml"
|
||||
class="sr-only"
|
||||
onchange={handleFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if !yaml}
|
||||
<button
|
||||
type="button"
|
||||
class="dropzone"
|
||||
class:drag-over={dragOver}
|
||||
ondragover={(e) => { e.preventDefault(); dragOver = true; }}
|
||||
ondragleave={() => (dragOver = false)}
|
||||
ondrop={handleDrop}
|
||||
onclick={() => fileInput?.click()}
|
||||
>
|
||||
<div class="dz-icon">⇣</div>
|
||||
<div class="dz-title">Drop a <em>docker-compose.yml</em> here</div>
|
||||
<div class="dz-sub">or click to browse · or use <strong>Load sample</strong> above</div>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="editor" class:hidden={!yaml}>
|
||||
<div class="editor-head">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
<span class="editor-title">docker-compose.yml</span>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div class="gutter" aria-hidden="true"><pre>{lineNumbers}</pre></div>
|
||||
<textarea
|
||||
bind:value={yaml}
|
||||
onscroll={syncScroll}
|
||||
rows="20"
|
||||
spellcheck="false"
|
||||
placeholder={sample}
|
||||
class="yaml-area"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-foot">
|
||||
<span>{lineCount} lines</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{byteCount} bytes</span>
|
||||
<span class="sep">·</span>
|
||||
<span>YAML</span>
|
||||
<button type="button" class="clear-btn" onclick={() => (yaml = '')}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="deploy-toggle">
|
||||
<input type="checkbox" bind:checked={deployNow} />
|
||||
<span class="toggle-box"></span>
|
||||
<span class="toggle-text">
|
||||
<strong>Deploy immediately</strong>
|
||||
<span class="dim">Strike while the iron's hot. If unchecked, the stack is saved cold.</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<a href="/stacks" class="btn-ghost">Cancel</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
class="btn-primary"
|
||||
>
|
||||
<span>{submitting ? 'Forging…' : deployNow ? 'Forge & deploy' : 'Save blueprint'}</span>
|
||||
<span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.forge {
|
||||
--serif: 'Instrument Serif', 'Iowan Old Style', Georgia, serif;
|
||||
--mono: var(--font-family-mono);
|
||||
--accent: var(--color-brand-600);
|
||||
--accent-soft: color-mix(in srgb, var(--color-brand-500) 14%, transparent);
|
||||
--glow: color-mix(in srgb, var(--color-brand-500) 32%, transparent);
|
||||
|
||||
position: relative;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 1.75rem clamp(1rem, 3vw, 1.75rem) 3rem;
|
||||
color: var(--text-primary);
|
||||
isolation: isolate;
|
||||
}
|
||||
.dot-grid {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; height: 400px;
|
||||
background-image: radial-gradient(var(--border-primary) 1px, transparent 1px);
|
||||
background-size: 22px 22px;
|
||||
mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
|
||||
-webkit-mask-image: radial-gradient(ellipse at 80% 0%, #000 0%, transparent 75%);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem; letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
text-decoration: none;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.back:hover { color: var(--accent); }
|
||||
|
||||
/* ── Head ──────────────────────────────────────── */
|
||||
.head { margin-bottom: 2rem; }
|
||||
.eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
.eyebrow .sep { opacity: 0.5; }
|
||||
.ember {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes breathe {
|
||||
0%, 100% { box-shadow: 0 0 0 3px var(--accent-soft); }
|
||||
50% { box-shadow: 0 0 0 6px color-mix(in srgb, var(--color-brand-500) 20%, transparent); }
|
||||
}
|
||||
|
||||
.display {
|
||||
font-family: var(--serif);
|
||||
font-size: clamp(3rem, 7vw, 4.75rem);
|
||||
font-weight: 400; line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.display em {
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
}
|
||||
.lede {
|
||||
font-family: var(--serif);
|
||||
color: var(--text-secondary);
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 56ch;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.lede code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85em;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* ── Form ──────────────────────────────────────── */
|
||||
.form {
|
||||
position: relative;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: 1.75rem;
|
||||
}
|
||||
.reg {
|
||||
position: absolute; width: 10px; height: 10px;
|
||||
border-color: var(--color-brand-500);
|
||||
border-style: solid; border-width: 0;
|
||||
}
|
||||
.reg-tl { top: -1px; left: -1px; border-top-width: 2px; border-left-width: 2px; border-top-left-radius: var(--radius-2xl); }
|
||||
.reg-tr { top: -1px; right: -1px; border-top-width: 2px; border-right-width: 2px; border-top-right-radius: var(--radius-2xl); }
|
||||
.reg-bl { bottom: -1px; left: -1px; border-bottom-width: 2px; border-left-width: 2px; border-bottom-left-radius: var(--radius-2xl); }
|
||||
.reg-br { bottom: -1px; right: -1px; border-bottom-width: 2px; border-right-width: 2px; border-bottom-right-radius: var(--radius-2xl); }
|
||||
|
||||
.alert {
|
||||
display: flex; gap: 0.7rem; align-items: center;
|
||||
padding: 0.7rem 0.9rem; margin-bottom: 1.25rem;
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger-dark);
|
||||
border: 1px solid var(--color-danger);
|
||||
border-left-width: 4px;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.alert-tag {
|
||||
font-family: var(--mono); font-weight: 700; font-size: 0.65rem;
|
||||
letter-spacing: 0.16em; padding: 0.15rem 0.4rem;
|
||||
background: var(--color-danger); color: #fff;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:global([data-theme='dark']) .alert {
|
||||
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* ── Fields ────────────────────────────────────── */
|
||||
.field { margin-bottom: 1.5rem; }
|
||||
.field-label {
|
||||
display: flex; align-items: center; gap: 0.55rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
.field-label .num {
|
||||
display: inline-flex; width: 26px; height: 26px;
|
||||
justify-content: center; align-items: center;
|
||||
background: var(--text-primary); color: var(--surface-card);
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
}
|
||||
.field-label .lbl {
|
||||
font-family: var(--serif);
|
||||
font-size: 1.25rem; line-height: 1;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.field-label .req {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem; font-weight: 600;
|
||||
color: var(--color-danger);
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
}
|
||||
.field-label .opt {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.6rem; font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase; letter-spacing: 0.12em;
|
||||
}
|
||||
.field-label .spacer { flex: 1; }
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: var(--surface-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 0.65rem 0.85rem;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.hint {
|
||||
font-size: 0.78rem; color: var(--text-tertiary);
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.3rem 0.7rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 120ms ease;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--color-brand-400);
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card-hover);
|
||||
}
|
||||
|
||||
/* ── Dropzone ──────────────────────────────────── */
|
||||
.dropzone {
|
||||
display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%; min-height: 240px;
|
||||
background: var(--surface-card-hover);
|
||||
border: 2px dashed var(--border-primary);
|
||||
border-radius: var(--radius-xl);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 2rem;
|
||||
transition: all 180ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dropzone:hover, .dropzone.drag-over {
|
||||
border-color: var(--color-brand-500);
|
||||
background: color-mix(in srgb, var(--color-brand-500) 6%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-icon { font-size: 2.25rem; line-height: 1; color: var(--text-tertiary); transition: color 150ms ease; }
|
||||
.dropzone:hover .dz-icon, .dropzone.drag-over .dz-icon { color: var(--accent); }
|
||||
.dz-title {
|
||||
font-family: var(--serif); font-size: 1.5rem;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.dz-title em { color: var(--accent); font-style: italic; }
|
||||
.dz-sub {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; letter-spacing: 0.06em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.dz-sub strong { color: var(--text-secondary); font-weight: 600; }
|
||||
|
||||
/* ── Editor ────────────────────────────────────── */
|
||||
.editor {
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--surface-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
.editor.hidden { display: none; }
|
||||
.editor-head {
|
||||
display: flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 0.8rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-bottom: 1px solid var(--border-secondary);
|
||||
}
|
||||
.editor-head .dot {
|
||||
width: 9px; height: 9px; border-radius: 50%;
|
||||
background: var(--border-input);
|
||||
}
|
||||
.editor-head .dot:nth-child(2) { background: var(--color-warning); }
|
||||
.editor-head .dot:nth-child(3) { background: var(--color-success); }
|
||||
.editor-head .editor-title {
|
||||
margin-left: 0.6rem;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
.gutter {
|
||||
flex-shrink: 0;
|
||||
width: 54px;
|
||||
overflow: hidden;
|
||||
background: var(--surface-card-hover);
|
||||
border-right: 1px solid var(--border-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
.gutter pre {
|
||||
margin: 0; padding: 0.85rem 0.6rem 0.85rem 0;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; line-height: 1.5;
|
||||
color: var(--text-tertiary);
|
||||
text-align: right;
|
||||
}
|
||||
.yaml-area {
|
||||
flex: 1; display: block;
|
||||
padding: 0.85rem 1rem;
|
||||
background: transparent;
|
||||
border: 0; outline: 0; resize: vertical;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem; line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
min-height: 300px;
|
||||
}
|
||||
.yaml-area::placeholder { color: var(--text-tertiary); }
|
||||
.editor-foot {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.4rem 0.85rem;
|
||||
background: var(--surface-card-hover);
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.editor-foot .sep { opacity: 0.5; }
|
||||
.clear-btn {
|
||||
margin-left: auto;
|
||||
background: transparent; border: 0;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.66rem; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.clear-btn:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
/* ── Deploy toggle ─────────────────────────────── */
|
||||
.deploy-toggle {
|
||||
display: flex; align-items: flex-start; gap: 0.8rem;
|
||||
padding: 1rem 1.1rem;
|
||||
background: var(--surface-card-hover);
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 150ms ease;
|
||||
}
|
||||
.deploy-toggle:hover { border-color: var(--color-brand-300); }
|
||||
.deploy-toggle input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.toggle-box {
|
||||
flex-shrink: 0;
|
||||
width: 20px; height: 20px;
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-input);
|
||||
position: relative;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.deploy-toggle input:checked + .toggle-box {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.deploy-toggle input:checked + .toggle-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 6px; top: 2px;
|
||||
width: 5px; height: 10px;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.toggle-text strong {
|
||||
display: block; font-family: var(--serif);
|
||||
font-size: 1.15rem; font-weight: 400; line-height: 1.2;
|
||||
color: var(--text-primary); margin-bottom: 0.15rem;
|
||||
}
|
||||
.toggle-text .dim { color: var(--text-tertiary); font-size: 0.82rem; }
|
||||
|
||||
/* ── Actions ───────────────────────────────────── */
|
||||
.actions {
|
||||
display: flex; justify-content: flex-end; gap: 0.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
}
|
||||
.btn-ghost {
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-primary);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-ghost:hover {
|
||||
background: var(--surface-card-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-brand-300);
|
||||
}
|
||||
.btn-primary {
|
||||
display: inline-flex; align-items: center; gap: 0.55rem;
|
||||
padding: 0.65rem 1.2rem;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface-card);
|
||||
border: 0; border-radius: var(--radius-lg);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.74rem; font-weight: 600;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
cursor: pointer;
|
||||
transition: transform 150ms ease, box-shadow 150ms ease;
|
||||
box-shadow: 0 0 0 0 var(--glow);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 0 4px var(--glow);
|
||||
}
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.arrow { transition: transform 150ms ease; }
|
||||
.btn-primary:hover:not(:disabled) .arrow { transform: translateX(3px); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user