feat(cutover): hard legacy cutover — drop projects/stacks/sites/deploys
Build / build (push) Successful in 10m39s
Build / build (push) Successful in 10m39s
The clean-break delete that closes the workload-first refactor arc.
Net diff: ~30 backend files deleted, ~20 modified, ~12k LOC removed
on the Go side; entire /projects /stacks /sites /deploy frontend
trees gone; ~6.7k LOC removed on the Svelte/TypeScript side.
Backend
- API handlers gone: internal/api/{projects,stages,stage_env,stacks,
static_sites,deploys,instances,volume_browser}.go
- Store CRUD + tests gone: internal/store/{projects,stages,stage_env,
stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
workload_sync}.go (+ _test.go siblings)
- Legacy deployer pipeline gone: internal/deployer/{bluegreen,promote,
rollback,subdomain,resolver_test}.go; deployer.go trimmed to just the
dispatch surface used by the plugin pipeline
- internal/staticsite/{manager,healthcheck}.go and
internal/stack/manager.go gone (the rest of those packages stay as
helpers imported by the static + compose plugins)
- internal/registry/poller.go gone (legacy registry poller)
- internal/volume.ResolvePath gone; ResolveWorkloadPath stays
- internal/webhook: handleWebhook (project) + handleSiteWebhook (site)
gone; only POST /api/webhook/triggers/{secret} remains
- workload-side webhook URL handlers (getWorkloadWebhook +
regenerateWorkloadWebhook + EnsureWorkloadWebhookSecret +
SetWorkloadWebhookSecret + GetWorkloadByWebhookSecret) gone — they
minted URLs that would 404 against the new trigger-only ingress
- cmd/server/main.go: dropped staticsite.Manager, stack.Manager,
staticsite.HealthChecker, registry poller, SetSiteSyncTriggerer,
SetStaticSiteManager, SetStackManager, wireStaticBackend
- store/store.go: idempotent DROP TABLE IF EXISTS for every legacy
table (projects, stages, stage_env, volumes, deploys, deploy_logs,
poll_states, stacks, stack_revisions, stack_deploys, static_sites,
static_site_secrets); FK order children-then-parents
- store/models.go: dropped Project, Stage, Deploy, DeployLog, StageEnv,
Volume, StaticSite, StaticSiteSecret, Stack, StackRevision,
StackDeploy types; kept WorkloadKind constants as documented strings
- internal/store/helpers.go (new): BoolToInt, rowScanner,
GenerateWebhookSecret extracted from deleted CRUD files
- internal/api/secrets.go (new): forwards to store.GenerateWebhookSecret
so api + store paths share one secret-generation impl (no
panic-vs-UUID-fallback divergence)
- internal/reconciler/reconciler.go: dropped legacy stack-by-compose
+ static-site label paths; only canonical tinyforge.workload.id
dispatch remains
- providers (gitea_content/github_provider/gitlab_provider) gained
path-traversal rejection on every tree entry
- internal/webhook ParsedImage / ParseImageRef demoted to package-
private (no external callers)
Frontend
- /projects /stacks /sites /deploy routes deleted (entire trees)
- ProjectCard / InstanceCard / StaleContainerCard components deleted
- api.ts: dropped every project/stage/stack/site/deploy/instance
helper + types (Project, Stage, Stack, StaticSite, Deploy,
Instance, Volume, etc.); kept Workload, Container, App, Settings,
Registry, EventTrigger, LogScanRule, webhook envelopes
- WorkloadWebhook type + getWorkloadWebhook/regenerateWorkloadWebhook
api functions gone (mirror of the backend deletion above)
- web/src/routes/+layout.svelte: dropped /projects /sites /stacks
/deploy nav entries, trimmed quick-nav keymap
- web/src/routes/+page.svelte: dashboard rewrite — reads
listWorkloads + listContainers only; 4-card stat grid
(workloads/running/failed/stale) + recent workloads strip
- navCounts.ts, SystemHealthCard.svelte, ContainerLogs.svelte,
ContainerStats.svelte, StatusBadge.svelte, TagCombobox.svelte,
proxies/+page.svelte, containers/+page.svelte all rewired to the
workload-first surface
- AbortController plumbing on dashboard, nav-counts, stale page,
SystemHealthCard so navigation doesn't leave dangling fetches
- i18n: dropped projects.*, projectDetail.*, envEditor.*,
volumeEditor.*, volumeBrowser.*, quickDeploy.*, sites.*, stacks.*,
instance.*, confirm.* namespaces; en/ru parity preserved (1042
keys each)
Hardening from go-reviewer + security-reviewer + typescript-reviewer
subagent passes (0 CRITICAL across all three; 1 HIGH + ~12 MEDIUM
addressed inline before commit):
- Sec H1: dead-end workload webhook URL handlers (would mint URLs
that 404 the new trigger-only ingress) deleted across backend +
frontend
- Go M1: IsTerminalDeployStatus dropped (no production callers)
- Go M2: ParsedImage/ParseImageRef lowercased (in-package only)
- Go M6: generateWebhookSecret unified — api shim forwards to
store.GenerateWebhookSecret
- Doc/comment freshness: stage_id (no longer FK), ProxyRoute legacy
field names, workloadIDRow rationale, webhook_deliveries.target_type
enum, WebhookDeliveryLog component header
Doc
- WORKLOAD_REFACTOR_TODO: cutover marked DONE; all three Priority 1
items are now shipped. Next focus is Priority 3 polish (apps.* i18n
+ codemap entries) and Priority 4 tests.
Behavioral notes for operators upgrading from a pre-cutover build
- Existing rows in the dropped tables disappear on first boot.
- Legacy webhook URLs at /api/webhook/{secret} and
/api/webhook/sites/{secret} return 404; CI configs must repoint to
/api/webhook/triggers/{secret} (the trigger-split boot backfill
lifted any embedded workload secret onto a Trigger row, so the
secret value itself carries over).
- Frontend routes /projects /stacks /sites /deploy are gone; nav
links replaced with /apps and /triggers.
This commit is contained in:
+10
-84
@@ -18,9 +18,9 @@ import (
|
|||||||
tinyforge "github.com/alexei/tinyforge"
|
tinyforge "github.com/alexei/tinyforge"
|
||||||
"github.com/alexei/tinyforge/internal/api"
|
"github.com/alexei/tinyforge/internal/api"
|
||||||
"github.com/alexei/tinyforge/internal/auth"
|
"github.com/alexei/tinyforge/internal/auth"
|
||||||
|
"github.com/alexei/tinyforge/internal/backup"
|
||||||
"github.com/alexei/tinyforge/internal/config"
|
"github.com/alexei/tinyforge/internal/config"
|
||||||
"github.com/alexei/tinyforge/internal/crypto"
|
"github.com/alexei/tinyforge/internal/crypto"
|
||||||
"github.com/alexei/tinyforge/internal/backup"
|
|
||||||
"github.com/alexei/tinyforge/internal/deployer"
|
"github.com/alexei/tinyforge/internal/deployer"
|
||||||
"github.com/alexei/tinyforge/internal/dns"
|
"github.com/alexei/tinyforge/internal/dns"
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
"github.com/alexei/tinyforge/internal/docker"
|
||||||
@@ -32,11 +32,8 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/npm"
|
"github.com/alexei/tinyforge/internal/npm"
|
||||||
"github.com/alexei/tinyforge/internal/proxy"
|
"github.com/alexei/tinyforge/internal/proxy"
|
||||||
"github.com/alexei/tinyforge/internal/reconciler"
|
"github.com/alexei/tinyforge/internal/reconciler"
|
||||||
"github.com/alexei/tinyforge/internal/registry"
|
|
||||||
"github.com/alexei/tinyforge/internal/stale"
|
"github.com/alexei/tinyforge/internal/stale"
|
||||||
"github.com/alexei/tinyforge/internal/stack"
|
|
||||||
"github.com/alexei/tinyforge/internal/stats"
|
"github.com/alexei/tinyforge/internal/stats"
|
||||||
"github.com/alexei/tinyforge/internal/staticsite"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
"github.com/alexei/tinyforge/internal/webhook"
|
"github.com/alexei/tinyforge/internal/webhook"
|
||||||
|
|
||||||
@@ -85,13 +82,6 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backfill workload rows for any project / stack / static site that
|
|
||||||
// predates the workload refactor. Idempotent — safe on every boot.
|
|
||||||
if err := db.BackfillWorkloads(); err != nil {
|
|
||||||
slog.Error("workload backfill", "error", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure default admin user exists on first launch.
|
// Ensure default admin user exists on first launch.
|
||||||
if err := ensureDefaultAdmin(db); err != nil {
|
if err := ensureDefaultAdmin(db); err != nil {
|
||||||
slog.Error("ensure default admin", "error", err)
|
slog.Error("ensure default admin", "error", err)
|
||||||
@@ -109,16 +99,10 @@ func main() {
|
|||||||
// Start the container index reconciler. Runs one boot pass and then
|
// Start the container index reconciler. Runs one boot pass and then
|
||||||
// ticks every 30s. Boot pass populates the containers table from any
|
// ticks every 30s. Boot pass populates the containers table from any
|
||||||
// running containers that predate the workload refactor; subsequent
|
// running containers that predate the workload refactor; subsequent
|
||||||
// ticks catch state drift the deployer didn't witness (e.g., a stack
|
// ticks catch state drift the deployer didn't witness.
|
||||||
// service that exited on its own). Stop() cancels its own child context
|
|
||||||
// before waiting on the goroutine, so a hung `docker ps` doesn't block
|
|
||||||
// shutdown.
|
|
||||||
rec := reconciler.New(db, dockerClient, 30*time.Second)
|
rec := reconciler.New(db, dockerClient, 30*time.Second)
|
||||||
rec.Start(context.Background())
|
rec.Start(context.Background())
|
||||||
defer rec.Stop()
|
defer rec.Stop()
|
||||||
// The plugin pass is wired after the deployer is constructed (below);
|
|
||||||
// the reconciler tolerates a nil dispatcher until then. SetPluginReconciler
|
|
||||||
// is safe to call at any time, including mid-tick.
|
|
||||||
|
|
||||||
// Read settings for NPM URL and polling interval.
|
// Read settings for NPM URL and polling interval.
|
||||||
settings, err := db.GetSettings()
|
settings, err := db.GetSettings()
|
||||||
@@ -181,33 +165,19 @@ func main() {
|
|||||||
defer stopLogger()
|
defer stopLogger()
|
||||||
|
|
||||||
// Event-trigger dispatcher: consume EventLog publishes off the bus
|
// Event-trigger dispatcher: consume EventLog publishes off the bus
|
||||||
// and fan out to operator-configured webhook actions. Loop-prevention
|
// and fan out to operator-configured webhook actions.
|
||||||
// is structural — the dispatcher never writes back to event_log; all
|
|
||||||
// delivery outcomes land in notifier audit logging.
|
|
||||||
stopTriggerDispatcher := events.RegisterEventTriggerDispatcher(eventBus, db, notifier)
|
stopTriggerDispatcher := events.RegisterEventTriggerDispatcher(eventBus, db, notifier)
|
||||||
defer stopTriggerDispatcher()
|
defer stopTriggerDispatcher()
|
||||||
|
|
||||||
dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey)
|
dep := deployer.New(dockerClient, proxyProvider, db, healthChecker, notifier, eventBus, encKey)
|
||||||
rec.SetPluginReconciler(dep)
|
rec.SetPluginReconciler(dep)
|
||||||
|
|
||||||
// Initialize webhook handler. Per-project and per-site secrets are stored
|
// Initialize webhook handler. The single inbound surface is
|
||||||
// on their respective rows; the static-site triggerer is wired in below
|
// /api/webhook/triggers/{secret}; the plugin dispatcher wires the
|
||||||
// once the site manager has been constructed.
|
// trigger fan-out to the deployer.
|
||||||
webhookHandler := webhook.NewHandler(db, dep, nil)
|
webhookHandler := webhook.NewHandler(db)
|
||||||
// Plugin-pipeline dispatcher for /api/webhook/workloads/{secret}.
|
|
||||||
// Wired here so the same *deployer.Deployer serves both legacy and
|
|
||||||
// plugin-native paths from one place.
|
|
||||||
webhookHandler.SetPluginDispatcher(dep)
|
webhookHandler.SetPluginDispatcher(dep)
|
||||||
|
|
||||||
// Initialize registry poller.
|
|
||||||
poller := registry.NewPoller(db, dep, encKey)
|
|
||||||
pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval)
|
|
||||||
if pollingInterval != "" {
|
|
||||||
if err := poller.Start(pollingInterval); err != nil {
|
|
||||||
slog.Warn("failed to start poller", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stale container scanner.
|
// Initialize stale container scanner.
|
||||||
staleScanner := stale.New(db, dockerClient, eventBus)
|
staleScanner := stale.New(db, dockerClient, eventBus)
|
||||||
if err := staleScanner.Start("1h"); err != nil {
|
if err := staleScanner.Start("1h"); err != nil {
|
||||||
@@ -228,9 +198,7 @@ func main() {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
slog.Warn("failed to schedule event prune cron", "error", err)
|
slog.Warn("failed to schedule event prune cron", "error", err)
|
||||||
}
|
}
|
||||||
// Webhook delivery log: keep 14 days of audit trail. Same daily cadence
|
// Webhook delivery log: keep 14 days of audit trail.
|
||||||
// so an admin always has a recent window for debugging without
|
|
||||||
// unbounded growth on a noisy CI.
|
|
||||||
if _, err := cronScheduler.AddFunc("@daily", func() {
|
if _, err := cronScheduler.AddFunc("@daily", func() {
|
||||||
cutoff := time.Now().UTC().AddDate(0, 0, -14).Format("2006-01-02 15:04:05")
|
cutoff := time.Now().UTC().AddDate(0, 0, -14).Format("2006-01-02 15:04:05")
|
||||||
pruned, err := db.PruneWebhookDeliveriesBefore(cutoff)
|
pruned, err := db.PruneWebhookDeliveriesBefore(cutoff)
|
||||||
@@ -339,41 +307,12 @@ func main() {
|
|||||||
}
|
}
|
||||||
scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours)
|
scheduleAutobackup(settings.BackupEnabled, settings.BackupIntervalHours)
|
||||||
|
|
||||||
// Initialize resource stats collector. Interval + retention are read from
|
// Initialize resource stats collector.
|
||||||
// settings on each tick, so configuration changes take effect within one
|
|
||||||
// tick without a restart.
|
|
||||||
statsCollector := stats.New(db, dockerClient)
|
statsCollector := stats.New(db, dockerClient)
|
||||||
statsCollector.Start()
|
statsCollector.Start()
|
||||||
|
|
||||||
// Initialize static site manager and health checker.
|
|
||||||
staticSiteMgr := staticsite.NewManager(db, dockerClient, proxyProvider, eventBus, notifier, encKey)
|
|
||||||
webhookHandler.SetSiteSyncTriggerer(staticSiteMgr)
|
|
||||||
// The plugin static source registers itself eagerly in its init()
|
|
||||||
// now that the deploy pipeline is implemented inline (see
|
|
||||||
// internal/workload/plugin/source/static). The legacy Manager kept
|
|
||||||
// here keeps the /api/sites/* HTTP routes alive during the cutover
|
|
||||||
// window.
|
|
||||||
staticSiteHealth := staticsite.NewHealthChecker(db, dockerClient, staticSiteMgr)
|
|
||||||
if err := staticSiteHealth.Start("2m"); err != nil {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log-scan manager: tails running containers and emits event_log
|
// Log-scan manager: tails running containers and emits event_log
|
||||||
// entries when log lines match operator-configured regex rules.
|
// entries when log lines match operator-configured regex rules.
|
||||||
// Start before the API server is wired so the reload callback can
|
|
||||||
// be plugged in via SetLogScanReloader.
|
|
||||||
logScanMgr := logscanner.NewManager(logscanner.Config{
|
logScanMgr := logscanner.NewManager(logscanner.Config{
|
||||||
Rules: db,
|
Rules: db,
|
||||||
Containers: db,
|
Containers: db,
|
||||||
@@ -382,9 +321,6 @@ func main() {
|
|||||||
Bus: eventBus,
|
Bus: eventBus,
|
||||||
PollInterval: 5 * time.Second,
|
PollInterval: 5 * time.Second,
|
||||||
})
|
})
|
||||||
// Manager owns its own cancellation; Stop() drives the loop and
|
|
||||||
// every tail to exit. Using Background here matches the
|
|
||||||
// reconciler + stale-scanner pattern elsewhere in this file.
|
|
||||||
if err := logScanMgr.Start(context.Background()); err != nil {
|
if err := logScanMgr.Start(context.Background()); err != nil {
|
||||||
slog.Warn("logscanner: initial rule load failed", "error", err)
|
slog.Warn("logscanner: initial rule load failed", "error", err)
|
||||||
}
|
}
|
||||||
@@ -392,10 +328,6 @@ func main() {
|
|||||||
|
|
||||||
// Build API server.
|
// Build API server.
|
||||||
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, notifier, webhookHandler, eventBus, encKey)
|
apiServer := api.NewServer(db, dockerClient, npmClient, proxyProvider, dep, notifier, webhookHandler, eventBus, encKey)
|
||||||
apiServer.SetStaticSiteManager(staticSiteMgr)
|
|
||||||
if stackMgr != nil {
|
|
||||||
apiServer.SetStackManager(stackMgr)
|
|
||||||
}
|
|
||||||
apiServer.SetStaleScanner(staleScanner)
|
apiServer.SetStaleScanner(staleScanner)
|
||||||
apiServer.SetLogScanReloader(logScanMgr)
|
apiServer.SetLogScanReloader(logScanMgr)
|
||||||
apiServer.SetBackupEngine(backupEngine)
|
apiServer.SetBackupEngine(backupEngine)
|
||||||
@@ -411,13 +343,11 @@ func main() {
|
|||||||
router := apiServer.Router()
|
router := apiServer.Router()
|
||||||
|
|
||||||
// Serve embedded static files for the SPA frontend.
|
// Serve embedded static files for the SPA frontend.
|
||||||
// The embed.FS has "web/build" as a prefix, so we sub it to get the root.
|
|
||||||
webBuildFS, err := fs.Sub(tinyforge.WebBuildFS, "web/build")
|
webBuildFS, err := fs.Sub(tinyforge.WebBuildFS, "web/build")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("embedded frontend not available", "error", err)
|
slog.Warn("embedded frontend not available", "error", err)
|
||||||
} else {
|
} else {
|
||||||
staticHandler := api.StaticHandler(webBuildFS)
|
staticHandler := api.StaticHandler(webBuildFS)
|
||||||
// Handle all non-API routes with the static file server.
|
|
||||||
router.NotFound(staticHandler.ServeHTTP)
|
router.NotFound(staticHandler.ServeHTTP)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +358,6 @@ func main() {
|
|||||||
Handler: router,
|
Handler: router,
|
||||||
ReadTimeout: 30 * time.Second,
|
ReadTimeout: 30 * time.Second,
|
||||||
// WriteTimeout is disabled (0) to support SSE long-lived connections.
|
// WriteTimeout is disabled (0) to support SSE long-lived connections.
|
||||||
// Individual non-SSE handlers should use context timeouts as needed.
|
|
||||||
WriteTimeout: 0,
|
WriteTimeout: 0,
|
||||||
IdleTimeout: 120 * time.Second,
|
IdleTimeout: 120 * time.Second,
|
||||||
}
|
}
|
||||||
@@ -456,12 +385,10 @@ func main() {
|
|||||||
// Stop accepting new work.
|
// Stop accepting new work.
|
||||||
cronScheduler.Stop()
|
cronScheduler.Stop()
|
||||||
eventBus.Unsubscribe(notifySub)
|
eventBus.Unsubscribe(notifySub)
|
||||||
staticSiteHealth.Stop()
|
|
||||||
staleScanner.Stop()
|
staleScanner.Stop()
|
||||||
poller.Stop()
|
|
||||||
statsCollector.Stop()
|
statsCollector.Stop()
|
||||||
|
|
||||||
// Drain in-progress deploys, site syncs, and notifications.
|
// Drain in-progress deploys and notifications.
|
||||||
dep.Drain()
|
dep.Drain()
|
||||||
webhookHandler.Drain()
|
webhookHandler.Drain()
|
||||||
notifier.Drain()
|
notifier.Drain()
|
||||||
@@ -491,7 +418,6 @@ func envOrDefault(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ensureDefaultAdmin creates a default admin user on first launch if no users exist.
|
// ensureDefaultAdmin creates a default admin user on first launch if no users exist.
|
||||||
// The password comes from ADMIN_PASSWORD env var, defaulting to "admin".
|
|
||||||
func ensureDefaultAdmin(db *store.Store) error {
|
func ensureDefaultAdmin(db *store.Store) error {
|
||||||
count, err := db.UserCount()
|
count, err := db.UserCount()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,22 +9,26 @@ order.
|
|||||||
|
|
||||||
> ## Current focus (read this first)
|
> ## Current focus (read this first)
|
||||||
>
|
>
|
||||||
> **Triggers as first-class reusable entities — DONE** (2026-05-16) and
|
> **Hard legacy cutover — DONE** (2026-05-16). All three Priority 1 items
|
||||||
> **Static source inline port — DONE** (2026-05-16). The phantom-row
|
> are now shipped. The legacy `/api/{projects,stages,stacks,sites,
|
||||||
> adapter (`cmd/server/static_backend.go`) is gone; the static plugin
|
> deploys,instances}/*` HTTP surface, every backing table (`projects`,
|
||||||
> now operates directly on `plugin.Workload` + `containers` +
|
> `stages`, `stage_env`, `volumes`, `deploys`, `deploy_logs`,
|
||||||
> `workload_env`, with runtime state (`last_commit_sha`, `last_sync_at`,
|
> `poll_states`, `stacks`, `stack_revisions`, `stack_deploys`,
|
||||||
> `last_error`, `status`) carried in `containers.extra_json`. Provider
|
> `static_sites`, `static_site_secrets`), the project-deploy pipeline
|
||||||
> downloads enforce path-traversal rejection, error strings are
|
> (`bluegreen.go`, `promote.go`, `rollback.go`, `subdomain.go` + most of
|
||||||
> sanitized before persistence, and Docker resource names are suffixed
|
> `deployer.go`), the legacy webhook routes (`/api/webhook/{secret}`,
|
||||||
> with the workload ID short prefix to dodge name collisions.
|
> `/api/webhook/sites/{secret}`, `/api/webhook/workloads/{secret}`), and
|
||||||
|
> the legacy frontend (`/projects`, `/stacks`, `/sites`, `/deploy`) are
|
||||||
|
> gone. The `internal/staticsite/{provider,gitea_content,
|
||||||
|
> github_provider,gitlab_provider,markdown,deno}` and
|
||||||
|
> `internal/stack/{compose,parse,validate}` files survive only as
|
||||||
|
> helpers imported by the static + compose plugins.
|
||||||
>
|
>
|
||||||
> **Next on Priority 1** is the **hard legacy cutover** — drop
|
> **Next focus** is **Priority 3 polish** — the `apps.*` i18n namespace
|
||||||
> `/api/projects`, `/api/stacks`, `/api/sites`, `/api/stages` handlers,
|
> still has ~60 hardcoded English strings on `/apps` and `/apps/new`,
|
||||||
> drop their tables, delete `internal/stack/` + `internal/staticsite/`
|
> and `docs/CODEMAPS/` lacks an entry for `internal/workload/plugin/`.
|
||||||
> packages, delete frontend `/projects` / `/stacks` / `/sites` routes.
|
> After that, **Priority 4 tests** — `/api/workloads/*` integration tests
|
||||||
> The `internal/staticsite` package stays alive only for the legacy
|
> and dispatcher coverage.
|
||||||
> `/api/sites/*` HTTP routes — once those drop, it dies with them.
|
|
||||||
|
|
||||||
## Status at a glance
|
## Status at a glance
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ order.
|
|||||||
| ---- | -------- | ------ |
|
| ---- | -------- | ------ |
|
||||||
| Triggers as first-class reusable entities | 1 | **DONE** (2026-05-16) |
|
| Triggers as first-class reusable entities | 1 | **DONE** (2026-05-16) |
|
||||||
| Static source inline port | 1 | **DONE** (2026-05-16) |
|
| Static source inline port | 1 | **DONE** (2026-05-16) |
|
||||||
| Hard legacy cutover | 1 | **PENDING — current focus** |
|
| Hard legacy cutover | 1 | **DONE** (2026-05-16) |
|
||||||
| Generalized volume scopes | 2 | DONE |
|
| Generalized volume scopes | 2 | DONE |
|
||||||
| Kind-aware editors (compose / image / static) | 2 | DONE |
|
| Kind-aware editors (compose / image / static) | 2 | DONE |
|
||||||
| Vendor-specific webhook parsing | 2 | DONE |
|
| Vendor-specific webhook parsing | 2 | DONE |
|
||||||
@@ -241,19 +245,82 @@ addressed before merge):
|
|||||||
keep their old `dw-site-mysite` shape until they're redeployed
|
keep their old `dw-site-mysite` shape until they're redeployed
|
||||||
through the plugin path.
|
through the plugin path.
|
||||||
|
|
||||||
### Hard legacy cutover
|
### ~~Hard legacy cutover~~ — DONE (2026-05-16)
|
||||||
|
|
||||||
The static-source inline port (above) is now complete; the cutover is
|
The clean-break delete that closed the workload-first arc. Net diff:
|
||||||
unblocked. Proceeding with the cutover means:
|
~30 files deleted, ~20 modified, ~12k LOC removed.
|
||||||
|
|
||||||
- Delete `/api/projects`, `/api/stacks`, `/api/sites`, `/api/stages` handlers.
|
**Backend deletions:**
|
||||||
- Drop tables: `projects`, `stages`, `stacks`, `stack_revisions`,
|
|
||||||
`stack_deploys`, `static_sites`, `static_site_secrets`, `deploys`,
|
- API handlers: `internal/api/{projects,stages,stage_env,stacks,
|
||||||
`poll_states`.
|
static_sites,deploys,instances,volume_browser}.go`.
|
||||||
- Delete `internal/stack/`, `internal/staticsite/` packages.
|
- Store CRUD + tests: `internal/store/{projects,stages,stage_env,
|
||||||
- Delete frontend `/projects`, `/sites`, `/stacks` routes.
|
stacks,static_sites,static_site_secrets,deploys,poll_state,volumes,
|
||||||
- Delete legacy `volume.ResolvePath` + `internal/api/volume_browser.go`
|
workload_sync}.go` + their `_test.go`.
|
||||||
callers (the only remaining users).
|
- Deployer pipeline: `internal/deployer/{bluegreen,promote,rollback,
|
||||||
|
subdomain,resolver_test}.go`; `deployer.go` trimmed to just the
|
||||||
|
dispatch surface.
|
||||||
|
- `internal/staticsite/{manager,healthcheck}.go` and
|
||||||
|
`internal/stack/manager.go` (the rest of those packages are still
|
||||||
|
imported by the static + compose plugins as helpers).
|
||||||
|
- Webhook routes: `handleWebhook` (project) + `handleSiteWebhook`
|
||||||
|
(site) handlers gone; `/api/webhook/triggers/{secret}` is the only
|
||||||
|
inbound surface left. The workload-side webhook URL handlers
|
||||||
|
(`getWorkloadWebhook` + `regenerateWorkloadWebhook`) were removed
|
||||||
|
in the cutover-followup pass when a security review caught them
|
||||||
|
minting URLs that 404'd.
|
||||||
|
- `internal/registry/poller.go` (legacy registry poller).
|
||||||
|
- `internal/volume/ResolvePath` (legacy resolver; the workload
|
||||||
|
resolver `ResolveWorkloadPath` stays).
|
||||||
|
- `cmd/server/main.go`: dropped `staticsite.Manager`,
|
||||||
|
`stack.Manager`, `staticsite.HealthChecker`, registry poller,
|
||||||
|
`SetSiteSyncTriggerer`, `SetStaticSiteManager`, `SetStackManager`.
|
||||||
|
|
||||||
|
**Schema migrations:** `internal/store/store.go` ends with
|
||||||
|
idempotent `DROP TABLE IF EXISTS` for every legacy table
|
||||||
|
(`projects`, `stages`, `stage_env`, `volumes`, `deploys`,
|
||||||
|
`deploy_logs`, `poll_states`, `stacks`, `stack_revisions`,
|
||||||
|
`stack_deploys`, `static_sites`, `static_site_secrets`). FK order is
|
||||||
|
children-then-parents.
|
||||||
|
|
||||||
|
**Frontend deletions:** `web/src/routes/{projects,stacks,sites,
|
||||||
|
deploy}/` (entire trees); legacy components
|
||||||
|
(`ProjectCard.svelte`, `InstanceCard.svelte`,
|
||||||
|
`StaleContainerCard.svelte`); `api.ts` legacy functions + types
|
||||||
|
(`Project`, `Stage`, `Stack`, `StaticSite`, `Deploy`, `Instance`,
|
||||||
|
plus their helpers); i18n namespaces (`projects.*`, `projectDetail.*`,
|
||||||
|
`envEditor.*`, `volumeEditor.*`, `volumeBrowser.*`, `quickDeploy.*`,
|
||||||
|
`sites.*`, `stacks.*`, `instance.*`, `confirm.*`); nav entries.
|
||||||
|
Dashboard rewritten to read `listWorkloads()` + `listContainers()`
|
||||||
|
only.
|
||||||
|
|
||||||
|
**Helper extractions** (to keep deletions atomic):
|
||||||
|
`internal/store/helpers.go` (`BoolToInt`, `rowScanner`,
|
||||||
|
`GenerateWebhookSecret`); `internal/api/secrets.go` (api shim that
|
||||||
|
forwards to the store helper so the api + store paths share one
|
||||||
|
secret-generation impl, no panic-vs-UUID-fallback divergence).
|
||||||
|
|
||||||
|
**Reviews shipped through go-reviewer + security-reviewer +
|
||||||
|
typescript-reviewer subagents** — 0 CRITICAL across all three; 1
|
||||||
|
HIGH (dead-end workload webhook surface) + ~12 MEDIUMs all
|
||||||
|
addressed inline before commit.
|
||||||
|
|
||||||
|
**Behavioral notes for operators upgrading from a pre-cutover
|
||||||
|
build:**
|
||||||
|
|
||||||
|
- Existing rows in `projects` / `stages` / `stacks` / `static_sites`
|
||||||
|
/ `static_site_secrets` / `deploys` / `deploy_logs` / `volumes`
|
||||||
|
/ `poll_states` / `stage_env` / `stack_revisions` / `stack_deploys`
|
||||||
|
are dropped on first boot.
|
||||||
|
- The legacy webhook URLs at `/api/webhook/{secret}` and
|
||||||
|
`/api/webhook/sites/{secret}` return 404 — operators with old CI
|
||||||
|
configs must repoint to `/api/webhook/triggers/{secret}` (the boot
|
||||||
|
backfill from the trigger-split refactor lifted any embedded
|
||||||
|
workload secret onto a Trigger row, so the secret value itself
|
||||||
|
carries over).
|
||||||
|
- Frontend routes `/projects`, `/stacks`, `/sites`, `/deploy` are
|
||||||
|
gone. Nav links replaced with `/apps` (+ `/triggers` from the
|
||||||
|
prior arc).
|
||||||
|
|
||||||
## Priority 2 — Behavior gaps
|
## Priority 2 — Behavior gaps
|
||||||
|
|
||||||
|
|||||||
@@ -1,236 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// listDeploys handles GET /api/deploys.
|
|
||||||
func (s *Server) listDeploys(w http.ResponseWriter, r *http.Request) {
|
|
||||||
limitStr := r.URL.Query().Get("limit")
|
|
||||||
limit := 50
|
|
||||||
if limitStr != "" {
|
|
||||||
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
|
|
||||||
limit = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
offsetStr := r.URL.Query().Get("offset")
|
|
||||||
offset := 0
|
|
||||||
if offsetStr != "" {
|
|
||||||
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
|
|
||||||
offset = parsed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
projectID := r.URL.Query().Get("project_id")
|
|
||||||
stageID := r.URL.Query().Get("stage_id")
|
|
||||||
|
|
||||||
deploys, err := s.store.GetDeploys(projectID, stageID, limit, offset)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list deploys", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, deploys)
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: getDeployLogs has been replaced by streamDeployLogs in sse.go.
|
|
||||||
// The new handler supports both SSE streaming and JSON fallback via Accept header.
|
|
||||||
|
|
||||||
// inspectRequest is the expected JSON body for POST /api/deploy/inspect.
|
|
||||||
type inspectRequest struct {
|
|
||||||
Image string `json:"image"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspectResponse is the response body for POST /api/deploy/inspect.
|
|
||||||
type inspectResponse struct {
|
|
||||||
Image string `json:"image"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
Healthcheck string `json:"healthcheck"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// inspectImage handles POST /api/deploy/inspect.
|
|
||||||
// Pulls the image and inspects it for EXPOSE ports and healthcheck config.
|
|
||||||
func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req inspectRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Image == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "image is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Pull the image first so it's available locally for inspection.
|
|
||||||
// Split image:tag for the pull call.
|
|
||||||
imageRef, tag := splitImageTag(req.Image)
|
|
||||||
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
|
|
||||||
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
|
|
||||||
// Try to inspect anyway in case the image is already local.
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := s.docker.InspectImage(ctx, req.Image)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to inspect image", "image", req.Image, "error", err)
|
|
||||||
errMsg := "Failed to inspect image. "
|
|
||||||
if strings.Contains(err.Error(), "docker_engine") || strings.Contains(err.Error(), "docker.sock") {
|
|
||||||
errMsg += "Docker is not available on this machine. Enter port and project name manually."
|
|
||||||
} else {
|
|
||||||
errMsg += "Image may not exist or registry requires authentication."
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusBadGateway, errMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
port := docker.ExtractPort(info.ExposedPorts)
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, inspectResponse{
|
|
||||||
Image: req.Image,
|
|
||||||
Port: port,
|
|
||||||
Healthcheck: info.Healthcheck,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// quickDeployRequest is the expected JSON body for POST /api/deploy/quick.
|
|
||||||
type quickDeployRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
Registry string `json:"registry"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
Force bool `json:"force"` // skip duplicate check
|
|
||||||
EnableProxy *bool `json:"enable_proxy"` // nil defaults to true
|
|
||||||
AutoDeploy *bool `json:"auto_deploy"` // nil defaults to true (deploy immediately)
|
|
||||||
}
|
|
||||||
|
|
||||||
// quickDeploy handles POST /api/deploy/quick.
|
|
||||||
// Creates a project, a default stage, and triggers a deploy in one call.
|
|
||||||
func (s *Server) quickDeploy(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req quickDeployRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Image == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "image is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split tag from image if the image URL contains one (e.g., "registry/app:v1").
|
|
||||||
if req.Tag == "" {
|
|
||||||
imageRef, tag := splitImageTag(req.Image)
|
|
||||||
if tag != "" {
|
|
||||||
req.Image = imageRef
|
|
||||||
req.Tag = tag
|
|
||||||
} else {
|
|
||||||
req.Tag = "latest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
// Derive name from image (without tag).
|
|
||||||
parts := strings.Split(req.Image, "/")
|
|
||||||
req.Name = parts[len(parts)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for existing projects with the same image.
|
|
||||||
if !req.Force {
|
|
||||||
existing, err := s.store.GetProjectsByImage(req.Image)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to check existing projects", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(existing) > 0 {
|
|
||||||
respondJSON(w, http.StatusConflict, map[string]any{
|
|
||||||
"message": "A project with this image already exists",
|
|
||||||
"existing_projects": existing,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create project.
|
|
||||||
project, err := s.store.CreateProject(store.Project{
|
|
||||||
Name: req.Name,
|
|
||||||
Image: req.Image,
|
|
||||||
Registry: req.Registry,
|
|
||||||
Port: req.Port,
|
|
||||||
Env: "{}",
|
|
||||||
Volumes: "{}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create default stage.
|
|
||||||
enableProxy := true
|
|
||||||
if req.EnableProxy != nil {
|
|
||||||
enableProxy = *req.EnableProxy
|
|
||||||
}
|
|
||||||
shouldDeploy := true
|
|
||||||
if req.AutoDeploy != nil {
|
|
||||||
shouldDeploy = *req.AutoDeploy
|
|
||||||
}
|
|
||||||
stage, err := s.store.CreateStage(store.Stage{
|
|
||||||
ProjectID: project.ID,
|
|
||||||
Name: "dev",
|
|
||||||
TagPattern: "*",
|
|
||||||
AutoDeploy: shouldDeploy,
|
|
||||||
MaxInstances: 1,
|
|
||||||
EnableProxy: enableProxy,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only trigger deploy if auto_deploy is enabled.
|
|
||||||
var deployID string
|
|
||||||
if shouldDeploy {
|
|
||||||
deployID, err = s.deployer.AsyncTriggerDeploy(r.Context(), project.ID, stage.ID, req.Tag)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to trigger deploy", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "created"
|
|
||||||
if shouldDeploy {
|
|
||||||
status = "deploying"
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusAccepted, map[string]any{
|
|
||||||
"project": project,
|
|
||||||
"stage": stage,
|
|
||||||
"tag": req.Tag,
|
|
||||||
"deploy_id": deployID,
|
|
||||||
"status": status,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// splitImageTag splits "image:tag" into image and tag parts.
|
|
||||||
// Returns the full string and empty tag if no colon separator is found.
|
|
||||||
func splitImageTag(ref string) (string, string) {
|
|
||||||
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
|
||||||
afterColon := ref[idx+1:]
|
|
||||||
if !strings.Contains(afterColon, "/") {
|
|
||||||
return ref[:idx], afterColon
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ref, ""
|
|
||||||
}
|
|
||||||
|
|
||||||
+35
-40
@@ -190,27 +190,34 @@ func (s *Server) deleteDNSRecord(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
respondJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS consumers.
|
// buildConsumerNameMap builds a lookup of "type:id" -> display name for DNS
|
||||||
|
// consumers. Sourced from the containers index now that legacy project/stage
|
||||||
|
// tables are gone — the workload's name + the container's role + tag is what
|
||||||
|
// operators see in the UI.
|
||||||
func (s *Server) buildConsumerNameMap() map[string]string {
|
func (s *Server) buildConsumerNameMap() map[string]string {
|
||||||
names := make(map[string]string)
|
names := make(map[string]string)
|
||||||
|
containers, err := s.store.ListContainers(store.ContainerFilter{})
|
||||||
// Instance consumers: "instance:id" -> "project/stage:tag"
|
if err != nil {
|
||||||
projects, _ := s.store.GetAllProjects()
|
return names
|
||||||
projectNames := make(map[string]string, len(projects))
|
|
||||||
for _, p := range projects {
|
|
||||||
projectNames[p.ID] = p.Name
|
|
||||||
}
|
}
|
||||||
|
workloadNames := make(map[string]string)
|
||||||
for _, p := range projects {
|
for _, c := range containers {
|
||||||
stages, _ := s.store.GetStagesByProjectID(p.ID)
|
wname, ok := workloadNames[c.WorkloadID]
|
||||||
for _, st := range stages {
|
if !ok {
|
||||||
rows, _ := s.store.ListContainersByStageID(st.ID)
|
if w, err := s.store.GetWorkloadByID(c.WorkloadID); err == nil {
|
||||||
for _, c := range rows {
|
wname = w.Name
|
||||||
names["instance:"+c.ID] = p.Name + "/" + st.Name + ":" + c.ImageTag
|
|
||||||
}
|
}
|
||||||
|
workloadNames[c.WorkloadID] = wname
|
||||||
}
|
}
|
||||||
|
label := wname
|
||||||
|
if c.Role != "" {
|
||||||
|
label = label + "/" + c.Role
|
||||||
|
}
|
||||||
|
if c.ImageTag != "" {
|
||||||
|
label = label + ":" + c.ImageTag
|
||||||
|
}
|
||||||
|
names["instance:"+c.ID] = label
|
||||||
}
|
}
|
||||||
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,38 +350,26 @@ func (s *Server) syncDNSRecords(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID" for all active DNS consumers.
|
// computeExpectedFQDNs returns a map of FQDN -> "consumerType:consumerID"
|
||||||
|
// for every running container that has a proxy route configured. Sourced
|
||||||
|
// directly from the containers index — the workload-first cutover dropped
|
||||||
|
// the per-stage enable_proxy toggle in favour of "if a proxy route ID
|
||||||
|
// exists, the workload wanted a route."
|
||||||
func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]string, error) {
|
func (s *Server) computeExpectedFQDNs(settings store.Settings) (map[string]string, error) {
|
||||||
expected := make(map[string]string)
|
expected := make(map[string]string)
|
||||||
|
containers, err := s.store.ListContainers(store.ContainerFilter{})
|
||||||
// Instances with proxy enabled.
|
|
||||||
projects, err := s.store.GetAllProjects()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get projects: %w", err)
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
}
|
}
|
||||||
for _, p := range projects {
|
for _, c := range containers {
|
||||||
stages, err := s.store.GetStagesByProjectID(p.ID)
|
if c.Subdomain == "" || c.State != "running" {
|
||||||
if err != nil {
|
|
||||||
slog.Warn("dns: failed to get stages", "project_id", p.ID, "error", err)
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, st := range stages {
|
if c.NpmProxyID == 0 && c.ProxyRouteID == "" {
|
||||||
if !st.EnableProxy {
|
continue
|
||||||
continue
|
|
||||||
}
|
|
||||||
rows, err := s.store.ListContainersByStageID(st.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("dns: failed to get containers", "stage_id", st.ID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, c := range rows {
|
|
||||||
if c.NpmProxyID > 0 && c.Subdomain != "" && c.State == "running" {
|
|
||||||
fqdn := c.Subdomain + "." + settings.Domain
|
|
||||||
expected[fqdn] = "instance:" + c.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
fqdn := c.Subdomain + "." + settings.Domain
|
||||||
|
expected[fqdn] = "instance:" + c.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
return expected, nil
|
return expected, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-128
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -14,17 +13,15 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Limits and constants for the log endpoints.
|
// Limits and constants for the log endpoints.
|
||||||
const (
|
const (
|
||||||
defaultLogTail = 200
|
defaultLogTail = 200
|
||||||
maxLogTail = 5000
|
maxLogTail = 5000
|
||||||
maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses
|
maxJSONLogBytes = 4 << 20 // 4 MiB cap for non-streaming log responses
|
||||||
maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner
|
maxLogLineBytes = 1 << 20 // 1 MiB max line length for the bufio.Scanner
|
||||||
logHeartbeatPeriod = 20 * time.Second
|
logHeartbeatPeriod = 20 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,82 +34,8 @@ var (
|
|||||||
ctlBytePattern = regexp.MustCompile(`[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]`)
|
ctlBytePattern = regexp.MustCompile(`[\x00-\x08\x0b-\x1a\x1c-\x1f\x7f]`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// listProjectImages handles GET /api/projects/{id}/images.
|
|
||||||
// Returns all local Docker images matching the project's image reference.
|
|
||||||
func (s *Server) listProjectImages(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
project, err := s.store.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.docker == nil || project.Image == "" {
|
|
||||||
respondJSON(w, http.StatusOK, []any{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
images, err := s.docker.ListImagesByRef(r.Context(), project.Image)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("list project images", "project", project.Name, "error", err)
|
|
||||||
respondJSON(w, http.StatusOK, []any{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, images)
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamContainerLogs handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/logs.
|
|
||||||
// Streams container logs via SSE. {iid} is the container row ID. Ownership is
|
|
||||||
// verified by joining through workload + stage so an attacker cannot stream
|
|
||||||
// logs for a foreign container by guessing IDs under the wrong project URL.
|
|
||||||
func (s *Server) streamContainerLogs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
containerRowID := chi.URLParam(r, "iid")
|
|
||||||
|
|
||||||
c, err := s.store.GetContainerByID(containerRowID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get container", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
wl, err := s.store.GetWorkloadByID(c.WorkloadID)
|
|
||||||
if err != nil {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
stage, err := s.store.GetStageByID(stageID)
|
|
||||||
if err != nil || stage.ProjectID != projectID {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if wl.Kind != string(store.WorkloadKindProject) || wl.RefID != projectID || c.Role != stage.Name {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.ContainerID == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.streamLogsForContainer(w, r, c.ContainerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamLogsForContainer streams logs for an arbitrary container ID using the
|
// streamLogsForContainer streams logs for an arbitrary container ID using the
|
||||||
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (instance, site)
|
// shared SSE/JSON dual-mode pattern. Owner-specific handlers (workload-container)
|
||||||
// should validate ownership and then delegate here.
|
// should validate ownership and then delegate here.
|
||||||
func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) {
|
func (s *Server) streamLogsForContainer(w http.ResponseWriter, r *http.Request, containerID string) {
|
||||||
if s.docker == nil {
|
if s.docker == nil {
|
||||||
@@ -255,11 +178,7 @@ func sanitizeDockerLogLine(line string) string {
|
|||||||
// by any container, computed in a single DB pass against the normalized
|
// by any container, computed in a single DB pass against the normalized
|
||||||
// containers index. Returning an error (rather than swallowing) prevents
|
// containers index. Returning an error (rather than swallowing) prevents
|
||||||
// prune logic from treating a transient DB failure as "nothing is active".
|
// prune logic from treating a transient DB failure as "nothing is active".
|
||||||
func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string]bool, error) {
|
func buildActiveImagesSet(st *store.Store) (map[string]bool, error) {
|
||||||
// `projects` is unused now — kept in the signature for back-compat with
|
|
||||||
// callers that already happen to have the slice. The image_ref column
|
|
||||||
// holds the full "image:tag" string written by the deployer.
|
|
||||||
_ = projects
|
|
||||||
containers, err := st.ListContainers(store.ContainerFilter{})
|
containers, err := st.ListContainers(store.ContainerFilter{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list containers: %w", err)
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
@@ -274,8 +193,43 @@ func buildActiveImagesSet(st *store.Store, projects []store.Project) (map[string
|
|||||||
return active, nil
|
return active, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// unusedImageStats handles GET /api/docker/unused-images.
|
// workloadImageBases returns the set of "image" strings (no tag) that
|
||||||
// Returns the total size of unused project images and whether the threshold is exceeded.
|
// some workload currently mounts to, derived from container.image_ref.
|
||||||
|
// This replaces the legacy "list all projects → projects[].Image" view
|
||||||
|
// after the workload-first cutover.
|
||||||
|
func workloadImageBases(st *store.Store) (map[string]bool, error) {
|
||||||
|
containers, err := st.ListContainers(store.ContainerFilter{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list containers: %w", err)
|
||||||
|
}
|
||||||
|
bases := make(map[string]bool, len(containers))
|
||||||
|
for _, c := range containers {
|
||||||
|
if c.ImageRef == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ref, _ := splitImageTag(c.ImageRef)
|
||||||
|
if ref != "" {
|
||||||
|
bases[ref] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bases, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitImageTag splits "image:tag" into image and tag parts. Returns the
|
||||||
|
// full string and empty tag if no colon separator is found. Inlined here
|
||||||
|
// because the legacy deploys.go that owned it was removed.
|
||||||
|
func splitImageTag(ref string) (string, string) {
|
||||||
|
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
||||||
|
afterColon := ref[idx+1:]
|
||||||
|
if !strings.Contains(afterColon, "/") {
|
||||||
|
return ref[:idx], afterColon
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ref, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// unusedImageStats handles GET /api/docker/unused-images. Returns the total
|
||||||
|
// size of unused workload images and whether the threshold is exceeded.
|
||||||
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.docker == nil {
|
if s.docker == nil {
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
@@ -291,32 +245,25 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
projects, err := s.store.GetAllProjects()
|
imageBases, err := workloadImageBases(s.store)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("unused images: list projects", "error", err)
|
slog.Error("unused images: list workload images", "error", err)
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build set of active image refs in one DB pass instead of N×K queries.
|
activeImages, err := buildActiveImagesSet(s.store)
|
||||||
// A flaky read here previously masqueraded as "no images are active",
|
|
||||||
// which on the prune endpoint would have deleted *running* images.
|
|
||||||
activeImages, err := buildActiveImagesSet(s.store, projects)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("unused images: build active set", "error", err)
|
slog.Error("unused images: build active set", "error", err)
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sum unused image sizes.
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
var totalSize int64
|
var totalSize int64
|
||||||
var count int
|
var count int
|
||||||
for _, p := range projects {
|
for base := range imageBases {
|
||||||
if p.Image == "" {
|
images, err := s.docker.ListImagesByRef(ctx, base)
|
||||||
continue
|
|
||||||
}
|
|
||||||
images, err := s.docker.ListImagesByRef(ctx, p.Image)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -339,69 +286,53 @@ func (s *Server) unusedImageStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// pruneImages handles POST /api/docker/prune-images.
|
// pruneImages handles POST /api/docker/prune-images. Only removes images that
|
||||||
// Only removes images that belong to Tinyforge projects (not all system images).
|
// some workload references (via container.image_ref), never arbitrary host
|
||||||
|
// images.
|
||||||
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) pruneImages(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.docker == nil {
|
if s.docker == nil {
|
||||||
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all image references from our projects.
|
imageBases, err := workloadImageBases(s.store)
|
||||||
projects, err := s.store.GetAllProjects()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("prune: failed to list projects", "error", err)
|
slog.Error("prune: list workload images", "error", err)
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a set of image refs used by active instances. Bail out on error
|
activeImages, err := buildActiveImagesSet(s.store)
|
||||||
// — silently treating a DB blip as "no active images" would prune
|
|
||||||
// images currently in use by running containers.
|
|
||||||
activeImages, err := buildActiveImagesSet(s.store, projects)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("prune: build active set", "error", err)
|
slog.Error("prune: build active set", "error", err)
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect all unique image bases from projects (without tags).
|
if len(imageBases) == 0 {
|
||||||
projectImages := make(map[string]bool)
|
|
||||||
for _, p := range projects {
|
|
||||||
if p.Image != "" {
|
|
||||||
projectImages[p.Image] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(projectImages) == 0 {
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"images_removed": 0,
|
"images_removed": 0,
|
||||||
"space_reclaimed_mb": 0,
|
"space_reclaimed_mb": 0,
|
||||||
"message": "No project images to clean up",
|
"message": "No workload images to clean up",
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all local Docker images and find ones matching our projects but not actively used.
|
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
removed := 0
|
removed := 0
|
||||||
var reclaimedBytes int64
|
var reclaimedBytes int64
|
||||||
|
|
||||||
for imageBase := range projectImages {
|
for base := range imageBases {
|
||||||
// List all tags for this image.
|
images, err := s.docker.ListImagesByRef(ctx, base)
|
||||||
images, err := s.docker.ListImagesByRef(ctx, imageBase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("prune: list images", "image", imageBase, "error", err)
|
slog.Warn("prune: list images", "image", base, "error", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, img := range images {
|
for _, img := range images {
|
||||||
// Skip images that are actively used by running instances.
|
|
||||||
if activeImages[img.Ref] {
|
if activeImages[img.Ref] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove unused image.
|
|
||||||
if err := s.docker.RemoveImage(ctx, img.ID); err != nil {
|
if err := s.docker.RemoveImage(ctx, img.ID); err != nil {
|
||||||
slog.Warn("prune: remove image", "image", img.Ref, "error", err)
|
slog.Warn("prune: remove image", "image", img.Ref, "error", err)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -239,17 +239,13 @@ func (s *Server) proxyHealth(ctx context.Context) map[string]any {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// managedRouteCount returns the number of proxy routes Tinyforge manages
|
// managedRouteCount returns the number of proxy routes Tinyforge manages,
|
||||||
// (Docker instances + static sites combined). The domain argument doesn't
|
// reading from the unified containers index. The domain argument doesn't
|
||||||
// affect the count so we pass an empty string to skip FQDN rendering.
|
// affect the count so we pass an empty string to skip FQDN rendering.
|
||||||
func (s *Server) managedRouteCount() (int, error) {
|
func (s *Server) managedRouteCount() (int, error) {
|
||||||
instanceRoutes, err := s.store.ListProxyRoutes("")
|
routes, err := s.store.ListProxyRoutes("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
siteRoutes, err := s.store.ListStaticSiteProxyRoutes("")
|
return len(routes), nil
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
return len(instanceRoutes) + len(siteRoutes), nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,293 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
|
||||||
)
|
|
||||||
|
|
||||||
// listInstances handles GET /api/projects/{id}/stages/{stage}/instances.
|
|
||||||
// Reads the normalized container index — the legacy `instances` table is gone.
|
|
||||||
// JSON shape stays Container-shaped (id, container_id, image_tag, subdomain,
|
|
||||||
// state, port, etc.), so the frontend type may show some renamed fields
|
|
||||||
// (status→state, last_alive_at→last_seen_at).
|
|
||||||
func (s *Server) listInstances(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
|
|
||||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, err := s.store.ListContainersByStageID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list containers", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconcile container state with Docker's actual state — covers the
|
|
||||||
// case where a container was killed externally between deployer writes
|
|
||||||
// and the next reconciler tick.
|
|
||||||
ctx := r.Context()
|
|
||||||
for i, c := range containers {
|
|
||||||
if c.ContainerID == "" || c.State == "removing" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
running, err := s.docker.IsContainerRunning(ctx, c.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
actual := "stopped"
|
|
||||||
if running {
|
|
||||||
actual = "running"
|
|
||||||
}
|
|
||||||
if c.State != actual {
|
|
||||||
containers[i].State = actual
|
|
||||||
_ = s.store.UpdateContainerState(c.ID, actual)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, containers)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deployRequest is the expected JSON body for triggering a deploy.
|
|
||||||
type deployRequest struct {
|
|
||||||
ImageTag string `json:"image_tag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// deployInstance handles POST /api/projects/{id}/stages/{stage}/instances.
|
|
||||||
func (s *Server) deployInstance(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
|
|
||||||
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req deployRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.ImageTag == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "image_tag is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
deployID, err := s.deployer.AsyncTriggerDeploy(r.Context(), projectID, stageID, req.ImageTag)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to trigger deploy", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusAccepted, map[string]string{
|
|
||||||
"status": "deploying",
|
|
||||||
"deploy_id": deployID,
|
|
||||||
"project_id": projectID,
|
|
||||||
"stage_id": stageID,
|
|
||||||
"image_tag": req.ImageTag,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeInstance handles DELETE /api/projects/{id}/stages/{stage}/instances/{iid}.
|
|
||||||
// {iid} is the container row ID (same UUID as the legacy instance ID).
|
|
||||||
// Verifies that the container belongs to the project + stage in the URL —
|
|
||||||
// without this check, a stale URL could delete an unrelated stack/site row.
|
|
||||||
func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
|
|
||||||
c, ok := s.resolveAndAuthorizeInstance(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := c.ID
|
|
||||||
|
|
||||||
// Remove the Docker container if it has one.
|
|
||||||
if c.ContainerID != "" {
|
|
||||||
if err := s.docker.RemoveContainer(r.Context(), c.ContainerID, true); err != nil {
|
|
||||||
slog.Error("remove container", "container_id", c.ContainerID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete proxy route if it has one.
|
|
||||||
if c.ProxyRouteID != "" {
|
|
||||||
if err := s.proxyProvider.DeleteRoute(r.Context(), c.ProxyRouteID); err != nil {
|
|
||||||
slog.Warn("delete proxy route on container removal", "route_id", c.ProxyRouteID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete container row.
|
|
||||||
if err := s.store.DeleteContainer(id); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
|
||||||
}
|
|
||||||
|
|
||||||
// stopInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/stop.
|
|
||||||
func (s *Server) stopInstance(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.controlInstance(w, r, "stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// startInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/start.
|
|
||||||
func (s *Server) startInstance(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.controlInstance(w, r, "start")
|
|
||||||
}
|
|
||||||
|
|
||||||
// restartInstance handles POST /api/projects/{id}/stages/{stage}/instances/{iid}/restart.
|
|
||||||
func (s *Server) restartInstance(w http.ResponseWriter, r *http.Request) {
|
|
||||||
s.controlInstance(w, r, "restart")
|
|
||||||
}
|
|
||||||
|
|
||||||
// controlInstance performs a stop/start/restart action on a container.
|
|
||||||
// The container's ownership of the URL-provided project + stage is verified
|
|
||||||
// before any Docker call — see resolveAndAuthorizeInstance for rationale.
|
|
||||||
func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action string) {
|
|
||||||
c, ok := s.resolveAndAuthorizeInstance(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
id := c.ID
|
|
||||||
|
|
||||||
if c.ContainerID == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "container row has no docker container bound")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
var controlErr error
|
|
||||||
var newState string
|
|
||||||
|
|
||||||
switch action {
|
|
||||||
case "stop":
|
|
||||||
controlErr = s.docker.StopContainer(ctx, c.ContainerID, 10)
|
|
||||||
newState = "stopped"
|
|
||||||
case "start":
|
|
||||||
controlErr = s.docker.StartContainer(ctx, c.ContainerID)
|
|
||||||
newState = "running"
|
|
||||||
case "restart":
|
|
||||||
controlErr = s.docker.RestartContainer(ctx, c.ContainerID, 10)
|
|
||||||
newState = "running"
|
|
||||||
default:
|
|
||||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("unknown action: %s", action))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if controlErr != nil {
|
|
||||||
slog.Error("failed to control container", "action", action, "id", id, "error", controlErr)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateContainerState(id, newState); err != nil {
|
|
||||||
slog.Error("update container state", "id", id, "state", newState, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
|
||||||
"instance_id": id,
|
|
||||||
"action": action,
|
|
||||||
"status": newState,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeployTriggerer is the interface for triggering deployments. The legacy
|
|
||||||
// project/stage methods continue to drive image-tag CI promotions; the
|
|
||||||
// plugin methods (DispatchPlugin / DispatchTeardown / DispatchReconcile)
|
|
||||||
// route through the unified Source registry. Both surfaces are kept on
|
|
||||||
// one interface so the API layer holds a single deployer reference and
|
|
||||||
// the type assertion in hooks.go / workloads_plugin.go is replaced with
|
|
||||||
// compile-time checking.
|
|
||||||
type DeployTriggerer interface {
|
|
||||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
|
||||||
AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error)
|
|
||||||
|
|
||||||
DispatchPlugin(ctx context.Context, w plugin.Workload, intent plugin.DeploymentIntent) error
|
|
||||||
DispatchTeardown(ctx context.Context, w plugin.Workload) error
|
|
||||||
DispatchReconcile(ctx context.Context, w plugin.Workload) error
|
|
||||||
PluginDeps() plugin.Deps
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAndAuthorizeInstance loads the container row identified by {iid} and
|
|
||||||
// verifies it actually belongs to the project + stage in the URL path.
|
|
||||||
// Without this, a stale or hand-crafted URL like
|
|
||||||
//
|
|
||||||
// DELETE /api/projects/<projectA>/stages/<stageA>/instances/<rowOfStackB>
|
|
||||||
//
|
|
||||||
// would happily delete an unrelated stack/site container — admin-only doesn't
|
|
||||||
// excuse the cross-project bypass. Returns the container on success or
|
|
||||||
// nothing (with the response already written) on failure.
|
|
||||||
func (s *Server) resolveAndAuthorizeInstance(w http.ResponseWriter, r *http.Request) (store.Container, bool) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
stageName := ""
|
|
||||||
if stageID := chi.URLParam(r, "stage"); stageID != "" {
|
|
||||||
st, err := s.store.GetStageByID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
if st.ProjectID != projectID {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
stageName = st.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
id := chi.URLParam(r, "iid")
|
|
||||||
c, err := s.store.GetContainerByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
slog.Error("failed to get container", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
w2, err := s.store.GetWorkloadByRef(store.WorkloadKindProject, projectID)
|
|
||||||
if err != nil {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
if c.WorkloadID != w2.ID {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
if stageName != "" && c.Role != stageName {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return store.Container{}, false
|
|
||||||
}
|
|
||||||
return c, true
|
|
||||||
}
|
|
||||||
@@ -30,14 +30,12 @@ func logging(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redactPath strips secrets from URL paths that carry them in segments.
|
// redactPath strips secrets from URL paths that carry them in segments.
|
||||||
|
// Only the canonical /api/webhook/triggers/{secret} surface remains after
|
||||||
|
// the hard cutover.
|
||||||
func redactPath(path string) string {
|
func redactPath(path string) string {
|
||||||
const projectPrefix = "/api/webhook/"
|
const triggerPrefix = "/api/webhook/triggers/"
|
||||||
const sitePrefix = "/api/webhook/sites/"
|
if strings.HasPrefix(path, triggerPrefix) {
|
||||||
switch {
|
return triggerPrefix + "***"
|
||||||
case strings.HasPrefix(path, sitePrefix):
|
|
||||||
return sitePrefix + "***"
|
|
||||||
case strings.HasPrefix(path, projectPrefix):
|
|
||||||
return projectPrefix + "***"
|
|
||||||
}
|
}
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
@@ -161,24 +159,6 @@ func jsonContentType(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// deprecated marks responses with RFC-8594-style headers so API consumers
|
|
||||||
// can detect that an endpoint is on its way out. The Workload-first
|
|
||||||
// refactor is migrating away from /api/projects, /api/stages,
|
|
||||||
// /api/static_sites, and /api/stacks toward /api/workloads; this signals
|
|
||||||
// it to integrators without breaking them. Date is the operator-facing
|
|
||||||
// sunset hint, not a hard switch.
|
|
||||||
func deprecated(replacement string) func(http.Handler) http.Handler {
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Deprecation", "true")
|
|
||||||
if replacement != "" {
|
|
||||||
w.Header().Set("Link", `<`+replacement+`>; rel="successor-version"`)
|
|
||||||
}
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rateLimitMiddleware wraps a handler with per-IP rate limiting using the
|
// rateLimitMiddleware wraps a handler with per-IP rate limiting using the
|
||||||
// supplied limiter. Requests over the limit get 429.
|
// supplied limiter. Requests over the limit get 429.
|
||||||
func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
|
func rateLimitMiddleware(rl *rateLimiter) func(http.Handler) http.Handler {
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
// Outgoing-webhook signing-secret + send-test endpoints. There are four
|
// Outgoing-webhook signing-secret + send-test endpoints. After the hard
|
||||||
// tiers — settings, project, stage, site — each exposing the same three
|
// cutover only the settings tier survives at the API surface; per-workload
|
||||||
// operations: reveal (lazy-gen), regenerate, and send a synthetic test
|
// notification settings live on the workload row itself and are accessed
|
||||||
// event. Returning a 200 from "send test" doesn't mean the receiver
|
// via the workload endpoints.
|
||||||
// processed the event correctly — only that it answered with 2xx. The UI
|
|
||||||
// surfaces the receiver's status code + body preview so operators can
|
|
||||||
// distinguish "wired" from "wired and accepted".
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/notify"
|
"github.com/alexei/tinyforge/internal/notify"
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// notificationSecretResponse is what the GET / regenerate endpoints return.
|
// notificationSecretResponse is what the GET / regenerate endpoints return.
|
||||||
@@ -92,8 +85,7 @@ func (s *Server) disableSettingsNotificationSigning(w http.ResponseWriter, r *ht
|
|||||||
|
|
||||||
// settingsNotificationTest handles POST /api/settings/notification-test.
|
// settingsNotificationTest handles POST /api/settings/notification-test.
|
||||||
// Sends a synthetic test event to the global webhook URL using the global
|
// Sends a synthetic test event to the global webhook URL using the global
|
||||||
// secret. No tier resolution — that's the whole point: each tier's test
|
// secret.
|
||||||
// button proves *that* tier is wired correctly.
|
|
||||||
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request) {
|
||||||
settings, err := s.store.GetSettings()
|
settings, err := s.store.GetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -112,292 +104,3 @@ func (s *Server) settingsNotificationTest(w http.ResponseWriter, r *http.Request
|
|||||||
)
|
)
|
||||||
respondJSON(w, http.StatusOK, result)
|
respondJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Project tier
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *Server) getProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
secret, err := s.store.EnsureProjectNotificationSecret(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("get project notification secret", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) regenerateProjectNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetProjectNotificationSecret(id, secret); err != nil {
|
|
||||||
slog.Error("regenerate project notification secret", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("project notification secret rotated", "project", id)
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) disableProjectNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if err := s.store.SetProjectNotificationSecret(id, ""); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) projectNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
project, err := s.store.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := s.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url, secret, tier := resolveProjectTestTarget(project, settings)
|
|
||||||
if url == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "no notification URL configured for this project (and no global fallback)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
||||||
defer cancel()
|
|
||||||
result := s.notifier.SendSyncForTest(
|
|
||||||
ctx, url, secret, tier,
|
|
||||||
buildTestEvent(project.Name, ""),
|
|
||||||
)
|
|
||||||
respondJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveProjectTestTarget mirrors the deploy-time stage→project→global
|
|
||||||
// resolution but without a stage in scope. Used by the project-level test
|
|
||||||
// button so the operator sees exactly what a project-only event would do.
|
|
||||||
func resolveProjectTestTarget(project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
|
||||||
if project.NotificationURL != "" {
|
|
||||||
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
|
||||||
}
|
|
||||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Stage tier
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *Server) getStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
secret, err := s.store.EnsureStageNotificationSecret(stageID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("get stage notification secret", "stage", stageID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) regenerateStageNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetStageNotificationSecret(stageID, secret); err != nil {
|
|
||||||
slog.Error("regenerate stage notification secret", "stage", stageID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("stage notification secret rotated", "stage", stageID)
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) disableStageNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
if err := s.store.SetStageNotificationSecret(stageID, ""); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) stageNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
stage, err := s.store.GetStageByID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
project, err := s.store.GetProjectByID(projectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := s.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Reuse the production resolver so the test button exercises the exact
|
|
||||||
// fall-through logic a real deploy would.
|
|
||||||
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
|
||||||
if url == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "no notification URL configured for this stage, project, or globally")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
||||||
defer cancel()
|
|
||||||
result := s.notifier.SendSyncForTest(
|
|
||||||
ctx, url, secret, tier,
|
|
||||||
buildTestEvent(project.Name, stage.Name),
|
|
||||||
)
|
|
||||||
respondJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveDeployTarget here mirrors the deployer's helper. Duplicated rather
|
|
||||||
// than imported to avoid an api → deployer dependency cycle and to keep the
|
|
||||||
// test-endpoint code self-contained. If divergence becomes a risk we can
|
|
||||||
// move this into a shared internal/notify subpackage.
|
|
||||||
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
|
||||||
if stage.NotificationURL != "" {
|
|
||||||
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
|
|
||||||
}
|
|
||||||
if project.NotificationURL != "" {
|
|
||||||
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
|
||||||
}
|
|
||||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Static-site tier
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (s *Server) getStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
secret, err := s.store.EnsureStaticSiteNotificationSecret(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("get static site notification secret", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: secret != ""})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) regenerateStaticSiteNotificationSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetStaticSiteNotificationSecret(id, secret); err != nil {
|
|
||||||
slog.Error("regenerate static site notification secret", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("static site notification secret rotated", "site", id)
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: secret, HasSecret: true})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) disableStaticSiteNotificationSigning(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if err := s.store.SetStaticSiteNotificationSecret(id, ""); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to disable signing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, notificationSecretResponse{Secret: "", HasSecret: false})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) staticSiteNotificationTest(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := s.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to load settings")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url, secret, tier := resolveSiteTestTarget(site, settings)
|
|
||||||
if url == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "no notification URL configured for this site (and no global fallback)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), testEventTimeout)
|
|
||||||
defer cancel()
|
|
||||||
result := s.notifier.SendSyncForTest(
|
|
||||||
ctx, url, secret, tier,
|
|
||||||
buildTestEvent(site.Name, ""),
|
|
||||||
)
|
|
||||||
respondJSON(w, http.StatusOK, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSiteTestTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
|
|
||||||
if site.NotificationURL != "" {
|
|
||||||
return site.NotificationURL, site.NotificationSecret, notify.TierSite
|
|
||||||
}
|
|
||||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,225 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/events"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// projectRequest is the expected JSON body for creating/updating a project.
|
|
||||||
type projectRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Registry string `json:"registry"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
Healthcheck string `json:"healthcheck"`
|
|
||||||
Env string `json:"env"`
|
|
||||||
Volumes string `json:"volumes"`
|
|
||||||
NpmAccessListID *int `json:"npm_access_list_id,omitempty"`
|
|
||||||
NotificationURL *string `json:"notification_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// listProjects handles GET /api/projects.
|
|
||||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projects, err := s.store.GetAllProjects()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list projects", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, projects)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createProject handles POST /api/projects.
|
|
||||||
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req projectRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Image == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "image is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.Env == "" {
|
|
||||||
req.Env = "{}"
|
|
||||||
}
|
|
||||||
if req.Volumes == "" {
|
|
||||||
req.Volumes = "{}"
|
|
||||||
}
|
|
||||||
|
|
||||||
npmAccessListID := 0
|
|
||||||
if req.NpmAccessListID != nil {
|
|
||||||
npmAccessListID = *req.NpmAccessListID
|
|
||||||
}
|
|
||||||
|
|
||||||
project, err := s.store.CreateProject(store.Project{
|
|
||||||
Name: req.Name,
|
|
||||||
Registry: req.Registry,
|
|
||||||
Image: req.Image,
|
|
||||||
Port: req.Port,
|
|
||||||
Healthcheck: req.Healthcheck,
|
|
||||||
Env: req.Env,
|
|
||||||
Volumes: req.Volumes,
|
|
||||||
NpmAccessListID: npmAccessListID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
Source: "admin",
|
|
||||||
Severity: "info",
|
|
||||||
Message: "project created: " + project.Name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, project)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProject handles GET /api/projects/{id}.
|
|
||||||
func (s *Server) getProject(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
project, err := s.store.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also fetch stages for this project.
|
|
||||||
stages, err := s.store.GetStagesByProjectID(id)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get stages", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"project": project,
|
|
||||||
"stages": stages,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateProject handles PUT /api/projects/{id}.
|
|
||||||
func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
existing, err := s.store.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req projectRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply updates to existing project, preserving fields not provided.
|
|
||||||
updated := existing
|
|
||||||
if req.Name != "" {
|
|
||||||
updated.Name = req.Name
|
|
||||||
}
|
|
||||||
if req.Image != "" {
|
|
||||||
updated.Image = req.Image
|
|
||||||
}
|
|
||||||
updated.Registry = req.Registry
|
|
||||||
updated.Port = req.Port
|
|
||||||
updated.Healthcheck = req.Healthcheck
|
|
||||||
if req.Env != "" {
|
|
||||||
updated.Env = req.Env
|
|
||||||
}
|
|
||||||
if req.Volumes != "" {
|
|
||||||
updated.Volumes = req.Volumes
|
|
||||||
}
|
|
||||||
if req.NpmAccessListID != nil {
|
|
||||||
updated.NpmAccessListID = *req.NpmAccessListID
|
|
||||||
}
|
|
||||||
if req.NotificationURL != nil {
|
|
||||||
updated.NotificationURL = *req.NotificationURL
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateProject(updated); err != nil {
|
|
||||||
slog.Error("failed to update project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
s.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
Source: "admin",
|
|
||||||
Severity: "info",
|
|
||||||
Message: "project updated: " + updated.Name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteProject handles DELETE /api/projects/{id}.
|
|
||||||
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
// Clean up Docker containers and proxy routes before deleting the project.
|
|
||||||
ctx := r.Context()
|
|
||||||
stages, _ := s.store.GetStagesByProjectID(id)
|
|
||||||
for _, stage := range stages {
|
|
||||||
rows, _ := s.store.ListContainersByStageID(stage.ID)
|
|
||||||
for _, c := range rows {
|
|
||||||
if c.ContainerID != "" {
|
|
||||||
if err := s.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
|
|
||||||
slog.Warn("delete project: remove container", "container", c.ContainerID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if c.ProxyRouteID != "" {
|
|
||||||
if err := s.proxyProvider.DeleteRoute(ctx, c.ProxyRouteID); err != nil {
|
|
||||||
slog.Warn("delete project: delete proxy route", "route", c.ProxyRouteID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.DeleteProject(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to delete project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
Source: "admin",
|
|
||||||
Severity: "info",
|
|
||||||
Message: "project deleted: " + id,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
|
||||||
}
|
|
||||||
+5
-13
@@ -6,9 +6,9 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
)
|
)
|
||||||
|
|
||||||
// listProxyRoutes handles GET /api/proxies.
|
// listProxyRoutes handles GET /api/proxies. Returns proxy routes derived
|
||||||
// Returns proxy routes from both Docker instances and static sites,
|
// from the containers index — the legacy static-site / project split is
|
||||||
// merged and sorted by domain.
|
// gone; any workload whose container carries a proxy route ID is listed.
|
||||||
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
|
||||||
settings, err := s.store.GetSettings()
|
settings, err := s.store.GetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -17,21 +17,13 @@ func (s *Server) listProxyRoutes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
instanceRoutes, err := s.store.ListProxyRoutes(settings.Domain)
|
routes, err := s.store.ListProxyRoutes(settings.Domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to list instance proxy routes", "error", err)
|
slog.Error("failed to list proxy routes", "error", err)
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
siteRoutes, err := s.store.ListStaticSiteProxyRoutes(settings.Domain)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list static site proxy routes", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
routes := append(instanceRoutes, siteRoutes...)
|
|
||||||
sort.SliceStable(routes, func(i, j int) bool {
|
sort.SliceStable(routes, func(i, j int) bool {
|
||||||
if routes[i].Domain == routes[j].Domain {
|
if routes[i].Domain == routes[j].Domain {
|
||||||
return routes[i].ProjectName < routes[j].ProjectName
|
return routes[i].ProjectName < routes[j].ProjectName
|
||||||
|
|||||||
+38
-209
@@ -16,43 +16,49 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/notify"
|
"github.com/alexei/tinyforge/internal/notify"
|
||||||
"github.com/alexei/tinyforge/internal/npm"
|
"github.com/alexei/tinyforge/internal/npm"
|
||||||
"github.com/alexei/tinyforge/internal/proxy"
|
"github.com/alexei/tinyforge/internal/proxy"
|
||||||
"github.com/alexei/tinyforge/internal/stack"
|
|
||||||
"github.com/alexei/tinyforge/internal/stale"
|
"github.com/alexei/tinyforge/internal/stale"
|
||||||
"github.com/alexei/tinyforge/internal/staticsite"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
"github.com/alexei/tinyforge/internal/webhook"
|
"github.com/alexei/tinyforge/internal/webhook"
|
||||||
|
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
// DNSProviderChangedFunc is called when DNS settings change so the caller can
|
||||||
// update the provider on the deployer.
|
// update the provider on the deployer.
|
||||||
type DNSProviderChangedFunc func(provider dns.Provider)
|
type DNSProviderChangedFunc func(provider dns.Provider)
|
||||||
|
|
||||||
|
// PluginDispatcher is the subset of the deployer the API layer uses for the
|
||||||
|
// plugin-native dispatch surface (generic-hooks endpoint + workload teardown
|
||||||
|
// + future surfaces). Defined here so the API does not import the deployer
|
||||||
|
// package directly.
|
||||||
|
type PluginDispatcher interface {
|
||||||
|
webhook.PluginDispatcher
|
||||||
|
DispatchTeardown(ctx context.Context, w plugin.Workload) error
|
||||||
|
}
|
||||||
|
|
||||||
// Server holds all dependencies for the API layer.
|
// Server holds all dependencies for the API layer.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
docker *docker.Client
|
docker *docker.Client
|
||||||
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
|
npm *npm.Client // optional: only for NPM-specific endpoints (certificates)
|
||||||
proxyProvider proxy.Provider
|
proxyProvider proxy.Provider
|
||||||
deployer DeployTriggerer
|
deployer PluginDispatcher
|
||||||
notifier *notify.Notifier
|
notifier *notify.Notifier
|
||||||
webhook *webhook.Handler
|
webhook *webhook.Handler
|
||||||
eventBus *events.Bus
|
eventBus *events.Bus
|
||||||
encKey [32]byte
|
encKey [32]byte
|
||||||
localAuth *auth.LocalAuth
|
localAuth *auth.LocalAuth
|
||||||
oidcProvider *auth.OIDCProvider
|
oidcProvider *auth.OIDCProvider
|
||||||
staleScanner *stale.Scanner
|
staleScanner *stale.Scanner
|
||||||
|
|
||||||
dnsProviderMu sync.RWMutex
|
dnsProviderMu sync.RWMutex
|
||||||
dnsProvider dns.Provider
|
dnsProvider dns.Provider
|
||||||
onDNSProviderChanged DNSProviderChangedFunc
|
onDNSProviderChanged DNSProviderChangedFunc
|
||||||
|
|
||||||
staticSiteManager *staticsite.Manager
|
backupEngine *backup.Engine
|
||||||
stackManager *stack.Manager
|
sseGate *sseGate
|
||||||
backupEngine *backup.Engine
|
logScanReloader LogScanReloader
|
||||||
sseGate *sseGate
|
dbPath string
|
||||||
logScanReloader LogScanReloader
|
shutdownFunc func() // called after restore to trigger graceful shutdown
|
||||||
dbPath string
|
|
||||||
shutdownFunc func() // called after restore to trigger graceful shutdown
|
|
||||||
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change
|
onBackupSettingsChanged func(enabled bool, intervalHours int) // called when backup settings change
|
||||||
onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes
|
onProxyProviderChanged func(provider proxy.Provider) // called when proxy provider changes
|
||||||
}
|
}
|
||||||
@@ -63,7 +69,7 @@ func NewServer(
|
|||||||
dockerClient *docker.Client,
|
dockerClient *docker.Client,
|
||||||
npmClient *npm.Client,
|
npmClient *npm.Client,
|
||||||
proxyProvider proxy.Provider,
|
proxyProvider proxy.Provider,
|
||||||
deployer DeployTriggerer,
|
deployer PluginDispatcher,
|
||||||
notifier *notify.Notifier,
|
notifier *notify.Notifier,
|
||||||
webhookHandler *webhook.Handler,
|
webhookHandler *webhook.Handler,
|
||||||
eventBus *events.Bus,
|
eventBus *events.Bus,
|
||||||
@@ -94,16 +100,6 @@ func NewServer(
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetStaticSiteManager sets the static site manager on the server.
|
|
||||||
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.
|
// SetStaleScanner sets the stale scanner on the server.
|
||||||
// Called after both the API server and scanner are initialized.
|
// Called after both the API server and scanner are initialized.
|
||||||
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
func (s *Server) SetStaleScanner(scanner *stale.Scanner) {
|
||||||
@@ -218,12 +214,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.Middleware(s.localAuth))
|
r.Use(auth.Middleware(s.localAuth))
|
||||||
|
|
||||||
// Plugin registry inspection + unified ingress (Workload refactor).
|
// Plugin registry inspection + unified ingress.
|
||||||
// /hooks/kinds is informational and visible to any authenticated
|
|
||||||
// caller. /hooks/generic dispatches deploys and is admin-gated —
|
|
||||||
// vendor-specific webhooks (with their own per-target HMAC
|
|
||||||
// secrets) live under /webhook/* and remain the only ingress
|
|
||||||
// reachable by external CI systems until Phase 5 consolidates them.
|
|
||||||
r.Get("/hooks/kinds", s.listHookKinds)
|
r.Get("/hooks/kinds", s.listHookKinds)
|
||||||
r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema)
|
r.Get("/hooks/kinds/{kind}/schema", s.getHookKindSchema)
|
||||||
r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric)
|
r.With(auth.AdminOnly).Post("/hooks/generic", s.dispatchGeneric)
|
||||||
@@ -234,134 +225,6 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Post("/auth/logout", s.logout)
|
r.Post("/auth/logout", s.logout)
|
||||||
r.Get("/proxies", s.listProxyRoutes)
|
r.Get("/proxies", s.listProxyRoutes)
|
||||||
r.Get("/docker/unused-images", s.unusedImageStats)
|
r.Get("/docker/unused-images", s.unusedImageStats)
|
||||||
// Legacy project/stage/site/stack endpoints carry a Deprecation
|
|
||||||
// header pointing at /api/workloads. Functional behavior is
|
|
||||||
// unchanged until the hard cutover removes them.
|
|
||||||
r.With(deprecated("/api/workloads")).Get("/projects", s.listProjects)
|
|
||||||
r.Route("/projects/{id}", func(r chi.Router) {
|
|
||||||
r.Get("/", s.getProject)
|
|
||||||
r.Get("/stages/{stage}/env", s.listStageEnv)
|
|
||||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
|
||||||
r.Get("/stages/{stage}/instances/{iid}/stats", s.getInstanceStats)
|
|
||||||
r.Get("/stages/{stage}/instances/{iid}/stats/history", s.getInstanceStatsHistory)
|
|
||||||
r.Get("/stages/{stage}/instances/{iid}/logs", s.streamContainerLogs)
|
|
||||||
r.Get("/images", s.listProjectImages)
|
|
||||||
r.Get("/volumes", s.listVolumes)
|
|
||||||
r.Get("/volumes/{volId}/browse", s.browseVolume)
|
|
||||||
r.Get("/volumes/{volId}/download", s.downloadVolume)
|
|
||||||
|
|
||||||
// Admin-only project mutations.
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(auth.AdminOnly)
|
|
||||||
r.Put("/", s.updateProject)
|
|
||||||
r.Delete("/", s.deleteProject)
|
|
||||||
|
|
||||||
// Per-project webhook URL management.
|
|
||||||
r.Get("/webhook", s.getProjectWebhook)
|
|
||||||
r.Post("/webhook/regenerate", s.regenerateProjectWebhook)
|
|
||||||
// Inbound HMAC signing — secret rotation + enforcement toggle.
|
|
||||||
r.Post("/webhook/signing-secret/regenerate", s.regenerateProjectSigningSecret)
|
|
||||||
r.Delete("/webhook/signing-secret", s.disableProjectSigningSecret)
|
|
||||||
r.Put("/webhook/require-signature", s.updateProjectSigningRequirement)
|
|
||||||
r.Get("/webhook/deliveries", s.listProjectWebhookDeliveries)
|
|
||||||
|
|
||||||
// Per-project outgoing-webhook signing & test.
|
|
||||||
r.Get("/notification-secret", s.getProjectNotificationSecret)
|
|
||||||
r.Post("/notification-secret/regenerate", s.regenerateProjectNotificationSecret)
|
|
||||||
r.Post("/notification-secret/disable", s.disableProjectNotificationSigning)
|
|
||||||
r.Post("/notification-test", s.projectNotificationTest)
|
|
||||||
|
|
||||||
// Stage endpoints.
|
|
||||||
r.Post("/stages", s.createStage)
|
|
||||||
r.Put("/stages/{stage}", s.updateStage)
|
|
||||||
r.Delete("/stages/{stage}", s.deleteStage)
|
|
||||||
|
|
||||||
// Per-stage outgoing-webhook signing & test.
|
|
||||||
r.Get("/stages/{stage}/notification-secret", s.getStageNotificationSecret)
|
|
||||||
r.Post("/stages/{stage}/notification-secret/regenerate", s.regenerateStageNotificationSecret)
|
|
||||||
r.Post("/stages/{stage}/notification-secret/disable", s.disableStageNotificationSigning)
|
|
||||||
r.Post("/stages/{stage}/notification-test", s.stageNotificationTest)
|
|
||||||
|
|
||||||
// Stage env override endpoints.
|
|
||||||
r.Post("/stages/{stage}/env", s.createStageEnv)
|
|
||||||
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
|
|
||||||
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
|
|
||||||
|
|
||||||
// Instance endpoints.
|
|
||||||
r.Post("/stages/{stage}/instances", s.deployInstance)
|
|
||||||
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
|
|
||||||
|
|
||||||
// Instance control endpoints.
|
|
||||||
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
|
|
||||||
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
|
|
||||||
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
|
|
||||||
|
|
||||||
// Volume endpoints.
|
|
||||||
r.Post("/volumes", s.createVolume)
|
|
||||||
r.Put("/volumes/{volId}", s.updateVolume)
|
|
||||||
r.Delete("/volumes/{volId}", s.deleteVolume)
|
|
||||||
r.Post("/volumes/{volId}/upload", s.uploadToVolume)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
// Stacks (docker-compose).
|
|
||||||
r.With(deprecated("/api/workloads?kind=plugin&source_kind=compose")).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.With(deprecated("/api/workloads?kind=plugin&source_kind=static")).Get("/sites", s.listStaticSites)
|
|
||||||
r.Route("/sites/{id}", func(r chi.Router) {
|
|
||||||
r.Get("/", s.getStaticSite)
|
|
||||||
r.Get("/secrets", s.listStaticSiteSecrets)
|
|
||||||
r.Get("/storage", s.getStaticSiteStorage)
|
|
||||||
r.Get("/logs", s.streamStaticSiteLogs)
|
|
||||||
r.Get("/stats", s.getStaticSiteStats)
|
|
||||||
r.Get("/stats/history", s.getStaticSiteStatsHistory)
|
|
||||||
|
|
||||||
// Admin-only mutations.
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
r.Use(auth.AdminOnly)
|
|
||||||
r.Put("/", s.updateStaticSite)
|
|
||||||
r.Delete("/", s.deleteStaticSite)
|
|
||||||
r.Post("/deploy", s.deployStaticSite)
|
|
||||||
r.Post("/stop", s.stopStaticSite)
|
|
||||||
r.Post("/start", s.startStaticSite)
|
|
||||||
r.Get("/webhook", s.getStaticSiteWebhook)
|
|
||||||
r.Post("/webhook/regenerate", s.regenerateStaticSiteWebhook)
|
|
||||||
r.Post("/webhook/signing-secret/regenerate", s.regenerateStaticSiteSigningSecret)
|
|
||||||
r.Delete("/webhook/signing-secret", s.disableStaticSiteSigningSecret)
|
|
||||||
r.Put("/webhook/require-signature", s.updateStaticSiteSigningRequirement)
|
|
||||||
r.Get("/webhook/deliveries", s.listStaticSiteWebhookDeliveries)
|
|
||||||
|
|
||||||
// Per-site outgoing-webhook signing & test.
|
|
||||||
r.Get("/notification-secret", s.getStaticSiteNotificationSecret)
|
|
||||||
r.Post("/notification-secret/regenerate", s.regenerateStaticSiteNotificationSecret)
|
|
||||||
r.Post("/notification-secret/disable", s.disableStaticSiteNotificationSigning)
|
|
||||||
r.Post("/notification-test", s.staticSiteNotificationTest)
|
|
||||||
r.Post("/secrets", s.createStaticSiteSecret)
|
|
||||||
r.Put("/secrets/{sid}", s.updateStaticSiteSecret)
|
|
||||||
r.Delete("/secrets/{sid}", s.deleteStaticSiteSecret)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
r.Get("/deploys", s.listDeploys)
|
|
||||||
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
|
|
||||||
r.Get("/events", s.streamEvents)
|
r.Get("/events", s.streamEvents)
|
||||||
r.Get("/events/log", s.listEventLog)
|
r.Get("/events/log", s.listEventLog)
|
||||||
r.Get("/events/log/stats", s.getEventLogStats)
|
r.Get("/events/log/stats", s.getEventLogStats)
|
||||||
@@ -388,12 +251,9 @@ func (s *Server) Router() chi.Router {
|
|||||||
// Stale container endpoints (read).
|
// Stale container endpoints (read).
|
||||||
r.Get("/containers/stale", s.listStaleContainers)
|
r.Get("/containers/stale", s.listStaleContainers)
|
||||||
|
|
||||||
// Workload-shaped endpoints (the unifying layer over project /
|
// Workload-shaped endpoints — the canonical surface after the
|
||||||
// stack / site). Read endpoints are open to any authenticated
|
// hard cutover. Reads open to any authenticated user; mutations
|
||||||
// user; create / update / deploy mutate state and are admin-gated.
|
// admin-gated.
|
||||||
// Plugin-native workloads (source_kind + trigger_kind set) are
|
|
||||||
// created here; legacy project / stack / site mutations remain at
|
|
||||||
// their dedicated endpoints during the cutover.
|
|
||||||
r.Get("/workloads", s.listWorkloads)
|
r.Get("/workloads", s.listWorkloads)
|
||||||
r.With(auth.AdminOnly).Post("/workloads", s.createPluginWorkload)
|
r.With(auth.AdminOnly).Post("/workloads", s.createPluginWorkload)
|
||||||
r.Route("/workloads/{id}", func(r chi.Router) {
|
r.Route("/workloads/{id}", func(r chi.Router) {
|
||||||
@@ -405,22 +265,17 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload)
|
r.With(auth.AdminOnly).Post("/deploy", s.deployPluginWorkload)
|
||||||
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
|
r.With(auth.AdminOnly).Delete("/", s.deletePluginWorkload)
|
||||||
|
|
||||||
// Per-workload env vars (analog of legacy stage_env).
|
// Per-workload env vars. Listing open to authenticated readers;
|
||||||
// Listing is open to authenticated readers; mutations are
|
// mutations admin-gated. Encrypted values are write-only after store.
|
||||||
// admin-gated. Encrypted values are write-only after store.
|
|
||||||
r.Get("/env", s.listWorkloadEnv)
|
r.Get("/env", s.listWorkloadEnv)
|
||||||
r.With(auth.AdminOnly).Put("/env", s.setWorkloadEnv)
|
r.With(auth.AdminOnly).Put("/env", s.setWorkloadEnv)
|
||||||
r.With(auth.AdminOnly).Delete("/env/{envID}", s.deleteWorkloadEnv)
|
r.With(auth.AdminOnly).Delete("/env/{envID}", s.deleteWorkloadEnv)
|
||||||
|
|
||||||
// Per-workload inbound webhook URL: rotate the secret + fetch
|
// Per-workload inbound webhook URL handlers were dropped in
|
||||||
// the canonical URL. Mirrors the project / site webhook UX.
|
// the hard legacy cutover; inbound webhooks are now first-
|
||||||
r.With(auth.AdminOnly).Get("/webhook", s.getWorkloadWebhook)
|
// class Triggers reachable via /api/triggers/{id}/webhook.
|
||||||
r.With(auth.AdminOnly).Post("/webhook/regenerate", s.regenerateWorkloadWebhook)
|
|
||||||
|
|
||||||
// Per-workload volume mounts (analog of legacy project volumes).
|
// Per-workload volume mounts.
|
||||||
// Reads are open to authenticated users; mutations admin-gated.
|
|
||||||
// Source/target paths are validated for traversal safety here;
|
|
||||||
// host-path allow-listing happens at deploy time.
|
|
||||||
r.Get("/volumes", s.listWorkloadVolumes)
|
r.Get("/volumes", s.listWorkloadVolumes)
|
||||||
r.With(auth.AdminOnly).Put("/volumes", s.setWorkloadVolume)
|
r.With(auth.AdminOnly).Put("/volumes", s.setWorkloadVolume)
|
||||||
r.With(auth.AdminOnly).Delete("/volumes/{volID}", s.deleteWorkloadVolume)
|
r.With(auth.AdminOnly).Delete("/volumes/{volID}", s.deleteWorkloadVolume)
|
||||||
@@ -432,8 +287,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
|
r.With(auth.AdminOnly).Post("/promote-from/{sourceID}", s.promoteFromWorkload)
|
||||||
|
|
||||||
// Trigger bindings on this workload — the symmetric view
|
// Trigger bindings on this workload — the symmetric view
|
||||||
// of /triggers/{id}/bindings keyed on the workload side
|
// of /triggers/{id}/bindings keyed on the workload side.
|
||||||
// so the workload detail page is one round-trip.
|
|
||||||
r.Get("/triggers", s.listBindingsForWorkload)
|
r.Get("/triggers", s.listBindingsForWorkload)
|
||||||
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
|
r.With(auth.AdminOnly).Post("/triggers", s.bindTriggerToWorkload)
|
||||||
})
|
})
|
||||||
@@ -453,10 +307,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// First-class Triggers (redeploy signal sources). One trigger
|
// First-class Triggers (redeploy signal sources). One trigger
|
||||||
// (registry / git / webhook / manual / schedule / log_scan)
|
|
||||||
// fans out to many workloads via workload_trigger_bindings.
|
// fans out to many workloads via workload_trigger_bindings.
|
||||||
// Reads are open to authenticated users; mutations + secret
|
|
||||||
// rotation are admin-gated.
|
|
||||||
r.Get("/triggers", s.listTriggers)
|
r.Get("/triggers", s.listTriggers)
|
||||||
r.Get("/triggers/{id}", s.getTrigger)
|
r.Get("/triggers/{id}", s.getTrigger)
|
||||||
r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger)
|
r.Get("/triggers/{id}/bindings", s.listBindingsForTrigger)
|
||||||
@@ -472,10 +323,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Delete("/bindings/{bid}", s.deleteBinding)
|
r.Delete("/bindings/{bid}", s.deleteBinding)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Event triggers: filter+action rules over the event_log
|
// Event triggers: filter+action rules over the event_log stream.
|
||||||
// stream. Read endpoints are available to any authenticated
|
|
||||||
// user; mutations + test-dispatch are admin-gated since they
|
|
||||||
// can fire arbitrary outbound webhooks.
|
|
||||||
r.Get("/event-triggers", s.listEventTriggers)
|
r.Get("/event-triggers", s.listEventTriggers)
|
||||||
r.Get("/event-triggers/{id}", s.getEventTrigger)
|
r.Get("/event-triggers/{id}", s.getEventTrigger)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
@@ -486,11 +334,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Post("/event-triggers/{id}/test", s.testEventTrigger)
|
r.Post("/event-triggers/{id}/test", s.testEventTrigger)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log-scan rules: regex patterns the scanner manager
|
// Log-scan rules.
|
||||||
// applies to container log lines. Read endpoints are
|
|
||||||
// available to any authenticated user; mutations are
|
|
||||||
// admin-gated since they can change global observability
|
|
||||||
// behavior across every workload.
|
|
||||||
r.Get("/log-scan-rules", s.listLogScanRules)
|
r.Get("/log-scan-rules", s.listLogScanRules)
|
||||||
r.Get("/log-scan-rules/stats", s.getLogScanStats)
|
r.Get("/log-scan-rules/stats", s.getLogScanStats)
|
||||||
r.Get("/log-scan-rules/{id}", s.getLogScanRule)
|
r.Get("/log-scan-rules/{id}", s.getLogScanRule)
|
||||||
@@ -512,7 +356,7 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.AdminOnly)
|
r.Use(auth.AdminOnly)
|
||||||
|
|
||||||
// Config export (reveals project/infra details).
|
// Config export (reveals registry/global details).
|
||||||
r.Get("/config/export", s.exportConfig)
|
r.Get("/config/export", s.exportConfig)
|
||||||
|
|
||||||
// Event log management.
|
// Event log management.
|
||||||
@@ -528,21 +372,6 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Put("/auth/users/{uid}/password", s.changePassword)
|
r.Put("/auth/users/{uid}/password", s.changePassword)
|
||||||
r.Delete("/auth/users/{uid}", s.deleteUser)
|
r.Delete("/auth/users/{uid}", s.deleteUser)
|
||||||
|
|
||||||
// Project creation.
|
|
||||||
r.Post("/projects", s.createProject)
|
|
||||||
|
|
||||||
// Static site creation and tools.
|
|
||||||
r.Post("/sites", s.createStaticSite)
|
|
||||||
r.Post("/sites/test-connection", s.testStaticSiteConnection)
|
|
||||||
r.Post("/sites/branches", s.listStaticSiteBranches)
|
|
||||||
r.Post("/sites/tree", s.listStaticSiteTree)
|
|
||||||
r.Post("/sites/detect-provider", s.detectStaticSiteProvider)
|
|
||||||
r.Post("/sites/repos", s.listStaticSiteRepos)
|
|
||||||
|
|
||||||
// Quick deploy endpoints.
|
|
||||||
r.Post("/deploy/inspect", s.inspectImage)
|
|
||||||
r.Post("/deploy/quick", s.quickDeploy)
|
|
||||||
|
|
||||||
// Registry creation.
|
// Registry creation.
|
||||||
r.Post("/registries", s.createRegistry)
|
r.Post("/registries", s.createRegistry)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateWebhookSecret is a one-line bridge to store.GenerateWebhookSecret
|
||||||
|
// so the api handlers and the store CRUD share one secret-generation
|
||||||
|
// path — no panic-vs-UUID-fallback divergence.
|
||||||
|
func generateWebhookSecret() string { return store.GenerateWebhookSecret() }
|
||||||
|
|
||||||
|
// webhookURLResponse is the common payload returned by every webhook
|
||||||
|
// endpoint. Clients never see raw secrets except at issue/rotate time via
|
||||||
|
// these fields; the URL shape is "/api/webhook/..." so callers can prepend
|
||||||
|
// their own origin.
|
||||||
|
type webhookURLResponse struct {
|
||||||
|
WebhookURL string `json:"webhook_url"`
|
||||||
|
WebhookSecret string `json:"webhook_secret"`
|
||||||
|
HasSigningSecret bool `json:"has_signing_secret"`
|
||||||
|
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// signingSecretResponse is returned when a signing secret is issued or rotated.
|
||||||
|
type signingSecretResponse struct {
|
||||||
|
SigningSecret string `json:"signing_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLimit clamps a query-string limit to [1, max], falling back to def.
|
||||||
|
func parseLimit(raw string, def, max int) int {
|
||||||
|
if raw == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if n > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -2,147 +2,14 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/events"
|
"github.com/alexei/tinyforge/internal/events"
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// streamDeployLogs handles GET /api/deploys/{id}/logs.
|
|
||||||
// It supports both SSE streaming and JSON fallback based on the Accept header.
|
|
||||||
//
|
|
||||||
// SSE mode (Accept: text/event-stream):
|
|
||||||
//
|
|
||||||
// Streams deploy log events in real-time. Existing logs are sent first,
|
|
||||||
// then new logs are pushed as they arrive via the event bus.
|
|
||||||
//
|
|
||||||
// JSON mode (default):
|
|
||||||
//
|
|
||||||
// Returns all existing deploy logs as a JSON array.
|
|
||||||
func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
deployID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
// Verify deploy exists.
|
|
||||||
deploy, err := s.store.GetDeployByID(deployID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "deploy")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get deploy", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON fallback: return existing logs as array.
|
|
||||||
accept := r.Header.Get("Accept")
|
|
||||||
if !strings.Contains(accept, "text/event-stream") {
|
|
||||||
logs, err := s.store.GetDeployLogs(deployID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get deploy logs", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, logs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// SSE mode.
|
|
||||||
release, ok := acquireSSESlot(w, s.sseGate)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer release()
|
|
||||||
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
respondError(w, http.StatusInternalServerError, "streaming not supported")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "text/event-stream")
|
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
|
||||||
w.Header().Set("Connection", "keep-alive")
|
|
||||||
w.Header().Set("X-Accel-Buffering", "no")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
flusher.Flush()
|
|
||||||
|
|
||||||
// Send existing logs first.
|
|
||||||
existingLogs, err := s.store.GetDeployLogs(deployID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("get existing deploy logs", "error", err)
|
|
||||||
} else {
|
|
||||||
for _, entry := range existingLogs {
|
|
||||||
writeSSE(w, flusher, events.Event{
|
|
||||||
Type: events.EventDeployLog,
|
|
||||||
Payload: events.DeployLogPayload{
|
|
||||||
DeployID: deployID,
|
|
||||||
Message: entry.Message,
|
|
||||||
Level: entry.Level,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If deploy is already finished, send completion and close.
|
|
||||||
if isTerminalStatus(deploy.Status) {
|
|
||||||
writeSSE(w, flusher, events.Event{
|
|
||||||
Type: events.EventDeployStatus,
|
|
||||||
Payload: events.DeployStatusPayload{
|
|
||||||
DeployID: deployID,
|
|
||||||
ProjectID: deploy.ProjectID,
|
|
||||||
StageID: deploy.StageID,
|
|
||||||
ImageTag: deploy.ImageTag,
|
|
||||||
Status: deploy.Status,
|
|
||||||
Error: deploy.Error,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe to new deploy log events for this deploy.
|
|
||||||
sub := s.eventBus.Subscribe(func(evt events.Event) bool {
|
|
||||||
switch payload := evt.Payload.(type) {
|
|
||||||
case events.DeployLogPayload:
|
|
||||||
return payload.DeployID == deployID
|
|
||||||
case events.DeployStatusPayload:
|
|
||||||
return payload.DeployID == deployID
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
defer s.eventBus.Unsubscribe(sub)
|
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case evt, ok := <-sub:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeSSE(w, flusher, evt)
|
|
||||||
|
|
||||||
// Close stream when deploy reaches terminal status.
|
|
||||||
if evt.Type == events.EventDeployStatus {
|
|
||||||
if payload, ok := evt.Payload.(events.DeployStatusPayload); ok {
|
|
||||||
if isTerminalStatus(payload.Status) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamEvents handles GET /api/events.
|
// streamEvents handles GET /api/events.
|
||||||
// It streams instance status changes and deploy status changes via SSE.
|
// It streams instance status changes and deploy status changes via SSE.
|
||||||
func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -203,8 +70,3 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
|
|||||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTerminalStatus returns true if the deploy status is final.
|
|
||||||
func isTerminalStatus(status string) bool {
|
|
||||||
return store.IsTerminalDeployStatus(status)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,285 +0,0 @@
|
|||||||
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"
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/crypto"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stageEnvRequest is the expected JSON body for creating/updating a stage env override.
|
|
||||||
type stageEnvRequest struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Encrypted *bool `json:"encrypted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// listStageEnv handles GET /api/projects/{id}/stages/{stage}/env.
|
|
||||||
func (s *Server) listStageEnv(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
|
|
||||||
// Verify stage exists.
|
|
||||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
envs, err := s.store.GetStageEnvByStageID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list stage env", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask encrypted values in the response.
|
|
||||||
masked := make([]store.StageEnv, len(envs))
|
|
||||||
for i, env := range envs {
|
|
||||||
masked[i] = env
|
|
||||||
if env.Encrypted {
|
|
||||||
masked[i].Value = "••••••••"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, masked)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createStageEnv handles POST /api/projects/{id}/stages/{stage}/env.
|
|
||||||
func (s *Server) createStageEnv(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
|
|
||||||
// Verify stage exists.
|
|
||||||
if _, err := s.store.GetStageByID(stageID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req stageEnvRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Key == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "key is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
encrypted := false
|
|
||||||
if req.Encrypted != nil {
|
|
||||||
encrypted = *req.Encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
value := req.Value
|
|
||||||
if encrypted && value != "" {
|
|
||||||
enc, err := crypto.Encrypt(s.encKey, value)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to encrypt value", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
value = enc
|
|
||||||
}
|
|
||||||
|
|
||||||
env, err := s.store.CreateStageEnv(store.StageEnv{
|
|
||||||
StageID: stageID,
|
|
||||||
Key: req.Key,
|
|
||||||
Value: value,
|
|
||||||
Encrypted: encrypted,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create stage env", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask encrypted value in the response.
|
|
||||||
if env.Encrypted {
|
|
||||||
env.Value = "••••••••"
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, env)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStageEnv handles PUT /api/projects/{id}/stages/{stage}/env/{envId}.
|
|
||||||
func (s *Server) updateStageEnv(w http.ResponseWriter, r *http.Request) {
|
|
||||||
envID := chi.URLParam(r, "envId")
|
|
||||||
|
|
||||||
existing, err := s.store.GetStageEnvByID(envID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage env")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage env", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req stageEnvRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := existing
|
|
||||||
if req.Key != "" {
|
|
||||||
updated.Key = req.Key
|
|
||||||
}
|
|
||||||
if req.Encrypted != nil {
|
|
||||||
updated.Encrypted = *req.Encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only update value if provided (allows updating key/encrypted without changing the value).
|
|
||||||
if req.Value != "" {
|
|
||||||
value := req.Value
|
|
||||||
if updated.Encrypted {
|
|
||||||
enc, err := crypto.Encrypt(s.encKey, value)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to encrypt value", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
value = enc
|
|
||||||
}
|
|
||||||
updated.Value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateStageEnv(updated); err != nil {
|
|
||||||
slog.Error("failed to update stage env", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask encrypted value in the response.
|
|
||||||
if updated.Encrypted {
|
|
||||||
updated.Value = "••••••••"
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteStageEnv handles DELETE /api/projects/{id}/stages/{stage}/env/{envId}.
|
|
||||||
func (s *Server) deleteStageEnv(w http.ResponseWriter, r *http.Request) {
|
|
||||||
envID := chi.URLParam(r, "envId")
|
|
||||||
if err := s.store.DeleteStageEnv(envID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage env")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to delete stage env", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
|
|
||||||
}
|
|
||||||
@@ -1,202 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/events"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// stageRequest is the expected JSON body for creating/updating a stage.
|
|
||||||
type stageRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
TagPattern string `json:"tag_pattern"`
|
|
||||||
AutoDeploy *bool `json:"auto_deploy"`
|
|
||||||
EnableProxy *bool `json:"enable_proxy"`
|
|
||||||
MaxInstances *int `json:"max_instances"`
|
|
||||||
Confirm *bool `json:"confirm"`
|
|
||||||
PromoteFrom string `json:"promote_from"`
|
|
||||||
Subdomain string `json:"subdomain"`
|
|
||||||
NotificationURL string `json:"notification_url"`
|
|
||||||
CpuLimit *float64 `json:"cpu_limit"`
|
|
||||||
MemoryLimit *int `json:"memory_limit"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// createStage handles POST /api/projects/{id}/stages.
|
|
||||||
func (s *Server) createStage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
// Verify project exists.
|
|
||||||
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req stageRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.TagPattern == "" {
|
|
||||||
req.TagPattern = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
autoDeploy := false
|
|
||||||
if req.AutoDeploy != nil {
|
|
||||||
autoDeploy = *req.AutoDeploy
|
|
||||||
}
|
|
||||||
maxInstances := 1
|
|
||||||
if req.MaxInstances != nil {
|
|
||||||
maxInstances = *req.MaxInstances
|
|
||||||
}
|
|
||||||
confirm := false
|
|
||||||
if req.Confirm != nil {
|
|
||||||
confirm = *req.Confirm
|
|
||||||
}
|
|
||||||
enableProxy := true
|
|
||||||
if req.EnableProxy != nil {
|
|
||||||
enableProxy = *req.EnableProxy
|
|
||||||
}
|
|
||||||
|
|
||||||
var cpuLimit float64
|
|
||||||
if req.CpuLimit != nil {
|
|
||||||
cpuLimit = *req.CpuLimit
|
|
||||||
}
|
|
||||||
var memoryLimit int
|
|
||||||
if req.MemoryLimit != nil {
|
|
||||||
memoryLimit = *req.MemoryLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
stage, err := s.store.CreateStage(store.Stage{
|
|
||||||
ProjectID: projectID,
|
|
||||||
Name: req.Name,
|
|
||||||
TagPattern: req.TagPattern,
|
|
||||||
AutoDeploy: autoDeploy,
|
|
||||||
EnableProxy: enableProxy,
|
|
||||||
MaxInstances: maxInstances,
|
|
||||||
Confirm: confirm,
|
|
||||||
PromoteFrom: req.PromoteFrom,
|
|
||||||
Subdomain: req.Subdomain,
|
|
||||||
NotificationURL: req.NotificationURL,
|
|
||||||
CpuLimit: cpuLimit,
|
|
||||||
MemoryLimit: memoryLimit,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
Source: "admin",
|
|
||||||
Severity: "info",
|
|
||||||
Message: "stage created: " + stage.Name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusCreated, stage)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStage handles PUT /api/projects/{id}/stages/{stage}.
|
|
||||||
func (s *Server) updateStage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
|
|
||||||
existing, err := s.store.GetStageByID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req stageRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := existing
|
|
||||||
if req.Name != "" {
|
|
||||||
updated.Name = req.Name
|
|
||||||
}
|
|
||||||
if req.TagPattern != "" {
|
|
||||||
updated.TagPattern = req.TagPattern
|
|
||||||
}
|
|
||||||
if req.AutoDeploy != nil {
|
|
||||||
updated.AutoDeploy = *req.AutoDeploy
|
|
||||||
}
|
|
||||||
if req.EnableProxy != nil {
|
|
||||||
updated.EnableProxy = *req.EnableProxy
|
|
||||||
}
|
|
||||||
if req.MaxInstances != nil {
|
|
||||||
updated.MaxInstances = *req.MaxInstances
|
|
||||||
}
|
|
||||||
if req.Confirm != nil {
|
|
||||||
updated.Confirm = *req.Confirm
|
|
||||||
}
|
|
||||||
updated.PromoteFrom = req.PromoteFrom
|
|
||||||
updated.Subdomain = req.Subdomain
|
|
||||||
updated.NotificationURL = req.NotificationURL
|
|
||||||
if req.CpuLimit != nil {
|
|
||||||
updated.CpuLimit = *req.CpuLimit
|
|
||||||
}
|
|
||||||
if req.MemoryLimit != nil {
|
|
||||||
updated.MemoryLimit = *req.MemoryLimit
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateStage(updated); err != nil {
|
|
||||||
slog.Error("failed to update stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
Source: "admin",
|
|
||||||
Severity: "info",
|
|
||||||
Message: "stage updated: " + updated.Name,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteStage handles DELETE /api/projects/{id}/stages/{stage}.
|
|
||||||
func (s *Server) deleteStage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
stageID := chi.URLParam(r, "stage")
|
|
||||||
if err := s.store.DeleteStage(stageID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "stage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to delete stage", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
Source: "admin",
|
|
||||||
Severity: "info",
|
|
||||||
Message: "stage deleted: " + stageID,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": stageID})
|
|
||||||
}
|
|
||||||
@@ -1,662 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/crypto"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── List / Get ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) listStaticSites(w http.ResponseWriter, r *http.Request) {
|
|
||||||
sites, err := s.store.GetAllStaticSites()
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list static sites")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask access tokens in response.
|
|
||||||
for i := range sites {
|
|
||||||
sites[i].AccessToken = maskToken(sites[i].AccessToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, sites)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) getStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
site.AccessToken = maskToken(site.AccessToken)
|
|
||||||
respondJSON(w, http.StatusOK, site)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Create ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type createStaticSiteRequest struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
GiteaURL string `json:"gitea_url"`
|
|
||||||
RepoOwner string `json:"repo_owner"`
|
|
||||||
RepoName string `json:"repo_name"`
|
|
||||||
Branch string `json:"branch"`
|
|
||||||
FolderPath string `json:"folder_path"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
Domain string `json:"domain"`
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
RenderMarkdown bool `json:"render_markdown"`
|
|
||||||
SyncTrigger string `json:"sync_trigger"`
|
|
||||||
TagPattern string `json:"tag_pattern"`
|
|
||||||
StorageEnabled bool `json:"storage_enabled"`
|
|
||||||
StorageLimitMB int `json:"storage_limit_mb"`
|
|
||||||
NotificationURL *string `json:"notification_url,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) createStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req createStaticSiteRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name == "" || req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "name, gitea_url, repo_owner, and repo_name are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Branch == "" {
|
|
||||||
req.Branch = "main"
|
|
||||||
}
|
|
||||||
if req.Mode == "" {
|
|
||||||
req.Mode = "static"
|
|
||||||
}
|
|
||||||
if req.SyncTrigger == "" {
|
|
||||||
req.SyncTrigger = "manual"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt access token if provided.
|
|
||||||
encryptedToken := ""
|
|
||||||
if req.AccessToken != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encryptedToken = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
site := store.StaticSite{
|
|
||||||
Name: req.Name,
|
|
||||||
Provider: req.Provider,
|
|
||||||
GiteaURL: strings.TrimRight(req.GiteaURL, "/"),
|
|
||||||
RepoOwner: req.RepoOwner,
|
|
||||||
RepoName: req.RepoName,
|
|
||||||
Branch: req.Branch,
|
|
||||||
FolderPath: req.FolderPath,
|
|
||||||
AccessToken: encryptedToken,
|
|
||||||
Domain: req.Domain,
|
|
||||||
Mode: req.Mode,
|
|
||||||
RenderMarkdown: req.RenderMarkdown,
|
|
||||||
SyncTrigger: req.SyncTrigger,
|
|
||||||
TagPattern: req.TagPattern,
|
|
||||||
StorageEnabled: req.StorageEnabled,
|
|
||||||
StorageLimitMB: req.StorageLimitMB,
|
|
||||||
Status: "idle",
|
|
||||||
}
|
|
||||||
if req.NotificationURL != nil {
|
|
||||||
site.NotificationURL = *req.NotificationURL
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.store.CreateStaticSite(site)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create static site: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
created.AccessToken = maskToken(created.AccessToken)
|
|
||||||
respondJSON(w, http.StatusCreated, created)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) updateStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
existing, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req createStaticSiteRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update fields.
|
|
||||||
if req.Name != "" {
|
|
||||||
existing.Name = req.Name
|
|
||||||
}
|
|
||||||
if req.Provider != "" {
|
|
||||||
existing.Provider = req.Provider
|
|
||||||
}
|
|
||||||
if req.GiteaURL != "" {
|
|
||||||
existing.GiteaURL = strings.TrimRight(req.GiteaURL, "/")
|
|
||||||
}
|
|
||||||
if req.RepoOwner != "" {
|
|
||||||
existing.RepoOwner = req.RepoOwner
|
|
||||||
}
|
|
||||||
if req.RepoName != "" {
|
|
||||||
existing.RepoName = req.RepoName
|
|
||||||
}
|
|
||||||
if req.Branch != "" {
|
|
||||||
existing.Branch = req.Branch
|
|
||||||
}
|
|
||||||
if req.FolderPath != "" {
|
|
||||||
existing.FolderPath = req.FolderPath
|
|
||||||
}
|
|
||||||
if req.Domain != "" {
|
|
||||||
existing.Domain = req.Domain
|
|
||||||
}
|
|
||||||
if req.Mode != "" {
|
|
||||||
existing.Mode = req.Mode
|
|
||||||
}
|
|
||||||
if req.SyncTrigger != "" {
|
|
||||||
existing.SyncTrigger = req.SyncTrigger
|
|
||||||
}
|
|
||||||
existing.RenderMarkdown = req.RenderMarkdown
|
|
||||||
existing.TagPattern = req.TagPattern
|
|
||||||
existing.StorageEnabled = req.StorageEnabled
|
|
||||||
existing.StorageLimitMB = req.StorageLimitMB
|
|
||||||
if req.NotificationURL != nil {
|
|
||||||
existing.NotificationURL = *req.NotificationURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update access token only if a new one is provided.
|
|
||||||
if req.AccessToken != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to encrypt access token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
existing.AccessToken = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateStaticSite(existing); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update static site: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.AccessToken = maskToken(existing.AccessToken)
|
|
||||||
respondJSON(w, http.StatusOK, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Delete ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) deleteStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
// Remove container and proxy route first.
|
|
||||||
if s.staticSiteManager != nil {
|
|
||||||
if err := s.staticSiteManager.Remove(r.Context(), id); err != nil {
|
|
||||||
// Log but don't fail — still delete the DB record.
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to remove site resources: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.DeleteStaticSite(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Deploy ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) deployStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger deploy asynchronously with a detached context
|
|
||||||
// (the HTTP request context is canceled when the response is sent).
|
|
||||||
// Manual deploys always force a full rebuild + proxy regeneration.
|
|
||||||
go func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := s.staticSiteManager.Deploy(ctx, id, true); err != nil {
|
|
||||||
// Error is already stored in the site status.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusAccepted, map[string]string{"status": "deploying"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stop / Start ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) stopStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
_ = s.staticSiteManager.Stop(ctx, id)
|
|
||||||
}()
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusAccepted, map[string]string{"status": "stopping"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) startStaticSite(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ctx := context.Background()
|
|
||||||
_ = s.staticSiteManager.Start(ctx, id)
|
|
||||||
}()
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusAccepted, map[string]string{"status": "starting"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Test Connection ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type testConnectionRequest struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
GiteaURL string `json:"gitea_url"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RepoOwner string `json:"repo_owner"`
|
|
||||||
RepoName string `json:"repo_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) testStaticSiteConnection(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req testConnectionRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.GiteaURL == "" || req.RepoOwner == "" || req.RepoName == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "gitea_url, repo_owner, and repo_name are required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt token for the manager to decrypt (consistent handling).
|
|
||||||
encToken := ""
|
|
||||||
if req.AccessToken != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encToken = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.staticSiteManager.TestConnection(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName); err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"status": "connected"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Branches ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type listBranchesRequest struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
GiteaURL string `json:"gitea_url"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RepoOwner string `json:"repo_owner"`
|
|
||||||
RepoName string `json:"repo_name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) listStaticSiteBranches(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req listBranchesRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
encToken := ""
|
|
||||||
if req.AccessToken != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encToken = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
branches, err := s.staticSiteManager.ListBranches(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "failed to list branches: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, branches)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Tree ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type listTreeRequest struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
GiteaURL string `json:"gitea_url"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
RepoOwner string `json:"repo_owner"`
|
|
||||||
RepoName string `json:"repo_name"`
|
|
||||||
Branch string `json:"branch"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) listStaticSiteTree(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req listTreeRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
encToken := ""
|
|
||||||
if req.AccessToken != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encToken = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := s.staticSiteManager.ListTree(r.Context(), req.Provider, req.GiteaURL, encToken, req.RepoOwner, req.RepoName, req.Branch)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "failed to list tree: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, entries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Secrets ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) listStaticSiteSecrets(w http.ResponseWriter, r *http.Request) {
|
|
||||||
siteID := chi.URLParam(r, "id")
|
|
||||||
secrets, err := s.store.GetStaticSiteSecretsBySiteID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list secrets")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mask encrypted values.
|
|
||||||
for i := range secrets {
|
|
||||||
if secrets[i].Encrypted {
|
|
||||||
secrets[i].Value = "••••••••"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, secrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
type createSecretRequest struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Encrypted bool `json:"encrypted"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) createStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
siteID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
var req createSecretRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Key == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "key is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
value := req.Value
|
|
||||||
if req.Encrypted && value != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, value)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
value = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := store.StaticSiteSecret{
|
|
||||||
SiteID: siteID,
|
|
||||||
Key: req.Key,
|
|
||||||
Value: value,
|
|
||||||
Encrypted: req.Encrypted,
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.store.CreateStaticSiteSecret(secret)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create secret: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if created.Encrypted {
|
|
||||||
created.Value = "••••••••"
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusCreated, created)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) updateStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
secretID := chi.URLParam(r, "sid")
|
|
||||||
|
|
||||||
existing, err := s.store.GetStaticSiteSecretByID(secretID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req createSecretRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.Key != "" {
|
|
||||||
existing.Key = req.Key
|
|
||||||
}
|
|
||||||
existing.Encrypted = req.Encrypted
|
|
||||||
|
|
||||||
if req.Value != "" {
|
|
||||||
value := req.Value
|
|
||||||
if req.Encrypted {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, value)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to encrypt secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
value = encrypted
|
|
||||||
}
|
|
||||||
existing.Value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateStaticSiteSecret(existing); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update secret: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing.Encrypted {
|
|
||||||
existing.Value = "••••••••"
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, existing)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) deleteStaticSiteSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
secretID := chi.URLParam(r, "sid")
|
|
||||||
|
|
||||||
if err := s.store.DeleteStaticSiteSecret(secretID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": secretID})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── List Repos ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type listReposRequest struct {
|
|
||||||
Provider string `json:"provider"`
|
|
||||||
GiteaURL string `json:"gitea_url"`
|
|
||||||
AccessToken string `json:"access_token"`
|
|
||||||
Query string `json:"query"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) listStaticSiteRepos(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req listReposRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.GiteaURL == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "gitea_url is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
encToken := ""
|
|
||||||
if req.AccessToken != "" {
|
|
||||||
encrypted, err := crypto.Encrypt(s.encKey, req.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to process token")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
encToken = encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
repos, err := s.staticSiteManager.ListRepos(r.Context(), req.Provider, req.GiteaURL, encToken, req.Query)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "failed to list repos: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, repos)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Detect Provider ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type detectProviderRequest struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) detectStaticSiteProvider(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req detectProviderRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.URL == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "url is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if s.staticSiteManager == nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "static site manager not initialized")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
provider := s.staticSiteManager.DetectProvider(r.Context(), req.URL)
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"provider": provider})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Storage Usage ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (s *Server) getStaticSiteStorage(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !site.StorageEnabled {
|
|
||||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"enabled": false,
|
|
||||||
"used_bytes": 0,
|
|
||||||
"limit_mb": 0,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usage, err := s.docker.InspectSiteStorageUsage(r.Context(), site.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to inspect storage usage")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]interface{}{
|
|
||||||
"enabled": true,
|
|
||||||
"used_bytes": usage.UsedBytes,
|
|
||||||
"limit_mb": site.StorageLimitMB,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// maskToken returns a masked version of a token string for API responses.
|
|
||||||
func maskToken(token string) string {
|
|
||||||
if token == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "••••••••"
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,23 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/auth"
|
"github.com/alexei/tinyforge/internal/auth"
|
||||||
"github.com/alexei/tinyforge/internal/stats"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// topConsumerWindow is how recent a container sample must be to count toward
|
// topConsumerMinWindow is how recent a container sample must be to count toward
|
||||||
// the "top consumers" list. Scaled with the collector interval (read from
|
// the "top consumers" list. Scaled with the collector interval (read from
|
||||||
// settings) so it stays meaningful even when sampling is sparse.
|
// settings) so it stays meaningful even when sampling is sparse.
|
||||||
const topConsumerMinWindow = 2 * time.Minute
|
const topConsumerMinWindow = 2 * time.Minute
|
||||||
|
|
||||||
// TopContainerSample augments a stats sample with the human-readable owner
|
// TopContainerSample augments a stats sample with the human-readable owner
|
||||||
// name so the UI can show "project/stage" or the static-site name without an
|
// name so the UI can show "workload/role" without an extra round-trip per row.
|
||||||
// extra round-trip per row.
|
|
||||||
type TopContainerSample struct {
|
type TopContainerSample struct {
|
||||||
store.ContainerStatsSample
|
store.ContainerStatsSample
|
||||||
OwnerName string `json:"owner_name"`
|
OwnerName string `json:"owner_name"`
|
||||||
@@ -90,107 +85,6 @@ func (s *Server) getSystemStatsHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, samples)
|
respondJSON(w, http.StatusOK, samples)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getInstanceStatsHistory handles GET /api/projects/{id}/stages/{stage}/instances/{iid}/stats/history.
|
|
||||||
// {iid} is the container row ID (same UUID as the legacy instance ID).
|
|
||||||
func (s *Server) getInstanceStatsHistory(w http.ResponseWriter, r *http.Request) {
|
|
||||||
instanceID := chi.URLParam(r, "iid")
|
|
||||||
if _, err := s.store.GetContainerByID(instanceID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get container", "id", instanceID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeInstance, instanceID, sinceTimestamp(parseWindow(r)))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list instance stats samples", "instance_id", instanceID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list samples")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if samples == nil {
|
|
||||||
samples = []store.ContainerStatsSample{}
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStaticSiteStats handles GET /api/sites/{id}/stats — current snapshot.
|
|
||||||
func (s *Server) getStaticSiteStats(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get site", "site_id", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if site.ContainerID == "" {
|
|
||||||
respondError(w, http.StatusConflict, "site has no container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if s.docker == nil {
|
|
||||||
respondError(w, http.StatusServiceUnavailable, "Docker is not available")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cs, err := s.docker.GetContainerStats(r.Context(), site.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get site stats", "site_id", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get site stats")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, cs)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStaticSiteStatsHistory handles GET /api/sites/{id}/stats/history.
|
|
||||||
func (s *Server) getStaticSiteStatsHistory(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get site", "site_id", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
samples, err := s.store.ListContainerStatsSamples(stats.OwnerTypeSite, id, sinceTimestamp(parseWindow(r)))
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list site stats samples", "site_id", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list samples")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if samples == nil {
|
|
||||||
samples = []store.ContainerStatsSample{}
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, samples)
|
|
||||||
}
|
|
||||||
|
|
||||||
// streamStaticSiteLogs handles GET /api/sites/{id}/logs?tail=200&follow=true.
|
|
||||||
// Reuses the shared container log streamer so the SSE + multiplex handling
|
|
||||||
// matches /api/projects/.../instances/.../logs exactly.
|
|
||||||
func (s *Server) streamStaticSiteLogs(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get site", "site_id", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if site.ContainerID == "" {
|
|
||||||
respondError(w, http.StatusConflict, "site has no container")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.streamLogsForContainer(w, r, site.ContainerID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listTopContainers handles GET /api/system/stats/top?limit=5&by=cpu.
|
// listTopContainers handles GET /api/system/stats/top?limit=5&by=cpu.
|
||||||
// Returns the top-N most recent samples across containers, sorted by CPU or
|
// Returns the top-N most recent samples across containers, sorted by CPU or
|
||||||
// memory. Container IDs are stripped for non-admins so a low-privilege viewer
|
// memory. Container IDs are stripped for non-admins so a low-privilege viewer
|
||||||
@@ -246,8 +140,6 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) {
|
|||||||
top = top[:limit]
|
top = top[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve owner names so the UI can show "project/stage" or the site name
|
|
||||||
// without a per-row round trip.
|
|
||||||
enriched := s.enrichWithOwnerNames(top)
|
enriched := s.enrichWithOwnerNames(top)
|
||||||
|
|
||||||
// Scrub container IDs for non-admins. The owner name is the actionable
|
// Scrub container IDs for non-admins. The owner name is the actionable
|
||||||
@@ -264,18 +156,13 @@ func (s *Server) listTopContainers(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enrichWithOwnerNames attaches a human-readable owner name to each sample.
|
// enrichWithOwnerNames attaches a human-readable owner name to each sample.
|
||||||
// Looks up instances and sites in batch so the cost is independent of the
|
// Names are resolved through the containers index → workloads, which after
|
||||||
// number of samples (which is at most 'limit').
|
// the cutover is the only available lookup path.
|
||||||
func (s *Server) enrichWithOwnerNames(samples []store.ContainerStatsSample) []TopContainerSample {
|
func (s *Server) enrichWithOwnerNames(samples []store.ContainerStatsSample) []TopContainerSample {
|
||||||
out := make([]TopContainerSample, len(samples))
|
out := make([]TopContainerSample, len(samples))
|
||||||
for i, sm := range samples {
|
for i, sm := range samples {
|
||||||
out[i] = TopContainerSample{ContainerStatsSample: sm}
|
out[i] = TopContainerSample{ContainerStatsSample: sm}
|
||||||
switch sm.OwnerType {
|
out[i].OwnerName = s.lookupInstanceName(sm.OwnerID)
|
||||||
case stats.OwnerTypeInstance:
|
|
||||||
out[i].OwnerName = s.lookupInstanceName(sm.OwnerID)
|
|
||||||
case stats.OwnerTypeSite:
|
|
||||||
out[i].OwnerName = s.lookupSiteName(sm.OwnerID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -300,11 +187,3 @@ func (s *Server) lookupInstanceName(instanceID string) string {
|
|||||||
return w.Name
|
return w.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// lookupSiteName returns the site's display name or empty on lookup error.
|
|
||||||
func (s *Server) lookupSiteName(siteID string) string {
|
|
||||||
site, err := s.store.GetStaticSiteByID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return site.Name
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/alexei/tinyforge/internal/volume"
|
|
||||||
)
|
|
||||||
|
|
||||||
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
|
|
||||||
func sanitizeFilename(name string) string {
|
|
||||||
return strings.Map(func(r rune) rune {
|
|
||||||
if r == '"' || r == '\\' || r == '\n' || r == '\r' {
|
|
||||||
return '_'
|
|
||||||
}
|
|
||||||
return r
|
|
||||||
}, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxUploadSize = 100 * 1024 * 1024 // 100MB
|
|
||||||
|
|
||||||
// resolveVolumeRoot looks up a volume and resolves its host path.
|
|
||||||
func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (string, bool) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
volID := chi.URLParam(r, "volId")
|
|
||||||
|
|
||||||
proj, err := s.store.GetProjectByID(projectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
vol, err := s.store.GetVolumeByID(volID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "volume")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
slog.Error("failed to get volume", "volume_id", volID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get volume")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify volume belongs to this project.
|
|
||||||
if vol.ProjectID != projectID {
|
|
||||||
respondNotFound(w, "volume")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
if vol.Scope == "ephemeral" {
|
|
||||||
respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
settings, err := s.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get settings", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get settings")
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
q := r.URL.Query()
|
|
||||||
params := volume.ResolveParams{
|
|
||||||
BasePath: settings.BaseVolumePath,
|
|
||||||
ProjectName: proj.Name,
|
|
||||||
StageName: q.Get("stage"),
|
|
||||||
ImageTag: q.Get("tag"),
|
|
||||||
AllowedVolumePaths: settings.AllowedVolumePaths,
|
|
||||||
}
|
|
||||||
|
|
||||||
rootPath, err := volume.ResolvePath(vol, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, err.Error())
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
|
|
||||||
return rootPath, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// browseVolume handles GET /api/projects/{id}/volumes/{volId}/browse?path=&stage=&tag=
|
|
||||||
func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rootPath, ok := s.resolveVolumeRoot(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath := r.URL.Query().Get("path")
|
|
||||||
entries, err := volume.ListDir(rootPath, relPath)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list directory", "root", rootPath, "path", relPath, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list directory")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"path": relPath,
|
|
||||||
"entries": entries,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadVolume handles GET /api/projects/{id}/volumes/{volId}/download?path=&stage=&tag=
|
|
||||||
// Downloads a single file directly, or a directory/root as a zip archive.
|
|
||||||
func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) {
|
|
||||||
rootPath, ok := s.resolveVolumeRoot(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath := r.URL.Query().Get("path")
|
|
||||||
|
|
||||||
// If path is empty or points to a directory, serve as zip.
|
|
||||||
if relPath == "" {
|
|
||||||
s.serveZip(w, rootPath, "", "volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if it's a file or directory.
|
|
||||||
f, info, err := volume.OpenFile(rootPath, relPath)
|
|
||||||
if err != nil {
|
|
||||||
// Might be a directory — try zip.
|
|
||||||
entries, listErr := volume.ListDir(rootPath, relPath)
|
|
||||||
if listErr == nil && entries != nil {
|
|
||||||
name := filepath.Base(relPath)
|
|
||||||
s.serveZip(w, rootPath, relPath, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to open file", "root", rootPath, "path", relPath, "error", err)
|
|
||||||
respondError(w, http.StatusNotFound, "file not found")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Serve single file with forced download.
|
|
||||||
safeName := sanitizeFilename(filepath.Base(relPath))
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName))
|
|
||||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
io.Copy(w, f)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) {
|
|
||||||
safeName := sanitizeFilename(name)
|
|
||||||
w.Header().Set("Content-Type", "application/zip")
|
|
||||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName))
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
if err := volume.WriteZip(rootPath, relPath, w); err != nil {
|
|
||||||
slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag=
|
|
||||||
// Accepts multipart form uploads. Overrides the global body limit for large files.
|
|
||||||
func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Override the global 1MB body limit for uploads.
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
||||||
|
|
||||||
rootPath, ok := s.resolveVolumeRoot(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
||||||
respondError(w, http.StatusBadRequest, "upload too large (max 100MB)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath := r.URL.Query().Get("path")
|
|
||||||
uploaded := []string{}
|
|
||||||
|
|
||||||
for _, fileHeaders := range r.MultipartForm.File {
|
|
||||||
for _, fh := range fileHeaders {
|
|
||||||
f, err := fh.Open()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to open upload", "filename", fh.Filename, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strip directory components from filename to prevent directory creation attacks.
|
|
||||||
targetRel := filepath.Join(relPath, filepath.Base(fh.Filename))
|
|
||||||
if err := volume.SaveFile(rootPath, targetRel, f); err != nil {
|
|
||||||
f.Close()
|
|
||||||
slog.Error("failed to save upload", "filename", fh.Filename, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to save file: "+fh.Filename)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
uploaded = append(uploaded, fh.Filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"uploaded": uploaded,
|
|
||||||
"count": len(uploaded),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/alexei/tinyforge/internal/volume"
|
|
||||||
)
|
|
||||||
|
|
||||||
// safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot.
|
|
||||||
var safeNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]*$`)
|
|
||||||
|
|
||||||
// validateVolumePath checks that the source path does not contain path traversal.
|
|
||||||
func validateVolumePath(source string) bool {
|
|
||||||
cleaned := filepath.Clean(source)
|
|
||||||
return !strings.Contains(cleaned, "..")
|
|
||||||
}
|
|
||||||
|
|
||||||
// volumeRequest is the expected JSON body for creating/updating a volume.
|
|
||||||
type volumeRequest struct {
|
|
||||||
Source string `json:"source"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
Mode string `json:"mode,omitempty"` // legacy — ignored if scope is set
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// validScopes lists all accepted scope values.
|
|
||||||
var validScopes = map[string]bool{
|
|
||||||
"instance": true, "stage": true, "project": true,
|
|
||||||
"project_named": true, "named": true, "ephemeral": true,
|
|
||||||
"absolute": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateVolumeScope validates the scope, name, and source combination.
|
|
||||||
func validateVolumeScope(scope, name, source, allowedPathsJSON string) string {
|
|
||||||
if !validScopes[scope] {
|
|
||||||
return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute"
|
|
||||||
}
|
|
||||||
if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" {
|
|
||||||
return "name is required for " + scope + " scope"
|
|
||||||
}
|
|
||||||
if name != "" && !safeNamePattern.MatchString(name) {
|
|
||||||
return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots"
|
|
||||||
}
|
|
||||||
if scope == "absolute" {
|
|
||||||
if source == "" {
|
|
||||||
return "source path is required for absolute scope"
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(source) {
|
|
||||||
return "absolute scope requires an absolute source path"
|
|
||||||
}
|
|
||||||
// Validate against allowlist.
|
|
||||||
allowed, err := volume.ParseAllowedPaths(allowedPathsJSON)
|
|
||||||
if err != nil {
|
|
||||||
return "failed to parse allowed volume paths"
|
|
||||||
}
|
|
||||||
if len(allowed) == 0 {
|
|
||||||
return "absolute volume paths are disabled — configure allowed paths in settings first"
|
|
||||||
}
|
|
||||||
matched := false
|
|
||||||
cleanSource := filepath.Clean(source)
|
|
||||||
for _, prefix := range allowed {
|
|
||||||
cleanPrefix := filepath.Clean(prefix)
|
|
||||||
if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix {
|
|
||||||
matched = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !matched {
|
|
||||||
return "source path is not under any allowed volume path"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// scopeDescriptions returns metadata about each scope for the UI.
|
|
||||||
type scopeInfo struct {
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
Description string `json:"description"`
|
|
||||||
NeedsName bool `json:"needs_name"`
|
|
||||||
PathExample string `json:"path_example"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// listVolumeScopes handles GET /api/volumes/scopes.
|
|
||||||
// Returns all available scopes with descriptions for UI hints.
|
|
||||||
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
|
|
||||||
scopes := []scopeInfo{
|
|
||||||
{
|
|
||||||
Scope: "instance",
|
|
||||||
Description: "Each deploy gets its own isolated directory. Data is not shared between deploys.",
|
|
||||||
NeedsName: false,
|
|
||||||
PathExample: "{base}/{project}/{stage}-{tag}/{source}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: "stage",
|
|
||||||
Description: "All deploys within the same stage share this volume. Data persists across blue-green deployments.",
|
|
||||||
NeedsName: false,
|
|
||||||
PathExample: "{base}/{project}/{stage}/{source}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: "project",
|
|
||||||
Description: "Shared across all stages of the project. Good for common config or shared assets.",
|
|
||||||
NeedsName: false,
|
|
||||||
PathExample: "{base}/{project}/{source}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: "project_named",
|
|
||||||
Description: "A named volume within the project. Multiple stages can reference the same name to share data selectively.",
|
|
||||||
NeedsName: true,
|
|
||||||
PathExample: "{base}/{project}/_named/{name}/{source}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: "named",
|
|
||||||
Description: "A globally named volume shared across projects. Use for cross-project resources like shared databases.",
|
|
||||||
NeedsName: true,
|
|
||||||
PathExample: "{base}/_named/{name}/{source}",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: "ephemeral",
|
|
||||||
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops. Good for temp files and caches.",
|
|
||||||
NeedsName: false,
|
|
||||||
PathExample: "(tmpfs — no host path)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Scope: "absolute",
|
|
||||||
Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.",
|
|
||||||
NeedsName: false,
|
|
||||||
PathExample: "/mnt/nfs/data (must match allowed paths)",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, scopes)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listVolumes handles GET /api/projects/{id}/volumes.
|
|
||||||
func (s *Server) listVolumes(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
vols, err := s.store.GetVolumesByProjectID(projectID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to list volumes", "project_id", projectID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list volumes")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, vols)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createVolume handles POST /api/projects/{id}/volumes.
|
|
||||||
func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
|
|
||||||
projectID := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if _, err := s.store.GetProjectByID(projectID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get project", "project_id", projectID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req volumeRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ephemeral volumes don't need a source path.
|
|
||||||
if req.Scope != "ephemeral" {
|
|
||||||
if req.Source == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "source is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validateVolumePath(req.Source) {
|
|
||||||
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.Target == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "target is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !validateVolumePath(req.Target) {
|
|
||||||
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve scope — support legacy mode field.
|
|
||||||
scope := req.Scope
|
|
||||||
if scope == "" {
|
|
||||||
switch req.Mode {
|
|
||||||
case "isolated":
|
|
||||||
scope = "instance"
|
|
||||||
case "shared":
|
|
||||||
scope = "project"
|
|
||||||
default:
|
|
||||||
scope = "project"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch settings for absolute path allowlist validation.
|
|
||||||
settings, err := s.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get settings for volume validation", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to validate volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" {
|
|
||||||
respondError(w, http.StatusBadRequest, errMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
vol, err := s.store.CreateVolume(store.Volume{
|
|
||||||
ProjectID: projectID,
|
|
||||||
Source: req.Source,
|
|
||||||
Target: req.Target,
|
|
||||||
Scope: scope,
|
|
||||||
Name: strings.TrimSpace(req.Name),
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to create volume", "project_id", projectID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to create volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusCreated, vol)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateVolume handles PUT /api/projects/{id}/volumes/{volId}.
|
|
||||||
func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) {
|
|
||||||
volID := chi.URLParam(r, "volId")
|
|
||||||
|
|
||||||
existing, err := s.store.GetVolumeByID(volID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to get volume", "volume_id", volID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var req volumeRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
updated := existing
|
|
||||||
if req.Source != "" {
|
|
||||||
if !validateVolumePath(req.Source) {
|
|
||||||
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updated.Source = req.Source
|
|
||||||
}
|
|
||||||
if req.Target != "" {
|
|
||||||
if !validateVolumePath(req.Target) {
|
|
||||||
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updated.Target = req.Target
|
|
||||||
}
|
|
||||||
if req.Scope != "" {
|
|
||||||
settings, err := s.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get settings for volume validation", "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to validate volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
source := updated.Source
|
|
||||||
if req.Source != "" {
|
|
||||||
source = req.Source
|
|
||||||
}
|
|
||||||
if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" {
|
|
||||||
respondError(w, http.StatusBadRequest, errMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
updated.Scope = req.Scope
|
|
||||||
updated.Name = strings.TrimSpace(req.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Non-ephemeral scopes require a source path.
|
|
||||||
if updated.Scope != "ephemeral" && updated.Source == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "source is required for non-ephemeral scopes")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.store.UpdateVolume(updated); err != nil {
|
|
||||||
slog.Error("failed to update volume", "volume_id", volID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, updated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteVolume handles DELETE /api/projects/{id}/volumes/{volId}.
|
|
||||||
func (s *Server) deleteVolume(w http.ResponseWriter, r *http.Request) {
|
|
||||||
volID := chi.URLParam(r, "volId")
|
|
||||||
if err := s.store.DeleteVolume(volID); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("failed to delete volume", "volume_id", volID, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to delete volume")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": volID})
|
|
||||||
}
|
|
||||||
@@ -1,370 +0,0 @@
|
|||||||
package api
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// generateWebhookSecret returns a 256-bit hex-encoded random token. Mirrors
|
|
||||||
// the helper in internal/store; kept here to avoid an import cycle and so the
|
|
||||||
// rotation handlers don't pretend to use uuid for what is really a secret.
|
|
||||||
func generateWebhookSecret() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
panic("crypto/rand failed: " + err.Error())
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// webhookURLResponse is the common payload returned by every webhook endpoint.
|
|
||||||
// Clients never see raw secrets except at issue/rotate time via these fields;
|
|
||||||
// the URL shape is "/api/webhook/..." so callers can prepend their own origin.
|
|
||||||
type webhookURLResponse struct {
|
|
||||||
WebhookURL string `json:"webhook_url"`
|
|
||||||
WebhookSecret string `json:"webhook_secret"`
|
|
||||||
HasSigningSecret bool `json:"has_signing_secret"`
|
|
||||||
WebhookRequireSignature bool `json:"webhook_require_signature"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// signingSecretResponse is returned when a signing secret is issued or rotated.
|
|
||||||
type signingSecretResponse struct {
|
|
||||||
SigningSecret string `json:"signing_secret"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// signingToggleRequest is the body of the require-signature toggle endpoint.
|
|
||||||
type signingToggleRequest struct {
|
|
||||||
RequireSignature bool `json:"require_signature"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// getProjectWebhook handles GET /api/projects/{id}/webhook.
|
|
||||||
// Returns the project's webhook URL + secret, generating one lazily if the
|
|
||||||
// project predates the per-project webhook migration.
|
|
||||||
func (s *Server) getProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
secret, err := s.store.EnsureProjectWebhookSecret(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("get project webhook: ensure secret", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
project, err := s.store.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
||||||
WebhookURL: "/api/webhook/" + secret,
|
|
||||||
WebhookSecret: secret,
|
|
||||||
HasSigningSecret: project.WebhookSigningSecret != "",
|
|
||||||
WebhookRequireSignature: project.WebhookRequireSignature,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// regenerateProjectSigningSecret handles POST /api/projects/{id}/webhook/signing-secret/regenerate.
|
|
||||||
// Issues a fresh HMAC signing secret for inbound webhook verification. The
|
|
||||||
// secret is returned exactly once — the UI is responsible for letting the
|
|
||||||
// user copy it into their CI configuration.
|
|
||||||
func (s *Server) regenerateProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetProjectWebhookSigningSecret(id, secret); err != nil {
|
|
||||||
slog.Error("rotate project signing secret", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("project webhook signing secret rotated", "project", id)
|
|
||||||
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
|
|
||||||
}
|
|
||||||
|
|
||||||
// disableProjectSigningSecret handles DELETE /api/projects/{id}/webhook/signing-secret.
|
|
||||||
// Clears the HMAC signing secret and disables enforcement.
|
|
||||||
func (s *Server) disableProjectSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if err := s.store.SetProjectWebhookSigningSecret(id, ""); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.SetProjectWebhookRequireSignature(id, false); err != nil {
|
|
||||||
slog.Warn("disable project require_signature", "project", id, "error", err)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateProjectSigningRequirement handles PUT /api/projects/{id}/webhook/require-signature.
|
|
||||||
// Toggles whether unsigned/invalidly-signed inbound webhook requests are
|
|
||||||
// rejected with 401.
|
|
||||||
func (s *Server) updateProjectSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
var req signingToggleRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RequireSignature {
|
|
||||||
project, err := s.store.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if project.WebhookSigningSecret == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.SetProjectWebhookRequireSignature(id, req.RequireSignature); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update setting")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// regenerateProjectWebhook handles POST /api/projects/{id}/webhook/regenerate.
|
|
||||||
// Rotates the project's webhook secret, invalidating the old URL.
|
|
||||||
func (s *Server) regenerateProjectWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
// Verify project exists before rotating.
|
|
||||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("regenerate project webhook: lookup", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetProjectWebhookSecret(id, secret); err != nil {
|
|
||||||
slog.Error("regenerate project webhook: set secret", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("project webhook secret rotated", "project", id)
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
||||||
WebhookURL: "/api/webhook/" + secret,
|
|
||||||
WebhookSecret: secret,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// getStaticSiteWebhook handles GET /api/sites/{id}/webhook.
|
|
||||||
func (s *Server) getStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
secret, err := s.store.EnsureStaticSiteWebhookSecret(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("get site webhook: ensure secret", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
||||||
WebhookURL: "/api/webhook/sites/" + secret,
|
|
||||||
WebhookSecret: secret,
|
|
||||||
HasSigningSecret: site.WebhookSigningSecret != "",
|
|
||||||
WebhookRequireSignature: site.WebhookRequireSignature,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// regenerateStaticSiteSigningSecret handles POST /api/sites/{id}/webhook/signing-secret/regenerate.
|
|
||||||
func (s *Server) regenerateStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetStaticSiteWebhookSigningSecret(id, secret); err != nil {
|
|
||||||
slog.Error("rotate site signing secret", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate signing secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Info("static site webhook signing secret rotated", "site", id)
|
|
||||||
respondJSON(w, http.StatusOK, signingSecretResponse{SigningSecret: secret})
|
|
||||||
}
|
|
||||||
|
|
||||||
// disableStaticSiteSigningSecret handles DELETE /api/sites/{id}/webhook/signing-secret.
|
|
||||||
func (s *Server) disableStaticSiteSigningSecret(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if err := s.store.SetStaticSiteWebhookSigningSecret(id, ""); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to clear signing secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.SetStaticSiteWebhookRequireSignature(id, false); err != nil {
|
|
||||||
slog.Warn("disable site require_signature", "site", id, "error", err)
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// listProjectWebhookDeliveries handles GET /api/projects/{id}/webhook/deliveries.
|
|
||||||
// Returns the most recent webhook deliveries for the project so users can
|
|
||||||
// debug "why didn't my deploy fire?" without grepping daemon logs.
|
|
||||||
func (s *Server) listProjectWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetProjectByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get project")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
|
|
||||||
deliveries, err := s.store.ListWebhookDeliveriesByTarget("project", id, limit)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("list project webhook deliveries", "project", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, deliveries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// listStaticSiteWebhookDeliveries handles GET /api/sites/{id}/webhook/deliveries.
|
|
||||||
func (s *Server) listStaticSiteWebhookDeliveries(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit := parseLimit(r.URL.Query().Get("limit"), 50, 200)
|
|
||||||
deliveries, err := s.store.ListWebhookDeliveriesByTarget("site", id, limit)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("list site webhook deliveries", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to list deliveries")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, deliveries)
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseLimit clamps a query-string limit to [1, max], falling back to def.
|
|
||||||
func parseLimit(raw string, def, max int) int {
|
|
||||||
if raw == "" {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
n, err := strconv.Atoi(raw)
|
|
||||||
if err != nil || n <= 0 {
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
if n > max {
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStaticSiteSigningRequirement handles PUT /api/sites/{id}/webhook/require-signature.
|
|
||||||
func (s *Server) updateStaticSiteSigningRequirement(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
var req signingToggleRequest
|
|
||||||
if !decodeJSON(w, r, &req) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if req.RequireSignature {
|
|
||||||
site, err := s.store.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if site.WebhookSigningSecret == "" {
|
|
||||||
respondError(w, http.StatusBadRequest, "issue a signing secret before enabling enforcement")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := s.store.SetStaticSiteWebhookRequireSignature(id, req.RequireSignature); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to update setting")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, map[string]bool{"success": true})
|
|
||||||
}
|
|
||||||
|
|
||||||
// regenerateStaticSiteWebhook handles POST /api/sites/{id}/webhook/regenerate.
|
|
||||||
func (s *Server) regenerateStaticSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
|
|
||||||
if _, err := s.store.GetStaticSiteByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("regenerate site webhook: lookup", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get static site")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetStaticSiteWebhookSecret(id, secret); err != nil {
|
|
||||||
slog.Error("regenerate site webhook: set secret", "site", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("static site webhook secret rotated", "site", id)
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
||||||
WebhookURL: "/api/webhook/sites/" + secret,
|
|
||||||
WebhookSecret: secret,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -136,61 +136,12 @@ func (s *Server) deleteWorkloadEnv(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": envID})
|
||||||
}
|
}
|
||||||
|
|
||||||
// getWorkloadWebhook handles GET /api/workloads/{id}/webhook. Returns
|
// Workload-level webhook URL handlers were dropped in the hard legacy
|
||||||
// the canonical URL + secret + signature-state flags. Lazily generates
|
// cutover: the old `/api/webhook/workloads/{secret}` route is gone, so
|
||||||
// a secret if the workload row predates the column.
|
// minting a workload secret would hand operators a URL that 404s. The
|
||||||
func (s *Server) getWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
|
// inbound webhook surface is now exclusively first-class triggers
|
||||||
id := chi.URLParam(r, "id")
|
// (`/api/webhook/triggers/{secret}`); use the trigger CRUD + bindings
|
||||||
secret, err := s.store.EnsureWorkloadWebhookSecret(id)
|
// endpoints to wire a workload to inbound deploys.
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "workload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("ensure workload webhook secret", "workload", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get webhook secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row, err := s.store.GetWorkloadByID(id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get workload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
||||||
WebhookURL: "/api/webhook/workloads/" + secret,
|
|
||||||
WebhookSecret: secret,
|
|
||||||
HasSigningSecret: row.WebhookSigningSecret != "",
|
|
||||||
WebhookRequireSignature: row.WebhookRequireSignature,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// regenerateWorkloadWebhook handles POST /api/workloads/{id}/webhook/regenerate.
|
|
||||||
// Rotates the URL secret. The old secret is invalidated immediately —
|
|
||||||
// any external system still hitting the old URL gets a 404 on next call.
|
|
||||||
func (s *Server) regenerateWorkloadWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := chi.URLParam(r, "id")
|
|
||||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
respondNotFound(w, "workload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to get workload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.store.SetWorkloadWebhookSecret(id, secret); err != nil {
|
|
||||||
slog.Error("rotate workload webhook secret", "workload", id, "error", err)
|
|
||||||
respondError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
row, _ := s.store.GetWorkloadByID(id)
|
|
||||||
respondJSON(w, http.StatusOK, webhookURLResponse{
|
|
||||||
WebhookURL: "/api/webhook/workloads/" + secret,
|
|
||||||
WebhookSecret: secret,
|
|
||||||
HasSigningSecret: row.WebhookSigningSecret != "",
|
|
||||||
WebhookRequireSignature: row.WebhookRequireSignature,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// validEnvKey accepts POSIX-style env names. Rejects anything that would
|
// validEnvKey accepts POSIX-style env names. Rejects anything that would
|
||||||
// confuse Docker's env parser (=, spaces, control chars).
|
// confuse Docker's env parser (=, spaces, control chars).
|
||||||
|
|||||||
@@ -20,6 +20,66 @@ type workloadVolumeRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scopeInfo carries one volume scope plus its operator-facing description.
|
||||||
|
// The UI uses NeedsName to decide whether to show the name input.
|
||||||
|
type scopeInfo struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
NeedsName bool `json:"needs_name"`
|
||||||
|
PathExample string `json:"path_example"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// listVolumeScopes handles GET /api/volumes/scopes. Returns the catalogue
|
||||||
|
// of supported volume scopes so the workload-volume editor can render
|
||||||
|
// scope-specific help text without baking the list into the frontend.
|
||||||
|
func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
scopes := []scopeInfo{
|
||||||
|
{
|
||||||
|
Scope: "instance",
|
||||||
|
Description: "Each deploy gets its own isolated directory keyed by image tag.",
|
||||||
|
NeedsName: false,
|
||||||
|
PathExample: "{base}/{workload}/instance-{tag}/{source}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "stage",
|
||||||
|
Description: "Shared across all instances of this workload (alias of project scope).",
|
||||||
|
NeedsName: false,
|
||||||
|
PathExample: "{base}/{workload}/{source}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "project",
|
||||||
|
Description: "Shared across all instances of this workload.",
|
||||||
|
NeedsName: false,
|
||||||
|
PathExample: "{base}/{workload}/{source}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "project_named",
|
||||||
|
Description: "A named volume within the workload — multiple mounts can share the name.",
|
||||||
|
NeedsName: true,
|
||||||
|
PathExample: "{base}/{workload}/_named/{name}/{source}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "named",
|
||||||
|
Description: "Globally named volume shared across workloads (e.g. shared databases).",
|
||||||
|
NeedsName: true,
|
||||||
|
PathExample: "{base}/_named/{name}/{source}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "ephemeral",
|
||||||
|
Description: "In-memory tmpfs mount. Fast but data is lost when the container stops.",
|
||||||
|
NeedsName: false,
|
||||||
|
PathExample: "(tmpfs — no host path)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Scope: "absolute",
|
||||||
|
Description: "Direct host path. Must be under an allowed path configured in settings.",
|
||||||
|
NeedsName: false,
|
||||||
|
PathExample: "/mnt/nfs/data (must match allowed paths)",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, scopes)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listWorkloadVolumes(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
if _, err := s.store.GetWorkloadByID(id); err != nil {
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeedConfig represents the top-level YAML seed configuration.
|
// SeedConfig represents the top-level YAML seed configuration. After the
|
||||||
|
// hard cutover only global settings + registries are supported; workloads
|
||||||
|
// are created through the API.
|
||||||
type SeedConfig struct {
|
type SeedConfig struct {
|
||||||
Global GlobalConfig `yaml:"global"`
|
Global GlobalConfig `yaml:"global"`
|
||||||
Registries map[string]RegistryDef `yaml:"registries"`
|
Registries map[string]RegistryDef `yaml:"registries"`
|
||||||
Projects map[string]ProjectDef `yaml:"projects"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GlobalConfig holds domain-wide settings from the seed file.
|
// GlobalConfig holds domain-wide settings from the seed file.
|
||||||
@@ -38,27 +39,6 @@ type RegistryDef struct {
|
|||||||
Token string `yaml:"token"`
|
Token string `yaml:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectDef defines a project from the seed file.
|
|
||||||
type ProjectDef struct {
|
|
||||||
Registry string `yaml:"registry"`
|
|
||||||
Image string `yaml:"image"`
|
|
||||||
Port int `yaml:"port"`
|
|
||||||
Healthcheck string `yaml:"healthcheck"`
|
|
||||||
Env map[string]string `yaml:"env"`
|
|
||||||
Volumes map[string]string `yaml:"volumes"`
|
|
||||||
Stages map[string]StageDef `yaml:"stages"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageDef defines a deployment stage from the seed file.
|
|
||||||
type StageDef struct {
|
|
||||||
TagPattern string `yaml:"tag_pattern"`
|
|
||||||
AutoDeploy bool `yaml:"auto_deploy"`
|
|
||||||
MaxInstances int `yaml:"max_instances"`
|
|
||||||
Confirm bool `yaml:"confirm"`
|
|
||||||
PromoteFrom string `yaml:"promote_from"`
|
|
||||||
Subdomain string `yaml:"subdomain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadSeedFile reads and parses the YAML seed config from the given path.
|
// LoadSeedFile reads and parses the YAML seed config from the given path.
|
||||||
func LoadSeedFile(path string) (SeedConfig, error) {
|
func LoadSeedFile(path string) (SeedConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
@@ -88,25 +68,5 @@ func validate(cfg SeedConfig) error {
|
|||||||
if cfg.Global.Domain == "" {
|
if cfg.Global.Domain == "" {
|
||||||
return fmt.Errorf("global.domain is required")
|
return fmt.Errorf("global.domain is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, proj := range cfg.Projects {
|
|
||||||
if proj.Image == "" {
|
|
||||||
return fmt.Errorf("project %q: image is required", name)
|
|
||||||
}
|
|
||||||
if proj.Registry != "" {
|
|
||||||
if _, ok := cfg.Registries[proj.Registry]; !ok {
|
|
||||||
return fmt.Errorf("project %q: references unknown registry %q", name, proj.Registry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for stageName, stage := range proj.Stages {
|
|
||||||
if stage.TagPattern == "" {
|
|
||||||
return fmt.Errorf("project %q stage %q: tag_pattern is required", name, stageName)
|
|
||||||
}
|
|
||||||
if stage.MaxInstances < 0 {
|
|
||||||
return fmt.Errorf("project %q stage %q: max_instances must be >= 0", name, stageName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
@@ -9,8 +8,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ExportConfig reads the current database state and produces a SeedConfig YAML
|
// ExportConfig reads the current database state and produces a SeedConfig YAML
|
||||||
// representation. Credential fields (tokens, passwords) are exported as placeholder
|
// representation. Credential fields (tokens, passwords) are exported as
|
||||||
// strings since they are encrypted in the database.
|
// placeholder strings since they are encrypted in the database. After the hard
|
||||||
|
// cutover, only global settings + registries are exported — workloads and
|
||||||
|
// triggers are created through the API, not via seed files.
|
||||||
func ExportConfig(db *store.Store) ([]byte, error) {
|
func ExportConfig(db *store.Store) ([]byte, error) {
|
||||||
cfg, err := buildSeedConfig(db)
|
cfg, err := buildSeedConfig(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -25,7 +26,6 @@ func ExportConfig(db *store.Store) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildSeedConfig constructs a SeedConfig from the current database state.
|
|
||||||
func buildSeedConfig(db *store.Store) (SeedConfig, error) {
|
func buildSeedConfig(db *store.Store) (SeedConfig, error) {
|
||||||
settings, err := db.GetSettings()
|
settings, err := db.GetSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,11 +37,6 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) {
|
|||||||
return SeedConfig{}, fmt.Errorf("get registries: %w", err)
|
return SeedConfig{}, fmt.Errorf("get registries: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
projects, err := db.GetAllProjects()
|
|
||||||
if err != nil {
|
|
||||||
return SeedConfig{}, fmt.Errorf("get projects: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := SeedConfig{
|
cfg := SeedConfig{
|
||||||
Global: GlobalConfig{
|
Global: GlobalConfig{
|
||||||
Domain: settings.Domain,
|
Domain: settings.Domain,
|
||||||
@@ -56,7 +51,6 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
Registries: make(map[string]RegistryDef),
|
Registries: make(map[string]RegistryDef),
|
||||||
Projects: make(map[string]ProjectDef),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, reg := range registries {
|
for _, reg := range registries {
|
||||||
@@ -67,52 +61,5 @@ func buildSeedConfig(db *store.Store) (SeedConfig, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, proj := range projects {
|
|
||||||
stages, err := db.GetStagesByProjectID(proj.ID)
|
|
||||||
if err != nil {
|
|
||||||
return SeedConfig{}, fmt.Errorf("get stages for project %s: %w", proj.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stageDefs := make(map[string]StageDef)
|
|
||||||
for _, st := range stages {
|
|
||||||
stageDefs[st.Name] = StageDef{
|
|
||||||
TagPattern: st.TagPattern,
|
|
||||||
AutoDeploy: st.AutoDeploy,
|
|
||||||
MaxInstances: st.MaxInstances,
|
|
||||||
Confirm: st.Confirm,
|
|
||||||
PromoteFrom: st.PromoteFrom,
|
|
||||||
Subdomain: st.Subdomain,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
envMap := parseJSONMap(proj.Env)
|
|
||||||
volMap := parseJSONMap(proj.Volumes)
|
|
||||||
|
|
||||||
cfg.Projects[proj.Name] = ProjectDef{
|
|
||||||
Registry: proj.Registry,
|
|
||||||
Image: proj.Image,
|
|
||||||
Port: proj.Port,
|
|
||||||
Healthcheck: proj.Healthcheck,
|
|
||||||
Env: envMap,
|
|
||||||
Volumes: volMap,
|
|
||||||
Stages: stageDefs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJSONMap safely parses a JSON-encoded map string. Returns nil on failure.
|
|
||||||
func parseJSONMap(jsonStr string) map[string]string {
|
|
||||||
if jsonStr == "" || jsonStr == "{}" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var m map[string]string
|
|
||||||
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(m) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-70
@@ -1,7 +1,11 @@
|
|||||||
|
// Package config loads and exports seed configuration. After the hard
|
||||||
|
// cutover the seed shape covers only what survives the workload-first
|
||||||
|
// refactor: global settings and registries. Project / stage / volume
|
||||||
|
// seeding is gone; the new way to bootstrap a workload is the plugin
|
||||||
|
// pipeline (POST /api/workloads).
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,7 +16,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ImportSeed loads the seed YAML file and imports its contents into the store.
|
// ImportSeed loads the seed YAML file and imports its contents into the store.
|
||||||
// Import is idempotent: it is skipped if any projects or registries already exist.
|
// Import is idempotent: it is skipped if any registries already exist.
|
||||||
// Credential fields (registry tokens, NPM password) are encrypted before storage.
|
// Credential fields (registry tokens, NPM password) are encrypted before storage.
|
||||||
func ImportSeed(db *store.Store, seedPath string) error {
|
func ImportSeed(db *store.Store, seedPath string) error {
|
||||||
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
|
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
|
||||||
@@ -47,16 +51,10 @@ func ImportSeed(db *store.Store, seedPath string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isPopulated returns true if the store already contains projects or registries.
|
// isPopulated returns true if the store already contains any registries.
|
||||||
|
// Workloads / apps are intentionally not consulted — they get created
|
||||||
|
// through the API, not seeded.
|
||||||
func isPopulated(db *store.Store) (bool, error) {
|
func isPopulated(db *store.Store) (bool, error) {
|
||||||
projects, err := db.GetAllProjects()
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("get projects: %w", err)
|
|
||||||
}
|
|
||||||
if len(projects) > 0 {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
registries, err := db.GetAllRegistries()
|
registries, err := db.GetAllRegistries()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("get registries: %w", err)
|
return false, fmt.Errorf("get registries: %w", err)
|
||||||
@@ -64,8 +62,7 @@ func isPopulated(db *store.Store) (bool, error) {
|
|||||||
return len(registries) > 0, nil
|
return len(registries) > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// importAll runs the full seed import inside a database transaction.
|
// importAll runs the seed import inside a database transaction.
|
||||||
// Uses raw SQL within the transaction so all inserts are atomic.
|
|
||||||
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
||||||
tx, err := db.DB().Begin()
|
tx, err := db.DB().Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,7 +72,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
|||||||
|
|
||||||
timestamp := store.Now()
|
timestamp := store.Now()
|
||||||
|
|
||||||
// Import registries first — projects reference them by name.
|
|
||||||
for name, regDef := range cfg.Registries {
|
for name, regDef := range cfg.Registries {
|
||||||
encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token)
|
encToken, err := crypto.EncryptIfNotEmpty(encKey, regDef.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -93,50 +89,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import projects and their stages.
|
|
||||||
for name, projDef := range cfg.Projects {
|
|
||||||
envJSON, err := mapToJSON(projDef.Env)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode env for project %q: %w", name, err)
|
|
||||||
}
|
|
||||||
volJSON, err := mapToJSON(projDef.Volumes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("encode volumes for project %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
projectID := uuid.New().String()
|
|
||||||
_, err = tx.Exec(
|
|
||||||
`INSERT INTO projects (id, name, registry, image, port, healthcheck, env, volumes, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
projectID, name, projDef.Registry, projDef.Image, projDef.Port,
|
|
||||||
projDef.Healthcheck, envJSON, volJSON, timestamp, timestamp,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("insert project %q: %w", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for stageName, stageDef := range projDef.Stages {
|
|
||||||
maxInstances := stageDef.MaxInstances
|
|
||||||
if maxInstances == 0 {
|
|
||||||
maxInstances = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
stageID := uuid.New().String()
|
|
||||||
_, err = tx.Exec(
|
|
||||||
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
stageID, projectID, stageName, stageDef.TagPattern,
|
|
||||||
store.BoolToInt(stageDef.AutoDeploy), maxInstances,
|
|
||||||
store.BoolToInt(stageDef.Confirm), stageDef.PromoteFrom,
|
|
||||||
stageDef.Subdomain, timestamp, timestamp,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("insert stage %q for project %q: %w", stageName, name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import global settings — encrypt NPM password.
|
|
||||||
encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password)
|
encNpmPassword, err := crypto.EncryptIfNotEmpty(encKey, cfg.Global.Npm.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("encrypt npm password: %w", err)
|
return fmt.Errorf("encrypt npm password: %w", err)
|
||||||
@@ -166,15 +118,3 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps.
|
|
||||||
func mapToJSON(m map[string]string) (string, error) {
|
|
||||||
if m == nil {
|
|
||||||
return "{}", nil
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(m)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(b), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
package deployer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// blueGreenDeploy performs a zero-downtime deployment:
|
|
||||||
// 1. Start new container (green)
|
|
||||||
// 2. Health check green
|
|
||||||
// 3. Swap NPM proxy to point to green
|
|
||||||
// 4. Stop old container (blue)
|
|
||||||
//
|
|
||||||
// If the new container fails health check, it is removed and the old one stays.
|
|
||||||
func (d *Deployer) blueGreenDeploy(
|
|
||||||
ctx context.Context,
|
|
||||||
project store.Project,
|
|
||||||
stage store.Stage,
|
|
||||||
settings store.Settings,
|
|
||||||
deployID string,
|
|
||||||
imageTag string,
|
|
||||||
) (string, string, string, error) {
|
|
||||||
// Find existing running container for this stage (the "blue" container).
|
|
||||||
existing, err := d.store.ListContainersByStageID(stage.ID)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("get existing containers: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var blueContainer *store.Container
|
|
||||||
for _, c := range existing {
|
|
||||||
if c.State == "running" {
|
|
||||||
cCopy := c
|
|
||||||
blueContainer = &cCopy
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Pull image.
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: pulling image %s:%s", project.Image, imageTag), "info")
|
|
||||||
|
|
||||||
authConfig, err := d.buildRegistryAuth(project)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("build registry auth: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("pull image: %w", err)
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, "Image pulled successfully", "info")
|
|
||||||
|
|
||||||
// Step 2: Ensure network.
|
|
||||||
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", "", fmt.Errorf("ensure network: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Create and start green container.
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
|
|
||||||
|
|
||||||
instanceID := uuid.New().String()
|
|
||||||
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
|
||||||
workloadID := d.resolveProjectWorkloadID(project.ID)
|
|
||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
|
||||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
|
||||||
envVars := d.mergeEnvVars(project, stage.ID)
|
|
||||||
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
|
|
||||||
|
|
||||||
containerCfg := docker.ContainerConfig{
|
|
||||||
Name: containerName,
|
|
||||||
Image: project.Image + ":" + imageTag,
|
|
||||||
Env: envVars,
|
|
||||||
ExposedPorts: []string{portStr},
|
|
||||||
NetworkName: settings.Network,
|
|
||||||
NetworkID: networkID,
|
|
||||||
WorkloadID: workloadID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindProject),
|
|
||||||
Role: stage.Name,
|
|
||||||
Mounts: mounts,
|
|
||||||
CpuLimit: stage.CpuLimit,
|
|
||||||
MemoryLimit: stage.MemoryLimit,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
|
|
||||||
if stage.EnableProxy {
|
|
||||||
fqdn := subdomain + "." + settings.Domain
|
|
||||||
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
|
|
||||||
if containerCfg.Labels == nil {
|
|
||||||
containerCfg.Labels = make(map[string]string)
|
|
||||||
}
|
|
||||||
for k, v := range proxyLabels {
|
|
||||||
containerCfg.Labels[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
|
|
||||||
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", instanceID, fmt.Errorf("create container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create container row.
|
|
||||||
row, err := d.store.CreateContainer(store.Container{
|
|
||||||
ID: instanceID,
|
|
||||||
WorkloadID: workloadID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindProject),
|
|
||||||
Role: stage.Name,
|
|
||||||
StageID: stage.ID,
|
|
||||||
ContainerID: containerID,
|
|
||||||
ImageRef: project.Image + ":" + imageTag,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Host: "local",
|
|
||||||
State: "stopped",
|
|
||||||
Port: project.Port,
|
|
||||||
Subdomain: subdomain,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return containerID, "", instanceID, fmt.Errorf("create container row: %w", err)
|
|
||||||
}
|
|
||||||
instanceID = row.ID
|
|
||||||
|
|
||||||
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
|
|
||||||
slog.Warn("link deploy to container", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info")
|
|
||||||
if err := d.docker.StartContainer(ctx, containerID); err != nil {
|
|
||||||
return containerID, "", instanceID, fmt.Errorf("start container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.store.UpdateContainerState(instanceID, "running"); err != nil {
|
|
||||||
slog.Warn("update container state", "error", err)
|
|
||||||
}
|
|
||||||
row.State = "running"
|
|
||||||
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
|
|
||||||
|
|
||||||
// Step 4: Health check the green container.
|
|
||||||
if project.Healthcheck != "" {
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
|
|
||||||
|
|
||||||
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck)
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info")
|
|
||||||
|
|
||||||
if err := d.health.Check(ctx, healthURL); err != nil {
|
|
||||||
return containerID, "", instanceID, fmt.Errorf("health check green: %w", err)
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, "Blue-green: green health check passed", "info")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Swap proxy to green.
|
|
||||||
var proxyRouteID string
|
|
||||||
if stage.EnableProxy {
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
|
||||||
|
|
||||||
accessListID := settings.NpmAccessListID
|
|
||||||
if project.NpmAccessListID > 0 {
|
|
||||||
accessListID = project.NpmAccessListID
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID)
|
|
||||||
if err != nil {
|
|
||||||
return containerID, "", instanceID, fmt.Errorf("configure proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
row.ProxyRouteID = proxyRouteID
|
|
||||||
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
|
|
||||||
|
|
||||||
// Create/update DNS record for the green container.
|
|
||||||
fqdn := subdomain + "." + settings.Domain
|
|
||||||
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
|
|
||||||
} else {
|
|
||||||
d.logDeploy(deployID, "Blue-green: proxy skipped (disabled for this stage)", "info")
|
|
||||||
}
|
|
||||||
|
|
||||||
row.Subdomain = subdomain
|
|
||||||
if err := d.store.UpdateContainer(row); err != nil {
|
|
||||||
slog.Warn("update container with proxy ID", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Stop the blue container.
|
|
||||||
if blueContainer != nil {
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue container %s (tag: %s)", blueContainer.ID, blueContainer.ImageTag), "info")
|
|
||||||
if err := d.removeContainer(ctx, *blueContainer, settings); err != nil {
|
|
||||||
// Non-fatal: log but continue. Green is already serving traffic.
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Blue-green: warning: failed to remove blue container: %v", err), "warn")
|
|
||||||
} else {
|
|
||||||
d.logDeploy(deployID, "Blue-green: blue container removed", "info")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return containerID, proxyRouteID, instanceID, nil
|
|
||||||
}
|
|
||||||
+19
-796
@@ -1,16 +1,15 @@
|
|||||||
|
// Package deployer dispatches plugin-native Source deploys. The legacy
|
||||||
|
// project-pipeline lived here until the hard cutover; what remains is a
|
||||||
|
// thin holder for the Deployer's shared dependencies that `dispatch.go`
|
||||||
|
// hands to every Source via PluginDeps().
|
||||||
package deployer
|
package deployer
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/crypto"
|
|
||||||
"github.com/alexei/tinyforge/internal/dns"
|
"github.com/alexei/tinyforge/internal/dns"
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
"github.com/alexei/tinyforge/internal/docker"
|
||||||
"github.com/alexei/tinyforge/internal/events"
|
"github.com/alexei/tinyforge/internal/events"
|
||||||
@@ -18,14 +17,11 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/notify"
|
"github.com/alexei/tinyforge/internal/notify"
|
||||||
"github.com/alexei/tinyforge/internal/proxy"
|
"github.com/alexei/tinyforge/internal/proxy"
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
"github.com/alexei/tinyforge/internal/volume"
|
|
||||||
"github.com/moby/moby/api/types/mount"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Deployer orchestrates the full deployment flow: pull image, create container,
|
// Deployer owns the dependency bundle each Source plugin needs at deploy
|
||||||
// start, configure proxy, health check, and handle rollback on failure.
|
// time. The plugin pipeline reaches in via PluginDeps(); see dispatch.go
|
||||||
// It implements both webhook.DeployTriggerer and registry.DeployTriggerer.
|
// for the dispatch surface itself.
|
||||||
type Deployer struct {
|
type Deployer struct {
|
||||||
docker *docker.Client
|
docker *docker.Client
|
||||||
proxy proxy.Provider
|
proxy proxy.Provider
|
||||||
@@ -88,21 +84,20 @@ func (d *Deployer) SetPreDeployBackuper(b PreDeployBackuper) {
|
|||||||
d.backuper = b
|
d.backuper = b
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
|
// MaybeBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB snapshot when
|
||||||
// the setting is enabled. Failures are logged but do not abort the deploy:
|
// the setting is enabled. Failures are logged but do not abort the deploy:
|
||||||
// missing a backup is preferable to refusing to ship a fix.
|
// missing a backup is preferable to refusing to ship a fix. Exposed so
|
||||||
func (d *Deployer) maybeBackupBeforeDeploy(deployID string, settings store.Settings) {
|
// Source plugins can opt into the same behaviour.
|
||||||
|
func (d *Deployer) MaybeBackupBeforeDeploy(deployID string, settings store.Settings) {
|
||||||
if !settings.AutoBackupBeforeDeploy || d.backuper == nil {
|
if !settings.AutoBackupBeforeDeploy || d.backuper == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
backup, err := d.backuper.CreateBackup("pre-deploy")
|
backup, err := d.backuper.CreateBackup("pre-deploy")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err)
|
slog.Warn("pre-deploy backup failed", "deploy_id", deployID, "error", err)
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup failed: %v", err), "warn")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename)
|
slog.Info("pre-deploy backup created", "deploy_id", deployID, "backup_id", backup.ID, "filename", backup.Filename)
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Pre-deploy backup created: %s", backup.Filename), "info")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
|
// SetDNSProvider sets the DNS provider for managing DNS records during deployments.
|
||||||
@@ -113,796 +108,24 @@ func (d *Deployer) SetDNSProvider(provider dns.Provider) {
|
|||||||
d.dns = provider
|
d.dns = provider
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDNS returns the current DNS provider under read lock.
|
|
||||||
func (d *Deployer) getDNS() dns.Provider {
|
|
||||||
d.dnsMu.RLock()
|
|
||||||
defer d.dnsMu.RUnlock()
|
|
||||||
return d.dns
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
|
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
|
||||||
func (d *Deployer) Drain() {
|
func (d *Deployer) Drain() {
|
||||||
d.shuttingDown.Store(true)
|
if !d.shuttingDown.CompareAndSwap(false, true) {
|
||||||
|
// Already draining.
|
||||||
|
}
|
||||||
slog.Info("deployer: draining in-progress deploys")
|
slog.Info("deployer: draining in-progress deploys")
|
||||||
d.activeWg.Wait()
|
d.activeWg.Wait()
|
||||||
slog.Info("deployer: all deploys drained")
|
slog.Info("deployer: all deploys drained")
|
||||||
}
|
}
|
||||||
|
|
||||||
// AsyncTriggerDeploy creates a deploy record and returns the deploy ID immediately,
|
// ShuttingDown reports whether Drain() has been called.
|
||||||
// then runs the full deploy pipeline in a background goroutine. Use this from HTTP handlers
|
func (d *Deployer) ShuttingDown() bool { return d.shuttingDown.Load() }
|
||||||
// to avoid blocking the request. Progress is streamed via SSE.
|
|
||||||
func (d *Deployer) AsyncTriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) (string, error) {
|
|
||||||
if d.shuttingDown.Load() {
|
|
||||||
return "", fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate inputs synchronously so the caller gets immediate feedback.
|
// rejectIfDraining is exposed in case any plugin wants the same hard-stop
|
||||||
project, err := d.store.GetProjectByID(projectID)
|
// behaviour the legacy pipeline used.
|
||||||
if err != nil {
|
func (d *Deployer) rejectIfDraining() error {
|
||||||
return "", fmt.Errorf("get project: %w", err)
|
|
||||||
}
|
|
||||||
stage, err := d.store.GetStageByID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get stage: %w", err)
|
|
||||||
}
|
|
||||||
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
|
|
||||||
return "", fmt.Errorf("promote validation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create deploy record synchronously so caller gets the ID.
|
|
||||||
deploy, err := d.store.CreateDeploy(store.Deploy{
|
|
||||||
ProjectID: projectID,
|
|
||||||
StageID: stageID,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Status: "pending",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("create deploy record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the actual deploy in the background.
|
|
||||||
d.activeWg.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer d.activeWg.Done()
|
|
||||||
// Use a detached context so client disconnect doesn't abort the deploy.
|
|
||||||
bgCtx := context.Background()
|
|
||||||
if err := d.runDeploy(bgCtx, project, stage, deploy.ID, imageTag); err != nil {
|
|
||||||
slog.Error("async deploy failed", "deploy_id", deploy.ID, "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return deploy.ID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// runDeploy is the internal deploy pipeline used by AsyncTriggerDeploy.
|
|
||||||
// It assumes the deploy record already exists and project/stage are validated.
|
|
||||||
func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage store.Stage, deployID string, imageTag string) error {
|
|
||||||
settings, err := d.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
if updateErr := d.store.UpdateDeployStatus(deployID, "failed", err.Error()); updateErr != nil {
|
|
||||||
slog.Warn("update deploy status", "error", updateErr)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("get settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("starting deploy",
|
|
||||||
"deploy_id", deployID,
|
|
||||||
"project", project.Name,
|
|
||||||
"stage", stage.Name,
|
|
||||||
"tag", imageTag,
|
|
||||||
)
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info")
|
|
||||||
|
|
||||||
// Take a pre-deploy DB snapshot if the operator opted in. Runs before
|
|
||||||
// any state-mutating work so a corrupted deploy is recoverable.
|
|
||||||
d.maybeBackupBeforeDeploy(deployID, settings)
|
|
||||||
|
|
||||||
// Enforce max_instances before deploying.
|
|
||||||
if err := d.enforceMaxInstances(ctx, stage, deployID, settings); err != nil {
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error")
|
|
||||||
}
|
|
||||||
|
|
||||||
var containerID string
|
|
||||||
var proxyRouteID string
|
|
||||||
var instanceID string
|
|
||||||
var deployErr error
|
|
||||||
|
|
||||||
if stage.MaxInstances == 1 {
|
|
||||||
containerID, proxyRouteID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deployID, imageTag)
|
|
||||||
} else {
|
|
||||||
containerID, proxyRouteID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deployID, imageTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
if deployErr != nil {
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Deploy failed: %v", deployErr), "error")
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "failed", deployErr.Error())
|
|
||||||
d.rollback(ctx, deployID, containerID, proxyRouteID, instanceID)
|
|
||||||
|
|
||||||
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
|
||||||
d.notifier.SendSigned(url, secret, tier, notify.Event{
|
|
||||||
Type: "deploy_failure",
|
|
||||||
Project: project.Name,
|
|
||||||
Stage: stage.Name,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Error: deployErr.Error(),
|
|
||||||
})
|
|
||||||
|
|
||||||
return fmt.Errorf("deploy failed: %w", deployErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "success", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status to success", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "success", "")
|
|
||||||
|
|
||||||
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
|
||||||
fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain)
|
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Deploy successful: %s", fullURL), "info")
|
|
||||||
|
|
||||||
url, secret, tier := resolveDeployTarget(stage, project, settings)
|
|
||||||
d.notifier.SendSigned(url, secret, tier, notify.Event{
|
|
||||||
Type: "deploy_success",
|
|
||||||
Project: project.Name,
|
|
||||||
Stage: stage.Name,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Subdomain: subdomain,
|
|
||||||
URL: fullURL,
|
|
||||||
})
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveDeployTarget picks the most-specific (URL, secret, tier) for a
|
|
||||||
// deploy notification: stage > project > global. An empty URL at a tier
|
|
||||||
// means "fall through to the next" — never "send unsigned to nowhere". The
|
|
||||||
// secret is always paired with the URL that sourced it, so a stage can sign
|
|
||||||
// even when project and global are unsigned (and vice versa).
|
|
||||||
func resolveDeployTarget(stage store.Stage, project store.Project, settings store.Settings) (string, string, notify.Tier) {
|
|
||||||
if stage.NotificationURL != "" {
|
|
||||||
return stage.NotificationURL, stage.NotificationSecret, notify.TierStage
|
|
||||||
}
|
|
||||||
if project.NotificationURL != "" {
|
|
||||||
return project.NotificationURL, project.NotificationSecret, notify.TierProject
|
|
||||||
}
|
|
||||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
|
|
||||||
// It validates inputs, creates a deploy record, and delegates to runDeploy.
|
|
||||||
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
|
|
||||||
if d.shuttingDown.Load() {
|
if d.shuttingDown.Load() {
|
||||||
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
||||||
}
|
}
|
||||||
|
|
||||||
d.activeWg.Add(1)
|
|
||||||
defer d.activeWg.Done()
|
|
||||||
|
|
||||||
project, err := d.store.GetProjectByID(projectID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get project: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stage, err := d.store.GetStageByID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get stage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
|
|
||||||
return fmt.Errorf("promote validation: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deploy, err := d.store.CreateDeploy(store.Deploy{
|
|
||||||
ProjectID: projectID,
|
|
||||||
StageID: stageID,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Status: "pending",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create deploy record: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeDeploy runs the deploy pipeline steps and returns rollback-relevant state.
|
|
||||||
// It returns (containerID, proxyRouteID, instanceID, error).
|
|
||||||
func (d *Deployer) executeDeploy(
|
|
||||||
ctx context.Context,
|
|
||||||
project store.Project,
|
|
||||||
stage store.Stage,
|
|
||||||
settings store.Settings,
|
|
||||||
deployID string,
|
|
||||||
imageTag string,
|
|
||||||
) (string, string, string, error) {
|
|
||||||
var containerID string
|
|
||||||
var proxyRouteID string
|
|
||||||
var instanceID string
|
|
||||||
|
|
||||||
// Step 1: Pull image.
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info")
|
|
||||||
|
|
||||||
authConfig, err := d.buildRegistryAuth(project)
|
|
||||||
if err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("build registry auth: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("pull image: %w", err)
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, "Image pulled successfully", "info")
|
|
||||||
|
|
||||||
// Step 2: Ensure network exists.
|
|
||||||
if settings.Network == "" {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("docker network not configured in settings")
|
|
||||||
}
|
|
||||||
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
|
|
||||||
if err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("ensure network: %w", err)
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Network %s ready (ID: %s)", settings.Network, truncateID(networkID)), "info")
|
|
||||||
|
|
||||||
// Step 3: Create and start container.
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
|
|
||||||
|
|
||||||
// Pre-generate instance ID so it can be set as a container label.
|
|
||||||
instanceID = uuid.New().String()
|
|
||||||
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
|
|
||||||
workloadID := d.resolveProjectWorkloadID(project.ID)
|
|
||||||
|
|
||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
|
||||||
|
|
||||||
// Remove any stale container with the same name (e.g., from a previous failed deploy).
|
|
||||||
_ = d.docker.RemoveContainer(ctx, containerName, true)
|
|
||||||
|
|
||||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
|
||||||
envVars := d.mergeEnvVars(project, stage.ID)
|
|
||||||
mounts := d.computeVolumeMounts(project.ID, project.Name, stage.Name, imageTag, settings.BaseVolumePath)
|
|
||||||
|
|
||||||
containerCfg := docker.ContainerConfig{
|
|
||||||
Name: containerName,
|
|
||||||
Image: project.Image + ":" + imageTag,
|
|
||||||
Env: envVars,
|
|
||||||
ExposedPorts: []string{portStr},
|
|
||||||
NetworkName: settings.Network,
|
|
||||||
NetworkID: networkID,
|
|
||||||
WorkloadID: workloadID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindProject),
|
|
||||||
Role: stage.Name,
|
|
||||||
Mounts: mounts,
|
|
||||||
CpuLimit: stage.CpuLimit,
|
|
||||||
MemoryLimit: stage.MemoryLimit,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set proxy labels for providers that use Docker labels (e.g., Traefik).
|
|
||||||
if stage.EnableProxy {
|
|
||||||
fqdn := subdomain + "." + settings.Domain
|
|
||||||
if proxyLabels := d.proxy.ContainerLabels(fqdn, project.Port); proxyLabels != nil {
|
|
||||||
if containerCfg.Labels == nil {
|
|
||||||
containerCfg.Labels = make(map[string]string)
|
|
||||||
}
|
|
||||||
for k, v := range proxyLabels {
|
|
||||||
containerCfg.Labels[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Creating container %s", containerName), "info")
|
|
||||||
containerID, err = d.docker.CreateContainer(ctx, containerCfg)
|
|
||||||
if err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("create container: %w", err)
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Container created (ID: %s)", truncateID(containerID)), "info")
|
|
||||||
|
|
||||||
// Create container row with the pre-generated ID. The deployer is the
|
|
||||||
// authoritative writer until the next reconciler tick — it's important
|
|
||||||
// the row exists before StartContainer so a fast tick doesn't see an
|
|
||||||
// orphan and mark it missing.
|
|
||||||
row, err := d.store.CreateContainer(store.Container{
|
|
||||||
ID: instanceID,
|
|
||||||
WorkloadID: workloadID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindProject),
|
|
||||||
Role: stage.Name,
|
|
||||||
StageID: stage.ID,
|
|
||||||
ContainerID: containerID,
|
|
||||||
ImageRef: project.Image + ":" + imageTag,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Host: "local",
|
|
||||||
State: "stopped",
|
|
||||||
Port: project.Port,
|
|
||||||
Subdomain: subdomain,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("create container row: %w", err)
|
|
||||||
}
|
|
||||||
instanceID = row.ID
|
|
||||||
|
|
||||||
// Link deploy to container row (the existing Deploy.InstanceID column
|
|
||||||
// stores the row ID — same value as before, just a renamed concept).
|
|
||||||
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
|
|
||||||
slog.Warn("link deploy to container", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info")
|
|
||||||
if err := d.docker.StartContainer(ctx, containerID); err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("start container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := d.store.UpdateContainerState(instanceID, "running"); err != nil {
|
|
||||||
slog.Warn("update container state to running", "error", err)
|
|
||||||
}
|
|
||||||
row.State = "running"
|
|
||||||
row.LastSeenAt = store.Now()
|
|
||||||
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
|
|
||||||
d.logDeploy(deployID, "Container started", "info")
|
|
||||||
|
|
||||||
// Step 4: Configure NPM proxy (optional per stage).
|
|
||||||
if stage.EnableProxy {
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
|
|
||||||
|
|
||||||
accessListID := settings.NpmAccessListID
|
|
||||||
if project.NpmAccessListID > 0 {
|
|
||||||
accessListID = project.NpmAccessListID
|
|
||||||
}
|
|
||||||
|
|
||||||
proxyRouteID, err = d.configureProxy(ctx, deployID, settings, containerID, containerName, project.Port, subdomain, accessListID)
|
|
||||||
if err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("configure proxy: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update container row with proxy route ID.
|
|
||||||
row.ProxyRouteID = proxyRouteID
|
|
||||||
row.Subdomain = subdomain
|
|
||||||
if err := d.store.UpdateContainer(row); err != nil {
|
|
||||||
slog.Warn("update container with proxy ID", "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create DNS record for this container.
|
|
||||||
fqdn := subdomain + "." + settings.Domain
|
|
||||||
d.ensureDNS(ctx, fqdn, "instance", instanceID, deployID)
|
|
||||||
} else {
|
|
||||||
d.logDeploy(deployID, "Proxy creation skipped (disabled for this stage)", "info")
|
|
||||||
row.Subdomain = subdomain
|
|
||||||
if err := d.store.UpdateContainer(row); err != nil {
|
|
||||||
slog.Warn("update container", "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Health check.
|
|
||||||
if project.Healthcheck != "" {
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
|
|
||||||
slog.Warn("update deploy status", "error", err)
|
|
||||||
}
|
|
||||||
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
|
|
||||||
|
|
||||||
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck)
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Running health check: %s", healthURL), "info")
|
|
||||||
|
|
||||||
if err := d.health.Check(ctx, healthURL); err != nil {
|
|
||||||
return containerID, proxyRouteID, instanceID, fmt.Errorf("health check: %w", err)
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, "Health check passed", "info")
|
|
||||||
} else {
|
|
||||||
d.logDeploy(deployID, "No health check configured, skipping", "info")
|
|
||||||
}
|
|
||||||
|
|
||||||
return containerID, proxyRouteID, instanceID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// configureProxy creates or updates a proxy route for the deployed container.
|
|
||||||
// Uses the configured proxy.Provider (NPM, Traefik, or None).
|
|
||||||
// In NPM remote mode, uses server_ip + published host port instead of container name.
|
|
||||||
// Returns the proxy route ID string.
|
|
||||||
func (d *Deployer) configureProxy(
|
|
||||||
ctx context.Context,
|
|
||||||
deployID string,
|
|
||||||
settings store.Settings,
|
|
||||||
containerID string,
|
|
||||||
containerName string,
|
|
||||||
containerPort int,
|
|
||||||
subdomain string,
|
|
||||||
accessListID int,
|
|
||||||
) (string, error) {
|
|
||||||
fqdn := subdomain + "." + settings.Domain
|
|
||||||
|
|
||||||
forwardHost := containerName
|
|
||||||
forwardPort := containerPort
|
|
||||||
|
|
||||||
// In NPM remote mode, use server_ip and the published host port.
|
|
||||||
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
|
||||||
if settings.ServerIP == "" {
|
|
||||||
return "", fmt.Errorf("NPM remote mode requires Server IP to be configured in settings")
|
|
||||||
}
|
|
||||||
forwardHost = settings.ServerIP
|
|
||||||
|
|
||||||
hostPort, err := d.docker.InspectContainerPort(ctx, containerID, fmt.Sprintf("%d/tcp", containerPort))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("look up host port for remote NPM: %w", err)
|
|
||||||
}
|
|
||||||
forwardPort = int(hostPort)
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("NPM remote mode: using %s:%d (host port)", forwardHost, forwardPort), "info")
|
|
||||||
}
|
|
||||||
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Configuring proxy (%s): %s -> %s:%d", d.proxy.Name(), fqdn, forwardHost, forwardPort), "info")
|
|
||||||
|
|
||||||
routeID, err := d.proxy.ConfigureRoute(ctx, fqdn, forwardHost, forwardPort, proxy.RouteOptions{
|
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
|
||||||
AccessListID: accessListID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("configure proxy route: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if routeID != "" {
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Proxy route configured (ID: %s)", routeID), "info")
|
|
||||||
}
|
|
||||||
return routeID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// enforceMaxInstances removes the oldest container rows when the stage has
|
|
||||||
// reached its instance limit, making room for the new deploy.
|
|
||||||
func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, deployID string, settings store.Settings) error {
|
|
||||||
if stage.MaxInstances <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
containers, err := d.store.ListContainersByStageID(stage.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get containers for stage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to running/stopped containers (not already failed/removing).
|
|
||||||
var active []store.Container
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.State == "running" || c.State == "stopped" {
|
|
||||||
active = append(active, c)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need room for one more container, so remove the oldest when at limit.
|
|
||||||
removeCount := len(active) - stage.MaxInstances + 1
|
|
||||||
if removeCount <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by created_at ascending (oldest first).
|
|
||||||
sort.Slice(active, func(i, j int) bool {
|
|
||||||
return active[i].CreatedAt < active[j].CreatedAt
|
|
||||||
})
|
|
||||||
|
|
||||||
for i := 0; i < removeCount && i < len(active); i++ {
|
|
||||||
c := active[i]
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Removing oldest container %s (tag: %s) to enforce max_instances=%d", c.ID, c.ImageTag, stage.MaxInstances), "info")
|
|
||||||
|
|
||||||
if err := d.removeContainer(ctx, c, settings); err != nil {
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Failed to remove container %s: %v", c.ID, err), "warn")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Removed container %s", c.ID), "info")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeContainer stops + removes the Docker container, deletes its proxy
|
|
||||||
// route, drops the DNS record, and removes the container row from the store.
|
|
||||||
func (d *Deployer) removeContainer(ctx context.Context, c store.Container, settings store.Settings) error {
|
|
||||||
// Mark as removing.
|
|
||||||
if err := d.store.UpdateContainerState(c.ID, "removing"); err != nil {
|
|
||||||
slog.Warn("update container state to removing", "id", c.ID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove Docker container.
|
|
||||||
if c.ContainerID != "" {
|
|
||||||
if err := d.docker.RemoveContainer(ctx, c.ContainerID, true); err != nil {
|
|
||||||
slog.Warn("remove docker container", "container_id", c.ContainerID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete proxy route.
|
|
||||||
if c.ProxyRouteID != "" {
|
|
||||||
if err := d.proxy.DeleteRoute(ctx, c.ProxyRouteID); err != nil {
|
|
||||||
slog.Warn("delete proxy route", "route_id", c.ProxyRouteID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove DNS record.
|
|
||||||
if c.Subdomain != "" && settings.Domain != "" {
|
|
||||||
fqdn := c.Subdomain + "." + settings.Domain
|
|
||||||
d.removeDNS(ctx, fqdn, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop the container row.
|
|
||||||
if err := d.store.DeleteContainer(c.ID); err != nil && !errors.Is(err, store.ErrNotFound) {
|
|
||||||
return fmt.Errorf("delete container row: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSubdomain generates the subdomain for an instance based on settings and stage config.
|
|
||||||
func (d *Deployer) buildSubdomain(project store.Project, stage store.Stage, settings store.Settings, imageTag string) string {
|
|
||||||
return GenerateTaggedSubdomain(settings.SubdomainPattern, project.Name, stage.Name, imageTag, stage.Subdomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildRegistryAuth constructs the Docker registry auth string for pulling images.
|
|
||||||
// If the project has a registry configured, it looks up the registry token.
|
|
||||||
func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
|
|
||||||
if project.Registry == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reg, err := d.store.GetRegistryByName(project.Registry)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("get registry %s: %w", project.Registry, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if reg.Token != "" {
|
|
||||||
decrypted, err := crypto.Decrypt(d.encKey, reg.Token)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decrypt registry token: %w", err)
|
|
||||||
}
|
|
||||||
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
|
|
||||||
}
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeEnvVars builds the final environment variable list for a container:
|
|
||||||
// 1. Parse project-level env JSON
|
|
||||||
// 2. Overlay with stage-level env overrides (stage wins on key conflict)
|
|
||||||
// 3. Decrypt any encrypted (secret) values
|
|
||||||
// Returns a []string of KEY=VALUE pairs.
|
|
||||||
func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string {
|
|
||||||
// Step 1: Parse project-level env.
|
|
||||||
envMap := make(map[string]string)
|
|
||||||
if project.Env != "" && project.Env != "{}" {
|
|
||||||
var projectEnv map[string]string
|
|
||||||
if err := json.Unmarshal([]byte(project.Env), &projectEnv); err != nil {
|
|
||||||
slog.Warn("parse project env vars", "error", err)
|
|
||||||
} else {
|
|
||||||
for k, v := range projectEnv {
|
|
||||||
envMap[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Overlay with stage-level overrides.
|
|
||||||
stageEnvs, err := d.store.GetStageEnvByStageID(stageID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("get stage env overrides", "stage_id", stageID, "error", err)
|
|
||||||
} else {
|
|
||||||
for _, se := range stageEnvs {
|
|
||||||
value := se.Value
|
|
||||||
if se.Encrypted {
|
|
||||||
// Step 3: Decrypt secret values.
|
|
||||||
decrypted, err := crypto.Decrypt(d.encKey, se.Value)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("decrypt stage env value", "key", se.Key, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
value = decrypted
|
|
||||||
}
|
|
||||||
envMap[se.Key] = value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
vars := make([]string, 0, len(envMap))
|
|
||||||
for k, v := range envMap {
|
|
||||||
vars = append(vars, k+"="+v)
|
|
||||||
}
|
|
||||||
return vars
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
|
|
||||||
// Uses the shared volume.ResolvePath for path resolution.
|
|
||||||
func (d *Deployer) computeVolumeMounts(projectID, projectName, stageName, imageTag, basePath string) []mount.Mount {
|
|
||||||
vols, err := d.store.GetVolumesByProjectID(projectID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("get project volumes", "project_id", projectID, "error", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(vols) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
params := volume.ResolveParams{
|
|
||||||
BasePath: basePath,
|
|
||||||
ProjectName: projectName,
|
|
||||||
StageName: stageName,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
}
|
|
||||||
|
|
||||||
mounts := make([]mount.Mount, 0, len(vols))
|
|
||||||
for _, vol := range vols {
|
|
||||||
scope := vol.Scope
|
|
||||||
if scope == "" {
|
|
||||||
switch vol.Mode {
|
|
||||||
case "isolated":
|
|
||||||
scope = "instance"
|
|
||||||
default:
|
|
||||||
scope = "project"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ephemeral volumes use tmpfs — no host path.
|
|
||||||
if scope == "ephemeral" {
|
|
||||||
mounts = append(mounts, mount.Mount{
|
|
||||||
Type: mount.TypeTmpfs,
|
|
||||||
Target: vol.Target,
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
source, err := volume.ResolvePath(vol, params)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("resolve volume path", "volume_id", vol.ID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
mounts = append(mounts, mount.Mount{
|
|
||||||
Type: mount.TypeBind,
|
|
||||||
Source: source,
|
|
||||||
Target: vol.Target,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return mounts
|
|
||||||
}
|
|
||||||
|
|
||||||
// logDeploy appends a log entry for a deploy and publishes it on the event bus.
|
|
||||||
// Errors are logged to stderr but not propagated.
|
|
||||||
func (d *Deployer) logDeploy(deployID, message, level string) {
|
|
||||||
if err := d.store.AppendDeployLog(deployID, message, level); err != nil {
|
|
||||||
slog.Warn("append deploy log", "error", err)
|
|
||||||
}
|
|
||||||
if d.eventBus != nil {
|
|
||||||
d.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventDeployLog,
|
|
||||||
Payload: events.DeployLogPayload{
|
|
||||||
DeployID: deployID,
|
|
||||||
Message: message,
|
|
||||||
Level: level,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishDeployStatus publishes a deploy status change event on the bus.
|
|
||||||
func (d *Deployer) publishDeployStatus(deployID, projectID, stageID, imageTag, status, deployErr string) {
|
|
||||||
if d.eventBus != nil {
|
|
||||||
d.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventDeployStatus,
|
|
||||||
Payload: events.DeployStatusPayload{
|
|
||||||
DeployID: deployID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
StageID: stageID,
|
|
||||||
ImageTag: imageTag,
|
|
||||||
Status: status,
|
|
||||||
Error: deployErr,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishInstanceStatus publishes an instance status change event on the bus.
|
|
||||||
func (d *Deployer) publishInstanceStatus(instanceID, projectID, stageID, status string) {
|
|
||||||
if d.eventBus != nil {
|
|
||||||
d.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventInstanceStatus,
|
|
||||||
Payload: events.InstanceStatusPayload{
|
|
||||||
InstanceID: instanceID,
|
|
||||||
ProjectID: projectID,
|
|
||||||
StageID: stageID,
|
|
||||||
Status: status,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureDNS creates or updates a DNS record for the given FQDN. Best-effort: logs warnings on failure.
|
|
||||||
func (d *Deployer) ensureDNS(ctx context.Context, fqdn, consumerType, consumerID, deployID string) {
|
|
||||||
dnsProvider := d.getDNS()
|
|
||||||
if dnsProvider == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := d.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("dns: get settings for server IP", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if settings.ServerIP == "" {
|
|
||||||
slog.Warn("dns: server IP not configured, skipping DNS record creation", "fqdn", fqdn)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
recordID, err := dnsProvider.EnsureRecord(ctx, fqdn, settings.ServerIP)
|
|
||||||
if err != nil {
|
|
||||||
msg := fmt.Sprintf("DNS: failed to create/update record for %s: %v", fqdn, err)
|
|
||||||
slog.Warn(msg)
|
|
||||||
if deployID != "" {
|
|
||||||
d.logDeploy(deployID, msg, "warn")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track the record locally.
|
|
||||||
if _, err := d.store.CreateDNSRecord(store.DNSRecord{
|
|
||||||
FQDN: fqdn,
|
|
||||||
ProviderRecordID: recordID,
|
|
||||||
ConsumerType: consumerType,
|
|
||||||
ConsumerID: consumerID,
|
|
||||||
}); err != nil {
|
|
||||||
// May already exist — try updating.
|
|
||||||
if updateErr := d.store.UpdateDNSRecordProviderID(fqdn, recordID); updateErr != nil {
|
|
||||||
slog.Warn("dns: failed to track record", "fqdn", fqdn, "error", updateErr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logMsg := fmt.Sprintf("DNS: record ensured for %s", fqdn)
|
|
||||||
slog.Info(logMsg)
|
|
||||||
if deployID != "" {
|
|
||||||
d.logDeploy(deployID, logMsg, "info")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeDNS deletes a DNS record for the given FQDN. Best-effort: logs warnings on failure.
|
|
||||||
func (d *Deployer) removeDNS(ctx context.Context, fqdn, deployID string) {
|
|
||||||
dnsProvider := d.getDNS()
|
|
||||||
if dnsProvider == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := dnsProvider.DeleteRecord(ctx, fqdn); err != nil {
|
|
||||||
msg := fmt.Sprintf("DNS: failed to delete record for %s: %v", fqdn, err)
|
|
||||||
slog.Warn(msg)
|
|
||||||
if deployID != "" {
|
|
||||||
d.logDeploy(deployID, msg, "warn")
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove local tracking.
|
|
||||||
if err := d.store.DeleteDNSRecord(fqdn); err != nil {
|
|
||||||
slog.Warn("dns: failed to remove tracking record", "fqdn", fqdn, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logMsg := fmt.Sprintf("DNS: record deleted for %s", fqdn)
|
|
||||||
slog.Info(logMsg)
|
|
||||||
if deployID != "" {
|
|
||||||
d.logDeploy(deployID, logMsg, "info")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateID safely truncates a Docker ID to 12 characters for display.
|
|
||||||
func truncateID(id string) string {
|
|
||||||
if len(id) > 12 {
|
|
||||||
return id[:12]
|
|
||||||
}
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveProjectWorkloadID returns the workload ID paired with a project.
|
|
||||||
// Backfill-on-boot guarantees the row exists, so this is essentially a lookup.
|
|
||||||
// On miss (defensive), it logs and returns empty so the caller can decide.
|
|
||||||
func (d *Deployer) resolveProjectWorkloadID(projectID string) string {
|
|
||||||
w, err := d.store.GetWorkloadByRef(store.WorkloadKindProject, projectID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("resolve project workload", "project_id", projectID, "error", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return w.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
package deployer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// validatePromoteFrom checks that a tag is running in the promote_from stage
|
|
||||||
// before allowing it to be deployed to the target stage.
|
|
||||||
// Returns nil if no promote_from is configured or if the tag is eligible.
|
|
||||||
func (d *Deployer) validatePromoteFrom(stage store.Stage, imageTag string) error {
|
|
||||||
if stage.PromoteFrom == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the source stage by name within the same project.
|
|
||||||
stages, err := d.store.GetStagesByProjectID(stage.ProjectID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get stages for project: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var sourceStage *store.Stage
|
|
||||||
for _, s := range stages {
|
|
||||||
if s.Name == stage.PromoteFrom {
|
|
||||||
sCopy := s
|
|
||||||
sourceStage = &sCopy
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if sourceStage == nil {
|
|
||||||
return fmt.Errorf("promote_from stage %q not found in project", stage.PromoteFrom)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the tag is running in the source stage.
|
|
||||||
containers, err := d.store.ListContainersByStageID(sourceStage.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get containers for source stage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.ImageTag == imageTag && (c.State == "running" || c.State == "stopped") {
|
|
||||||
return nil // Tag found in source stage, promotion is allowed.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("tag %q is not running in stage %q; promotion denied", imageTag, stage.PromoteFrom)
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
package deployer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/notify"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestResolveDeployTarget locks the stage→project→global precedence. The
|
|
||||||
// most-specific tier with a non-empty URL wins, and the secret travels
|
|
||||||
// with the URL that sourced it (so a stage can sign even when project and
|
|
||||||
// global are unsigned). A regression here misroutes notifications and
|
|
||||||
// silently leaks events to the wrong receiver — worth catching.
|
|
||||||
func TestResolveDeployTarget(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
stage store.Stage
|
|
||||||
project store.Project
|
|
||||||
settings store.Settings
|
|
||||||
wantURL string
|
|
||||||
wantSec string
|
|
||||||
wantTier notify.Tier
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "stage wins when set",
|
|
||||||
stage: store.Stage{NotificationURL: "https://stage.example/wh", NotificationSecret: "stage-key"},
|
|
||||||
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
|
|
||||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
|
||||||
wantURL: "https://stage.example/wh",
|
|
||||||
wantSec: "stage-key",
|
|
||||||
wantTier: notify.TierStage,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stage URL empty → project wins",
|
|
||||||
stage: store.Stage{NotificationURL: "", NotificationSecret: "stage-key"}, // secret without URL ignored
|
|
||||||
project: store.Project{NotificationURL: "https://project.example/wh", NotificationSecret: "project-key"},
|
|
||||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
|
||||||
wantURL: "https://project.example/wh",
|
|
||||||
wantSec: "project-key",
|
|
||||||
wantTier: notify.TierProject,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stage and project empty → global wins",
|
|
||||||
stage: store.Stage{},
|
|
||||||
project: store.Project{},
|
|
||||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
|
||||||
wantURL: "https://global.example/wh",
|
|
||||||
wantSec: "global-key",
|
|
||||||
wantTier: notify.TierSettings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "all empty → returns settings tier with empty URL (caller skips)",
|
|
||||||
stage: store.Stage{},
|
|
||||||
project: store.Project{},
|
|
||||||
settings: store.Settings{},
|
|
||||||
wantURL: "",
|
|
||||||
wantSec: "",
|
|
||||||
wantTier: notify.TierSettings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "stage signs even when global is unsigned",
|
|
||||||
stage: store.Stage{
|
|
||||||
NotificationURL: "https://stage.example/wh",
|
|
||||||
NotificationSecret: "stage-only-key",
|
|
||||||
},
|
|
||||||
project: store.Project{},
|
|
||||||
settings: store.Settings{NotificationURL: "https://global.example/wh"},
|
|
||||||
wantURL: "https://stage.example/wh",
|
|
||||||
wantSec: "stage-only-key",
|
|
||||||
wantTier: notify.TierStage,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
gotURL, gotSec, gotTier := resolveDeployTarget(tc.stage, tc.project, tc.settings)
|
|
||||||
if gotURL != tc.wantURL {
|
|
||||||
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
|
|
||||||
}
|
|
||||||
if gotSec != tc.wantSec {
|
|
||||||
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
|
|
||||||
}
|
|
||||||
if gotTier != tc.wantTier {
|
|
||||||
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package deployer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
// rollback cleans up a failed deployment by removing the container,
|
|
||||||
// deleting the proxy route, and updating the instance status.
|
|
||||||
// Errors during rollback are logged but do not prevent other cleanup steps.
|
|
||||||
func (d *Deployer) rollback(ctx context.Context, deployID string, containerID string, proxyRouteID string, instanceID string) {
|
|
||||||
d.logDeploy(deployID, "Rolling back failed deployment", "warn")
|
|
||||||
|
|
||||||
// Remove the container if it was created.
|
|
||||||
if containerID != "" {
|
|
||||||
if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil {
|
|
||||||
slog.Warn("rollback: remove container", "container_id", containerID, "error", err)
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error")
|
|
||||||
} else {
|
|
||||||
d.logDeploy(deployID, "Rollback: container removed", "info")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the proxy route if it was created.
|
|
||||||
if proxyRouteID != "" {
|
|
||||||
if err := d.proxy.DeleteRoute(ctx, proxyRouteID); err != nil {
|
|
||||||
slog.Warn("rollback: delete proxy route", "route_id", proxyRouteID, "error", err)
|
|
||||||
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy route: %v", err), "error")
|
|
||||||
} else {
|
|
||||||
d.logDeploy(deployID, "Rollback: proxy route deleted", "info")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up DNS record if the container had a subdomain. instanceID is
|
|
||||||
// the container row ID (same UUID either way) — read from containers.
|
|
||||||
if instanceID != "" {
|
|
||||||
c, err := d.store.GetContainerByID(instanceID)
|
|
||||||
if err == nil && c.Subdomain != "" {
|
|
||||||
settings, settingsErr := d.store.GetSettings()
|
|
||||||
if settingsErr != nil {
|
|
||||||
slog.Warn("rollback: failed to get settings for DNS cleanup", "error", settingsErr)
|
|
||||||
} else if settings.Domain != "" {
|
|
||||||
fqdn := c.Subdomain + "." + settings.Domain
|
|
||||||
d.removeDNS(ctx, fqdn, deployID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark the container row as failed if it was created.
|
|
||||||
if instanceID != "" {
|
|
||||||
if err := d.store.UpdateContainerState(instanceID, "failed"); err != nil {
|
|
||||||
slog.Warn("rollback: update container state", "id", instanceID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark deploy as rolled back.
|
|
||||||
if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil {
|
|
||||||
slog.Warn("rollback: update deploy status", "deploy_id", deployID, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d.logDeploy(deployID, "Rollback complete", "info")
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
package deployer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
// maxSubdomainLen is the maximum length of a single DNS label (RFC 1035).
|
|
||||||
const maxSubdomainLen = 63
|
|
||||||
|
|
||||||
// invalidDNSChars matches characters not allowed in a DNS label.
|
|
||||||
var invalidDNSChars = regexp.MustCompile(`[^a-z0-9-]`)
|
|
||||||
|
|
||||||
// GenerateSubdomain builds a subdomain string from the given pattern and parameters.
|
|
||||||
// The pattern may contain {stage}, {project}, and {tag} placeholders.
|
|
||||||
// If the stage has a custom subdomain override, that value is used instead of the pattern.
|
|
||||||
func GenerateSubdomain(pattern, project, stage, tag, stageSubdomain string) string {
|
|
||||||
if stageSubdomain != "" {
|
|
||||||
return SanitizeDNSLabel(stageSubdomain)
|
|
||||||
}
|
|
||||||
|
|
||||||
result := pattern
|
|
||||||
result = strings.ReplaceAll(result, "{stage}", stage)
|
|
||||||
result = strings.ReplaceAll(result, "{project}", project)
|
|
||||||
result = strings.ReplaceAll(result, "{tag}", tag)
|
|
||||||
|
|
||||||
return SanitizeDNSLabel(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GenerateTaggedSubdomain builds a subdomain that includes the tag for multi-instance support.
|
|
||||||
// It appends "-{sanitized_tag}" to the base subdomain.
|
|
||||||
func GenerateTaggedSubdomain(pattern, project, stage, tag, stageSubdomain string) string {
|
|
||||||
base := GenerateSubdomain(pattern, project, stage, "", stageSubdomain)
|
|
||||||
sanitizedTag := SanitizeDNSLabel(tag)
|
|
||||||
|
|
||||||
if sanitizedTag == "" {
|
|
||||||
return base
|
|
||||||
}
|
|
||||||
|
|
||||||
combined := base + "-" + sanitizedTag
|
|
||||||
return truncateDNSLabel(combined)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SanitizeDNSLabel converts an arbitrary string into a valid DNS label.
|
|
||||||
// It lowercases, replaces dots and invalid characters with hyphens,
|
|
||||||
// collapses consecutive hyphens, trims leading/trailing hyphens, and truncates.
|
|
||||||
func SanitizeDNSLabel(s string) string {
|
|
||||||
s = strings.ToLower(s)
|
|
||||||
s = strings.ReplaceAll(s, ".", "-")
|
|
||||||
s = invalidDNSChars.ReplaceAllString(s, "-")
|
|
||||||
s = collapseHyphens(s)
|
|
||||||
s = strings.Trim(s, "-")
|
|
||||||
return truncateDNSLabel(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// collapseHyphens replaces consecutive hyphens with a single hyphen.
|
|
||||||
func collapseHyphens(s string) string {
|
|
||||||
prev := false
|
|
||||||
var b strings.Builder
|
|
||||||
b.Grow(len(s))
|
|
||||||
|
|
||||||
for _, r := range s {
|
|
||||||
if r == '-' {
|
|
||||||
if !prev {
|
|
||||||
b.WriteRune(r)
|
|
||||||
}
|
|
||||||
prev = true
|
|
||||||
} else {
|
|
||||||
b.WriteRune(r)
|
|
||||||
prev = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return b.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateDNSLabel truncates a label to maxSubdomainLen characters,
|
|
||||||
// ensuring it does not end with a hyphen after truncation.
|
|
||||||
func truncateDNSLabel(s string) string {
|
|
||||||
if len(s) <= maxSubdomainLen {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
s = s[:maxSubdomainLen]
|
|
||||||
return strings.TrimRight(s, "-")
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
// Package reconciler keeps the normalized containers index in sync with the
|
// Package reconciler keeps the normalized containers index in sync with the
|
||||||
// Docker daemon. It runs on a tick (and one-shot at boot) — for every
|
// Docker daemon. It runs on a tick (and one-shot at boot) — for every
|
||||||
// Tinyforge-managed container in `docker ps`, it dispatches to a workload by
|
// Tinyforge-managed container in `docker ps`, it resolves a workload by the
|
||||||
// labels and writes a Container row through ReconcileContainer (which only
|
// canonical workload-id label and writes a Container row through
|
||||||
// touches Docker-derived fields on conflict, never deployer-owned columns
|
// ReconcileContainer (which only touches Docker-derived fields on conflict,
|
||||||
// like subdomain / proxy_route_id / npm_proxy_id / image_tag / stage_id).
|
// never deployer-owned columns like subdomain / proxy_route_id /
|
||||||
// Rows whose Docker container ID is no longer present are flipped to
|
// npm_proxy_id / image_tag / stage_id). Rows whose Docker container ID is no
|
||||||
// state='missing'.
|
// longer present are flipped to state='missing'.
|
||||||
//
|
//
|
||||||
// Dispatch precedence (a container with multiple matching labels is dispatched
|
// Only the tinyforge.workload.id label is honored after the hard cutover —
|
||||||
// by the first match in this order):
|
// every Source plugin labels its containers with the workload identity at
|
||||||
// 1. tinyforge.workload.id label (canonical, new)
|
// create time. The legacy tinyforge.static-site / compose-project paths
|
||||||
// 2. tinyforge.static-site label (legacy site — joins via static_sites)
|
// were dropped along with the static_sites / stacks tables.
|
||||||
// 3. com.docker.compose.project (stack — joins via Stack.ComposeProjectName)
|
|
||||||
//
|
|
||||||
// The legacy tinyforge.instance-id path was removed when the deployer was
|
|
||||||
// rewritten to use Container natively — every Tinyforge-managed project
|
|
||||||
// container now carries the workload labels at create time.
|
|
||||||
package reconciler
|
package reconciler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -23,7 +18,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -118,12 +112,8 @@ func (r *Reconciler) ReconcileOnce(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
seen := make(map[string]struct{}, len(items)) // container row IDs we touched
|
seen := make(map[string]struct{}, len(items)) // container row IDs we touched
|
||||||
|
|
||||||
// Build a per-pass cache of compose project name → stack ID so we don't
|
|
||||||
// hit the DB once per compose container.
|
|
||||||
stackByCompose := map[string]store.Stack{}
|
|
||||||
|
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
rowID := r.upsertFromItem(item, stackByCompose)
|
rowID := r.upsertFromItem(item)
|
||||||
if rowID != "" {
|
if rowID != "" {
|
||||||
seen[rowID] = struct{}{}
|
seen[rowID] = struct{}{}
|
||||||
}
|
}
|
||||||
@@ -221,16 +211,13 @@ func (r *Reconciler) loop(ctx context.Context) {
|
|||||||
|
|
||||||
// upsertFromItem dispatches one container to its workload and writes the
|
// upsertFromItem dispatches one container to its workload and writes the
|
||||||
// Container row. Returns the row ID on success or "" if no dispatch matched.
|
// Container row. Returns the row ID on success or "" if no dispatch matched.
|
||||||
func (r *Reconciler) upsertFromItem(item docker.ReconcileItem, stackCache map[string]store.Stack) string {
|
// After the hard cutover only the canonical tinyforge.workload.id label
|
||||||
|
// path is honored — every Source plugin labels its containers with the
|
||||||
|
// workload identity at create time.
|
||||||
|
func (r *Reconciler) upsertFromItem(item docker.ReconcileItem) string {
|
||||||
if id := item.Labels[docker.LabelWorkloadID]; id != "" {
|
if id := item.Labels[docker.LabelWorkloadID]; id != "" {
|
||||||
return r.upsertByWorkloadLabel(item, id)
|
return r.upsertByWorkloadLabel(item, id)
|
||||||
}
|
}
|
||||||
if siteID := item.Labels["tinyforge.static-site"]; siteID != "" {
|
|
||||||
return r.upsertBySiteLabel(item, siteID)
|
|
||||||
}
|
|
||||||
if cp := item.Labels["com.docker.compose.project"]; cp != "" && strings.HasPrefix(cp, "tinyforge-") {
|
|
||||||
return r.upsertByComposeProject(item, cp, stackCache)
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,9 +287,11 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Site/stack reach this branch only when their kind-specific dispatcher
|
// Site/stack reach this branch only when their plugin hasn't yet
|
||||||
// hasn't run yet (e.g. boot tick before site row is registered). The
|
// upserted the row (e.g. a boot tick that races the first deploy).
|
||||||
// site/stack dispatchers below own their own deterministic IDs.
|
// The deterministic ID computed here matches what the static and
|
||||||
|
// compose plugins write in their state-save paths, so a subsequent
|
||||||
|
// plugin write upserts in place rather than creating a sibling row.
|
||||||
rowID := workloadIDRow(workloadID, kind, role, item.ID)
|
rowID := workloadIDRow(workloadID, kind, role, item.ID)
|
||||||
port := 0
|
port := 0
|
||||||
if len(item.Ports) > 0 {
|
if len(item.Ports) > 0 {
|
||||||
@@ -326,79 +315,6 @@ func (r *Reconciler) upsertByWorkloadLabel(item docker.ReconcileItem, workloadID
|
|||||||
return rowID
|
return rowID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Reconciler) upsertBySiteLabel(item docker.ReconcileItem, siteID string) string {
|
|
||||||
w, err := r.store.GetWorkloadByRef(store.WorkloadKindSite, siteID)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
rowID := w.ID + ":site"
|
|
||||||
port := 0
|
|
||||||
if len(item.Ports) > 0 {
|
|
||||||
port = int(item.Ports[0])
|
|
||||||
}
|
|
||||||
if err := r.store.ReconcileContainer(store.Container{
|
|
||||||
ID: rowID,
|
|
||||||
WorkloadID: w.ID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindSite),
|
|
||||||
Role: "",
|
|
||||||
ContainerID: item.ID,
|
|
||||||
ImageRef: item.Image,
|
|
||||||
Host: "local",
|
|
||||||
State: normalizeState(item.State),
|
|
||||||
Port: port,
|
|
||||||
LastSeenAt: store.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
slog.Warn("reconciler: reconcile by site label", "container_id", item.ID, "error", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return rowID
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Reconciler) upsertByComposeProject(item docker.ReconcileItem, composeProject string, cache map[string]store.Stack) string {
|
|
||||||
stack, ok := cache[composeProject]
|
|
||||||
if !ok {
|
|
||||||
st, err := r.store.GetStackByComposeProjectName(composeProject)
|
|
||||||
if err != nil {
|
|
||||||
cache[composeProject] = store.Stack{} // negative cache for the rest of the pass
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
stack = st
|
|
||||||
cache[composeProject] = st
|
|
||||||
}
|
|
||||||
if stack.ID == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
w, err := r.store.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
role := item.Labels["com.docker.compose.service"]
|
|
||||||
if role == "" {
|
|
||||||
role = item.Name
|
|
||||||
}
|
|
||||||
rowID := w.ID + ":" + role
|
|
||||||
port := 0
|
|
||||||
if len(item.Ports) > 0 {
|
|
||||||
port = int(item.Ports[0])
|
|
||||||
}
|
|
||||||
if err := r.store.ReconcileContainer(store.Container{
|
|
||||||
ID: rowID,
|
|
||||||
WorkloadID: w.ID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindStack),
|
|
||||||
Role: role,
|
|
||||||
ContainerID: item.ID,
|
|
||||||
ImageRef: item.Image,
|
|
||||||
Host: "local",
|
|
||||||
State: normalizeState(item.State),
|
|
||||||
Port: port,
|
|
||||||
LastSeenAt: store.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
slog.Warn("reconciler: reconcile by compose project", "container_id", item.ID, "error", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return rowID
|
|
||||||
}
|
|
||||||
|
|
||||||
// markMissingRows flips state to 'missing' for any container row whose Docker
|
// markMissingRows flips state to 'missing' for any container row whose Docker
|
||||||
// container ID was not seen in this pass. Uses ListMissingSweepRows to scan
|
// container ID was not seen in this pass. Uses ListMissingSweepRows to scan
|
||||||
// only rows that are bound to a real container and not already missing.
|
// only rows that are bound to a real container and not already missing.
|
||||||
@@ -419,9 +335,11 @@ func (r *Reconciler) markMissingRows(seen map[string]struct{}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// workloadIDRow picks the row ID for a non-project workload-labelled
|
// workloadIDRow picks the row ID for a non-project workload-labelled
|
||||||
// container that has no existing row. Stack rows use workloadID:role; sites
|
// container that has no existing row. Sites use `<workloadID>:site`
|
||||||
// use workloadID:site. Project rows are never invented here — see
|
// (matches the static plugin's `containerRowID` helper). Stack
|
||||||
// upsertByWorkloadLabel for the rationale.
|
// services use `<workloadID>:<service-role>` (matches the compose
|
||||||
|
// plugin). Project rows are never invented here — the deployer
|
||||||
|
// pre-creates per-instance UUID rows so the reconciler must wait.
|
||||||
func workloadIDRow(workloadID, kind, role, containerID string) string {
|
func workloadIDRow(workloadID, kind, role, containerID string) string {
|
||||||
if kind == string(store.WorkloadKindSite) {
|
if kind == string(store.WorkloadKindSite) {
|
||||||
return workloadID + ":site"
|
return workloadID + ":site"
|
||||||
|
|||||||
@@ -28,17 +28,23 @@ func newTestStore(t *testing.T) *store.Store {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeWorkload inserts a workload row with the given kind so reconciler
|
||||||
|
// dispatch can resolve it by ID.
|
||||||
|
func makeWorkload(t *testing.T, st *store.Store, name, kind string) store.Workload {
|
||||||
|
t.Helper()
|
||||||
|
w, err := st.CreateWorkload(store.Workload{
|
||||||
|
Kind: kind, RefID: name + "-ref", Name: name,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateWorkload: %v", err)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
func TestReconcileWorkloadLabelledStackContainer(t *testing.T) {
|
func TestReconcileWorkloadLabelledStackContainer(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
|
|
||||||
// Set up a stack workload (no project/site interaction).
|
w := makeWorkload(t, st, "wf-stack", "stack")
|
||||||
stack, err := st.CreateStack(store.Stack{
|
|
||||||
Name: "wf-stack", ComposeProjectName: "tinyforge-wf-stack",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateStack: %v", err)
|
|
||||||
}
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
|
|
||||||
|
|
||||||
// One container with the canonical workload labels stamped.
|
// One container with the canonical workload labels stamped.
|
||||||
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
||||||
@@ -76,51 +82,10 @@ func TestReconcileWorkloadLabelledStackContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileComposeOnlyStackContainer(t *testing.T) {
|
|
||||||
st := newTestStore(t)
|
|
||||||
|
|
||||||
stack, _ := st.CreateStack(store.Stack{
|
|
||||||
Name: "compose-stack", ComposeProjectName: "tinyforge-compose-stack",
|
|
||||||
})
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
|
|
||||||
|
|
||||||
// Pre-existing compose container — only carries compose's own labels,
|
|
||||||
// no tinyforge.* labels at all.
|
|
||||||
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
|
||||||
ID: "docker-xyz",
|
|
||||||
Name: "tinyforge-compose-stack-worker-1",
|
|
||||||
Image: "redis:7",
|
|
||||||
State: "running",
|
|
||||||
Labels: map[string]string{
|
|
||||||
"com.docker.compose.project": "tinyforge-compose-stack",
|
|
||||||
"com.docker.compose.service": "worker",
|
|
||||||
},
|
|
||||||
}}}
|
|
||||||
|
|
||||||
r := New(st, fake, 0)
|
|
||||||
if err := r.ReconcileOnce(context.Background()); err != nil {
|
|
||||||
t.Fatalf("ReconcileOnce: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rows, _ := st.ListContainersByWorkload(w.ID)
|
|
||||||
if len(rows) != 1 {
|
|
||||||
t.Fatalf("expected 1 row, got %d", len(rows))
|
|
||||||
}
|
|
||||||
if rows[0].Role != "worker" {
|
|
||||||
t.Fatalf("role from compose label wrong: %q", rows[0].Role)
|
|
||||||
}
|
|
||||||
if rows[0].ContainerID != "docker-xyz" {
|
|
||||||
t.Fatalf("container_id not bound: %q", rows[0].ContainerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReconcileMarksMissingRows(t *testing.T) {
|
func TestReconcileMarksMissingRows(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
|
|
||||||
stack, _ := st.CreateStack(store.Stack{
|
w := makeWorkload(t, st, "missing-stack", "stack")
|
||||||
Name: "missing-stack", ComposeProjectName: "tinyforge-missing-stack",
|
|
||||||
})
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
|
|
||||||
|
|
||||||
// Pre-existing row with a real container_id that no longer exists.
|
// Pre-existing row with a real container_id that no longer exists.
|
||||||
if err := st.UpsertContainer(store.Container{
|
if err := st.UpsertContainer(store.Container{
|
||||||
@@ -145,10 +110,7 @@ func TestReconcileMarksMissingRows(t *testing.T) {
|
|||||||
func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) {
|
func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
|
|
||||||
stack, _ := st.CreateStack(store.Stack{
|
w := makeWorkload(t, st, "pending", "stack")
|
||||||
Name: "pending", ComposeProjectName: "tinyforge-pending",
|
|
||||||
})
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
|
|
||||||
|
|
||||||
// A row with empty container_id (deployer placeholder, awaiting docker
|
// A row with empty container_id (deployer placeholder, awaiting docker
|
||||||
// create). Reconciler must not mark this as missing.
|
// create). Reconciler must not mark this as missing.
|
||||||
@@ -171,9 +133,8 @@ func TestReconcileSkipsRowsAwaitingDocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReconcileIgnoresUnmanagedContainers(t *testing.T) {
|
func TestReconcileIgnoresUnmanagedContainers(t *testing.T) {
|
||||||
// A container without any tinyforge or compose labels would not even be
|
// A container without the canonical workload label is ignored even if
|
||||||
// returned by ListAllForReconciler in production; but the dispatch must
|
// it carries other labels — only tinyforge.workload.id is honored.
|
||||||
// be a no-op even if a stray item slips through.
|
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
||||||
ID: "docker-foreign", Labels: map[string]string{"app": "other"},
|
ID: "docker-foreign", Labels: map[string]string{"app": "other"},
|
||||||
@@ -197,11 +158,7 @@ func TestReconcileDoesNotClobberDeployerFields(t *testing.T) {
|
|||||||
|
|
||||||
// Project workload — exercises the path most affected by the regression
|
// Project workload — exercises the path most affected by the regression
|
||||||
// (proxies, blue-green slots, image-tag-based stale detection).
|
// (proxies, blue-green slots, image-tag-based stale detection).
|
||||||
project, err := st.CreateProject(store.Project{Name: "p", Image: "nginx"})
|
w := makeWorkload(t, st, "p", "project")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProject: %v", err)
|
|
||||||
}
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindProject, project.ID)
|
|
||||||
|
|
||||||
// Deployer wrote the row with proxy / subdomain / image_tag / stage_id.
|
// Deployer wrote the row with proxy / subdomain / image_tag / stage_id.
|
||||||
deployerRow := store.Container{
|
deployerRow := store.Container{
|
||||||
@@ -277,11 +234,7 @@ func TestReconcileRejectsForgedWorkloadLabel(t *testing.T) {
|
|||||||
// authoritative writer and inventing rows races with MaxInstances > 1 deploys.
|
// authoritative writer and inventing rows races with MaxInstances > 1 deploys.
|
||||||
func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) {
|
func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
project, err := st.CreateProject(store.Project{Name: "p2", Image: "nginx"})
|
w := makeWorkload(t, st, "p2", "project")
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProject: %v", err)
|
|
||||||
}
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindProject, project.ID)
|
|
||||||
|
|
||||||
// Reconciler sees a real container with project labels but no deployer
|
// Reconciler sees a real container with project labels but no deployer
|
||||||
// row exists yet (race during deploy).
|
// row exists yet (race during deploy).
|
||||||
@@ -306,10 +259,7 @@ func TestReconcileSkipsProjectInsertWithoutDeployerRow(t *testing.T) {
|
|||||||
|
|
||||||
func TestReconcileNormalizesState(t *testing.T) {
|
func TestReconcileNormalizesState(t *testing.T) {
|
||||||
st := newTestStore(t)
|
st := newTestStore(t)
|
||||||
stack, _ := st.CreateStack(store.Stack{
|
w := makeWorkload(t, st, "norm", "stack")
|
||||||
Name: "norm", ComposeProjectName: "tinyforge-norm",
|
|
||||||
})
|
|
||||||
w, _ := st.GetWorkloadByRef(store.WorkloadKindStack, stack.ID)
|
|
||||||
|
|
||||||
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
fake := &fakeDocker{items: []docker.ReconcileItem{{
|
||||||
ID: "docker-1",
|
ID: "docker-1",
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
package registry
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/crypto"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Poller periodically checks registries for new image tags and triggers
|
|
||||||
// deployments for stages with auto_deploy enabled.
|
|
||||||
type Poller struct {
|
|
||||||
store *store.Store
|
|
||||||
deployer DeployTriggerer
|
|
||||||
encKey [32]byte
|
|
||||||
cron *cron.Cron
|
|
||||||
mu sync.Mutex
|
|
||||||
entryID cron.EntryID
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPoller creates a new Poller instance.
|
|
||||||
func NewPoller(st *store.Store, deployer DeployTriggerer, encKey [32]byte) *Poller {
|
|
||||||
return &Poller{
|
|
||||||
store: st,
|
|
||||||
deployer: deployer,
|
|
||||||
encKey: encKey,
|
|
||||||
cron: cron.New(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins the polling scheduler with the given interval string (e.g., "5m", "1h").
|
|
||||||
// If the poller is already running, it stops and restarts with the new interval.
|
|
||||||
func (p *Poller) Start(interval string) error {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
duration, err := time.ParseDuration(interval)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse polling interval %q: %w", interval, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop existing schedule if running.
|
|
||||||
if p.running {
|
|
||||||
p.cron.Remove(p.entryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert duration to a cron schedule: @every <duration>.
|
|
||||||
spec := fmt.Sprintf("@every %s", duration.String())
|
|
||||||
entryID, err := p.cron.AddFunc(spec, func() {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
if pollErr := p.poll(ctx); pollErr != nil {
|
|
||||||
slog.Warn("poller: poll error", "error", pollErr)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("schedule poller: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.entryID = entryID
|
|
||||||
if !p.running {
|
|
||||||
p.cron.Start()
|
|
||||||
}
|
|
||||||
p.running = true
|
|
||||||
|
|
||||||
slog.Info("poller started", "interval", duration.String())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop gracefully shuts down the poller.
|
|
||||||
func (p *Poller) Stop() {
|
|
||||||
p.mu.Lock()
|
|
||||||
defer p.mu.Unlock()
|
|
||||||
|
|
||||||
if p.running {
|
|
||||||
ctx := p.cron.Stop()
|
|
||||||
<-ctx.Done()
|
|
||||||
p.running = false
|
|
||||||
slog.Info("poller stopped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// poll performs a single polling cycle: iterates over all projects and their
|
|
||||||
// stages, checks for new tags, and triggers deploys where appropriate.
|
|
||||||
func (p *Poller) poll(ctx context.Context) error {
|
|
||||||
projects, err := p.store.GetAllProjects()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get projects: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, project := range projects {
|
|
||||||
if err := p.pollProject(ctx, project); err != nil {
|
|
||||||
slog.Warn("poller: project error", "project", project.Name, "id", project.ID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollProject checks all stages of a single project for new tags.
|
|
||||||
func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
|
|
||||||
if project.Registry == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reg, err := p.store.GetRegistryByName(project.Registry)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get registry %s: %w", project.Registry, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := crypto.Decrypt(p.encKey, reg.Token)
|
|
||||||
if err != nil {
|
|
||||||
token = reg.Token
|
|
||||||
}
|
|
||||||
|
|
||||||
client, err := NewClient(reg.Type, reg.URL, token)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("create registry client: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tags, err := client.ListTags(ctx, project.Image)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("list tags for %s: %w", project.Image, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stages, err := p.store.GetStagesByProjectID(project.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get stages for project %s: %w", project.ID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, stage := range stages {
|
|
||||||
if err := p.pollStage(ctx, project, stage, tags); err != nil {
|
|
||||||
slog.Warn("poller: stage error", "project", project.Name, "stage", stage.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollStage checks a single stage for new tags and triggers deploy if needed.
|
|
||||||
func (p *Poller) pollStage(ctx context.Context, project store.Project, stage store.Stage, allTags []string) error {
|
|
||||||
latest, err := LatestTag(allTags, stage.TagPattern)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("match tags for stage %s: %w", stage.Name, err)
|
|
||||||
}
|
|
||||||
if latest == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := p.store.GetPollState(stage.ID)
|
|
||||||
if err != nil {
|
|
||||||
return p.store.UpsertPollState(store.PollState{
|
|
||||||
StageID: stage.ID,
|
|
||||||
LastTag: latest,
|
|
||||||
LastPolled: store.Now(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if err := p.store.UpsertPollState(store.PollState{
|
|
||||||
StageID: stage.ID,
|
|
||||||
LastTag: latest,
|
|
||||||
LastPolled: store.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
slog.Warn("poller: failed to update poll state", "stage_id", stage.ID, "error", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
if state.LastTag == latest {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("poller: new tag detected", "tag", latest, "project", project.Name, "stage", stage.Name, "previous", state.LastTag)
|
|
||||||
|
|
||||||
if !stage.AutoDeploy {
|
|
||||||
slog.Info("poller: auto_deploy disabled, skipping", "stage", stage.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p.deployer.TriggerDeploy(ctx, project.ID, stage.ID, latest); err != nil {
|
|
||||||
return fmt.Errorf("trigger deploy for tag %s: %w", latest, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -29,13 +29,6 @@ type Client interface {
|
|||||||
ListImages(ctx context.Context, owner string) ([]RegistryImage, error)
|
ListImages(ctx context.Context, owner string) ([]RegistryImage, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeployTriggerer is called by the poller when a new tag is detected for a
|
|
||||||
// stage with auto_deploy enabled. This decouples the registry package from the
|
|
||||||
// deployer implementation.
|
|
||||||
type DeployTriggerer interface {
|
|
||||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// MatchTags filters a list of tags, returning only those that match the given
|
// MatchTags filters a list of tags, returning only those that match the given
|
||||||
// glob pattern. Pattern matching uses path.Match semantics (*, ?, []).
|
// glob pattern. Pattern matching uses path.Match semantics (*, ?, []).
|
||||||
// Returns an error if the pattern is malformed.
|
// Returns an error if the pattern is malformed.
|
||||||
|
|||||||
@@ -1,405 +0,0 @@
|
|||||||
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", "")
|
|
||||||
m.syncContainerRows(ctx, st, yamlPath)
|
|
||||||
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", "")
|
|
||||||
m.markStackContainersState(stackID, "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", "")
|
|
||||||
m.markStackContainersState(stackID, "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 ---
|
|
||||||
|
|
||||||
// syncContainerRows upserts one Container row per compose service for this
|
|
||||||
// stack so the global container index stays in sync after every deploy. The
|
|
||||||
// Docker container ID is left empty here — the reconciler resolves it from
|
|
||||||
// `docker ps` via the `com.docker.compose.project` label. Best-effort: a
|
|
||||||
// failure here is logged but does not affect deploy outcome.
|
|
||||||
func (m *Manager) syncContainerRows(ctx context.Context, st store.Stack, yamlPath string) {
|
|
||||||
w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, st.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("stack: resolve workload", "stack", st.ID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
psCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
services, err := m.compose.Ps(psCtx, st.ComposeProjectName, yamlPath)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("stack: compose ps for container sync", "stack", st.ID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, svc := range services {
|
|
||||||
state := svc.State
|
|
||||||
if state == "" {
|
|
||||||
state = svc.Status
|
|
||||||
}
|
|
||||||
m.upsertStackContainer(w.ID, svc, state)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// upsertStackContainer writes a Container row for one compose service. The
|
|
||||||
// row ID is deterministic — `<workloadID>:<service>` — so re-deploys update
|
|
||||||
// the same row instead of accumulating rows.
|
|
||||||
func (m *Manager) upsertStackContainer(workloadID string, svc Service, state string) {
|
|
||||||
role := svc.Service
|
|
||||||
if role == "" {
|
|
||||||
role = svc.Name
|
|
||||||
}
|
|
||||||
if err := m.store.UpsertContainer(store.Container{
|
|
||||||
ID: workloadID + ":" + role,
|
|
||||||
WorkloadID: workloadID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindStack),
|
|
||||||
Role: role,
|
|
||||||
ContainerID: "", // reconciler fills in from docker ps
|
|
||||||
Host: "local",
|
|
||||||
State: state,
|
|
||||||
LastSeenAt: store.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
slog.Warn("stack: upsert container row", "workload_id", workloadID, "service", role, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// markStackContainersState bulk-updates the state of every container row for
|
|
||||||
// this stack (used by Stop/Start which don't go through compose ps).
|
|
||||||
func (m *Manager) markStackContainersState(stackID, state string) {
|
|
||||||
w, err := m.store.GetWorkloadByRef(store.WorkloadKindStack, stackID)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, err := m.store.ListContainersByWorkload(w.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("stack: list containers for state update", "workload_id", w.ID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, r := range rows {
|
|
||||||
if err := m.store.UpdateContainerState(r.ID, state); err != nil {
|
|
||||||
slog.Warn("stack: update container state", "container_row", r.ID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) }
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
package staticsite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HealthChecker periodically checks that deployed static site containers
|
|
||||||
// are still running. If a container has crashed, it updates the site status
|
|
||||||
// to "failed" and optionally triggers a redeploy.
|
|
||||||
type HealthChecker struct {
|
|
||||||
store *store.Store
|
|
||||||
docker *docker.Client
|
|
||||||
manager *Manager
|
|
||||||
|
|
||||||
cron *cron.Cron
|
|
||||||
mu sync.Mutex
|
|
||||||
entryID cron.EntryID
|
|
||||||
running bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHealthChecker creates a new static site health checker.
|
|
||||||
func NewHealthChecker(st *store.Store, dockerClient *docker.Client, mgr *Manager) *HealthChecker {
|
|
||||||
return &HealthChecker{
|
|
||||||
store: st,
|
|
||||||
docker: dockerClient,
|
|
||||||
manager: mgr,
|
|
||||||
cron: cron.New(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start begins the periodic health check with the given interval (e.g., "5m", "1m").
|
|
||||||
func (h *HealthChecker) Start(interval string) error {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
duration, err := time.ParseDuration(interval)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parse interval %q: %w", interval, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.running {
|
|
||||||
h.cron.Remove(h.entryID)
|
|
||||||
}
|
|
||||||
|
|
||||||
spec := fmt.Sprintf("@every %s", duration)
|
|
||||||
id, err := h.cron.AddFunc(spec, h.check)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("schedule health check: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
h.entryID = id
|
|
||||||
h.running = true
|
|
||||||
h.cron.Start()
|
|
||||||
|
|
||||||
slog.Info("static site health checker started", "interval", interval)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops the periodic health checker.
|
|
||||||
func (h *HealthChecker) Stop() {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
|
|
||||||
if h.running {
|
|
||||||
h.cron.Stop()
|
|
||||||
h.running = false
|
|
||||||
slog.Info("static site health checker stopped")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check runs a single health check pass over all deployed static sites.
|
|
||||||
func (h *HealthChecker) check() {
|
|
||||||
sites, err := h.store.GetAllStaticSites()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("static site health check: failed to list sites", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
for _, site := range sites {
|
|
||||||
if site.Status != "deployed" || site.ContainerID == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
running, err := h.docker.IsContainerRunning(ctx, site.ContainerID)
|
|
||||||
if err != nil {
|
|
||||||
// Container might have been removed externally.
|
|
||||||
slog.Warn("static site health check: container inspect failed",
|
|
||||||
"site", site.Name, "container", site.ContainerID[:12], "error", err)
|
|
||||||
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container not found")
|
|
||||||
h.manager.publishEvent(site.ID, site.Name, "failed: container not found")
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !running {
|
|
||||||
slog.Warn("static site health check: container not running",
|
|
||||||
"site", site.Name, "container", site.ContainerID[:12])
|
|
||||||
h.manager.updateStatus(site.ID, "failed", site.LastCommitSHA, "container stopped unexpectedly")
|
|
||||||
h.manager.publishEvent(site.ID, site.Name, "failed: container stopped unexpectedly")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,834 +0,0 @@
|
|||||||
package staticsite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/moby/moby/api/types/mount"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/crypto"
|
|
||||||
"github.com/alexei/tinyforge/internal/docker"
|
|
||||||
"github.com/alexei/tinyforge/internal/events"
|
|
||||||
"github.com/alexei/tinyforge/internal/notify"
|
|
||||||
"github.com/alexei/tinyforge/internal/proxy"
|
|
||||||
"github.com/alexei/tinyforge/internal/staticsite/deno"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Manager orchestrates the static site deployment pipeline.
|
|
||||||
type Manager struct {
|
|
||||||
store *store.Store
|
|
||||||
docker *docker.Client
|
|
||||||
proxyProvider proxy.Provider
|
|
||||||
eventBus *events.Bus
|
|
||||||
notifier *notify.Notifier
|
|
||||||
encKey [32]byte
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewManager creates a new static site manager.
|
|
||||||
func NewManager(
|
|
||||||
st *store.Store,
|
|
||||||
dockerClient *docker.Client,
|
|
||||||
proxyProvider proxy.Provider,
|
|
||||||
eventBus *events.Bus,
|
|
||||||
notifier *notify.Notifier,
|
|
||||||
encKey [32]byte,
|
|
||||||
) *Manager {
|
|
||||||
return &Manager{
|
|
||||||
store: st,
|
|
||||||
docker: dockerClient,
|
|
||||||
proxyProvider: proxyProvider,
|
|
||||||
eventBus: eventBus,
|
|
||||||
notifier: notifier,
|
|
||||||
encKey: encKey,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProxyProvider updates the proxy provider at runtime.
|
|
||||||
func (m *Manager) SetProxyProvider(provider proxy.Provider) {
|
|
||||||
m.proxyProvider = provider
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSiteWorkloadID returns the workload ID paired with a static site.
|
|
||||||
// Boot-time backfill guarantees the row exists; on lookup miss this returns
|
|
||||||
// empty so the caller can decide (the deployer continues without the label).
|
|
||||||
func (m *Manager) resolveSiteWorkloadID(siteID string) string {
|
|
||||||
w, err := m.store.GetWorkloadByRef(store.WorkloadKindSite, siteID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: resolve workload", "site_id", siteID, "error", err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return w.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
// upsertSiteContainer keeps the global container index in sync with the
|
|
||||||
// site's current container. Row ID is deterministic (workloadID + ":site")
|
|
||||||
// so re-deploys update in place. Best-effort.
|
|
||||||
func (m *Manager) upsertSiteContainer(site store.StaticSite, containerID, state string) {
|
|
||||||
workloadID := m.resolveSiteWorkloadID(site.ID)
|
|
||||||
if workloadID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := m.store.UpsertContainer(store.Container{
|
|
||||||
ID: workloadID + ":site",
|
|
||||||
WorkloadID: workloadID,
|
|
||||||
WorkloadKind: string(store.WorkloadKindSite),
|
|
||||||
Role: "",
|
|
||||||
ContainerID: containerID,
|
|
||||||
Host: "local",
|
|
||||||
State: state,
|
|
||||||
Subdomain: site.Domain,
|
|
||||||
ProxyRouteID: site.ProxyRouteID,
|
|
||||||
LastSeenAt: store.Now(),
|
|
||||||
}); err != nil {
|
|
||||||
slog.Warn("static site: upsert container row", "site_id", site.ID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// markSiteContainerState bulk-updates state for the site's container row.
|
|
||||||
// Used by Stop/Start which only flip state.
|
|
||||||
func (m *Manager) markSiteContainerState(siteID, state string) {
|
|
||||||
workloadID := m.resolveSiteWorkloadID(siteID)
|
|
||||||
if workloadID == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rowID := workloadID + ":site"
|
|
||||||
if err := m.store.UpdateContainerState(rowID, state); err != nil {
|
|
||||||
// NotFound is fine — the site may have never deployed.
|
|
||||||
slog.Debug("static site: update container state", "row", rowID, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deploy fetches content from Gitea and deploys a static site container.
|
|
||||||
// If force is true, skips the "no changes" check and always rebuilds/redeploys.
|
|
||||||
func (m *Manager) Deploy(ctx context.Context, siteID string, force bool) error {
|
|
||||||
site, err := m.store.GetStaticSiteByID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get site: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt access token if present.
|
|
||||||
token := ""
|
|
||||||
if site.AccessToken != "" {
|
|
||||||
decrypted, err := crypto.Decrypt(m.encKey, site.AccessToken)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: failed to decrypt access token", "site", site.Name, "error", err)
|
|
||||||
} else {
|
|
||||||
token = decrypted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
provider, err := NewGitProvider(ProviderType(site.Provider), site.GiteaURL, token)
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create provider: %v", err))
|
|
||||||
return fmt.Errorf("create provider: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's a new commit.
|
|
||||||
latestSHA, err := provider.GetLatestCommitSHA(ctx, site.RepoOwner, site.RepoName, site.Branch)
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("fetch commit SHA: %v", err))
|
|
||||||
return fmt.Errorf("get latest commit: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip redeploy only if SHA matches, status is deployed, container is running,
|
|
||||||
// proxy route exists, AND force is false. Manual deploys always force a full rebuild.
|
|
||||||
if !force && latestSHA == site.LastCommitSHA && site.Status == "deployed" && site.ContainerID != "" {
|
|
||||||
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
|
|
||||||
if !running {
|
|
||||||
slog.Info("static site: container not running, forcing redeploy", "site", site.Name)
|
|
||||||
} else if site.Domain != "" {
|
|
||||||
// Also verify the proxy route still exists (it may have been deleted externally).
|
|
||||||
proxyOK, err := m.proxyProvider.RouteExists(ctx, site.Domain)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: proxy check failed, forcing redeploy", "site", site.Name, "error", err)
|
|
||||||
} else if !proxyOK {
|
|
||||||
slog.Info("static site: proxy route missing, forcing redeploy", "site", site.Name)
|
|
||||||
} else {
|
|
||||||
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Info("static site: no changes", "site", site.Name, "sha", latestSHA)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update status to syncing.
|
|
||||||
m.updateStatus(site.ID, "syncing", site.LastCommitSHA, "")
|
|
||||||
m.publishEvent(site.ID, site.Name, "syncing")
|
|
||||||
|
|
||||||
// Create temp directory for the build context.
|
|
||||||
buildDir, err := os.MkdirTemp("", "dw-site-"+site.Name+"-*")
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("create temp dir: %v", err))
|
|
||||||
return fmt.Errorf("create temp dir: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(buildDir)
|
|
||||||
|
|
||||||
// Download folder contents.
|
|
||||||
if err := provider.DownloadFolder(ctx, site.RepoOwner, site.RepoName, site.Branch, site.FolderPath, buildDir); err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", site.LastCommitSHA, fmt.Sprintf("download folder: %v", err))
|
|
||||||
return fmt.Errorf("download folder: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render markdown if enabled.
|
|
||||||
if site.RenderMarkdown {
|
|
||||||
if err := RenderMarkdownFiles(buildDir); err != nil {
|
|
||||||
slog.Warn("static site: markdown rendering failed", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine mode: check for api/ subdirectory.
|
|
||||||
mode := site.Mode
|
|
||||||
apiDir := filepath.Join(buildDir, "api")
|
|
||||||
hasAPI := false
|
|
||||||
if info, err := os.Stat(apiDir); err == nil && info.IsDir() {
|
|
||||||
hasAPI = true
|
|
||||||
}
|
|
||||||
if mode == "deno" && !hasAPI {
|
|
||||||
// Fallback to static if no api/ folder found.
|
|
||||||
mode = "static"
|
|
||||||
slog.Info("static site: no api/ folder found, falling back to static mode", "site", site.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare build context based on mode.
|
|
||||||
imageTag := fmt.Sprintf("dw-site-%s:latest", site.Name)
|
|
||||||
contextDir, err := os.MkdirTemp("", "dw-site-build-*")
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create build context: %v", err))
|
|
||||||
return fmt.Errorf("create build context dir: %w", err)
|
|
||||||
}
|
|
||||||
defer os.RemoveAll(contextDir)
|
|
||||||
|
|
||||||
if mode == "deno" {
|
|
||||||
if err := m.prepareDenoBuild(buildDir, contextDir); err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare deno build: %v", err))
|
|
||||||
return fmt.Errorf("prepare deno build: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := m.prepareStaticBuild(buildDir, contextDir); err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("prepare static build: %v", err))
|
|
||||||
return fmt.Errorf("prepare static build: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build Docker image.
|
|
||||||
if err := m.docker.BuildImage(ctx, contextDir, imageTag); err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build image: %v", err))
|
|
||||||
return fmt.Errorf("build image: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare environment variables (secrets).
|
|
||||||
env, err := m.buildEnvVars(site.ID)
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("build env vars: %v", err))
|
|
||||||
return fmt.Errorf("build env vars: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine container port.
|
|
||||||
containerPort := "80"
|
|
||||||
if mode == "deno" {
|
|
||||||
containerPort = "8000"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get network settings.
|
|
||||||
settings, err := m.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("get settings: %v", err))
|
|
||||||
return fmt.Errorf("get settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
networkName := settings.Network
|
|
||||||
networkID, err := m.docker.EnsureNetwork(ctx, networkName)
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("ensure network: %v", err))
|
|
||||||
return fmt.Errorf("ensure network: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
|
||||||
|
|
||||||
// Prepare volume mounts for persistent storage.
|
|
||||||
var mounts []mount.Mount
|
|
||||||
if site.StorageEnabled && mode == "deno" {
|
|
||||||
volName, volErr := m.docker.EnsureSiteVolume(ctx, site.Name)
|
|
||||||
if volErr != nil {
|
|
||||||
slog.Warn("static site: failed to ensure storage volume", "site", site.Name, "error", volErr)
|
|
||||||
} else {
|
|
||||||
mounts = append(mounts, mount.Mount{
|
|
||||||
Type: mount.TypeVolume,
|
|
||||||
Source: volName,
|
|
||||||
Target: "/app/data",
|
|
||||||
})
|
|
||||||
slog.Info("static site: storage volume attached", "site", site.Name, "volume", volName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and start new container.
|
|
||||||
containerID, err := m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
|
||||||
Name: containerName,
|
|
||||||
Image: imageTag,
|
|
||||||
Env: env,
|
|
||||||
ExposedPorts: []string{containerPort + "/tcp"},
|
|
||||||
NetworkName: networkName,
|
|
||||||
NetworkID: networkID,
|
|
||||||
Mounts: mounts,
|
|
||||||
Labels: map[string]string{
|
|
||||||
"tinyforge.static-site": site.ID,
|
|
||||||
"tinyforge.static-site-name": site.Name,
|
|
||||||
},
|
|
||||||
WorkloadID: m.resolveSiteWorkloadID(site.ID),
|
|
||||||
WorkloadKind: string(store.WorkloadKindSite),
|
|
||||||
Role: "",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
// Container might already exist — try to remove and recreate.
|
|
||||||
if site.ContainerID != "" {
|
|
||||||
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
|
||||||
m.docker.RemoveContainer(ctx, site.ContainerID, true)
|
|
||||||
}
|
|
||||||
// Also try by name.
|
|
||||||
m.removeContainerByName(ctx, containerName)
|
|
||||||
|
|
||||||
containerID, err = m.docker.CreateContainer(ctx, docker.ContainerConfig{
|
|
||||||
Name: containerName,
|
|
||||||
Image: imageTag,
|
|
||||||
Env: env,
|
|
||||||
ExposedPorts: []string{containerPort + "/tcp"},
|
|
||||||
NetworkName: networkName,
|
|
||||||
NetworkID: networkID,
|
|
||||||
Mounts: mounts,
|
|
||||||
Labels: map[string]string{
|
|
||||||
"tinyforge.static-site": site.ID,
|
|
||||||
"tinyforge.static-site-name": site.Name,
|
|
||||||
},
|
|
||||||
WorkloadID: m.resolveSiteWorkloadID(site.ID),
|
|
||||||
WorkloadKind: string(store.WorkloadKindSite),
|
|
||||||
Role: "",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("create container: %v", err))
|
|
||||||
return fmt.Errorf("create container: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := m.docker.StartContainer(ctx, containerID); err != nil {
|
|
||||||
m.docker.RemoveContainer(ctx, containerID, true)
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, fmt.Sprintf("start container: %v", err))
|
|
||||||
return fmt.Errorf("start container: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brief health check: wait 3 seconds and verify container is still running.
|
|
||||||
time.Sleep(3 * time.Second)
|
|
||||||
running, err := m.docker.IsContainerRunning(ctx, containerID)
|
|
||||||
if err != nil || !running {
|
|
||||||
// Grab container logs for the error message.
|
|
||||||
logMsg := "container exited immediately after start"
|
|
||||||
if logs, logErr := m.docker.ContainerLogs(ctx, containerID, false, "20"); logErr == nil {
|
|
||||||
buf, _ := io.ReadAll(logs)
|
|
||||||
logs.Close()
|
|
||||||
if len(buf) > 0 {
|
|
||||||
logMsg = string(buf)
|
|
||||||
// Truncate to reasonable length.
|
|
||||||
if len(logMsg) > 500 {
|
|
||||||
logMsg = logMsg[:500] + "..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.docker.RemoveContainer(ctx, containerID, true)
|
|
||||||
m.updateStatus(site.ID, "failed", latestSHA, logMsg)
|
|
||||||
return fmt.Errorf("container not running: %s", logMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine proxy target: container name + internal port (default),
|
|
||||||
// or server IP + host port for NPM remote mode.
|
|
||||||
internalPort, _ := strconv.Atoi(containerPort)
|
|
||||||
forwardHost := containerName
|
|
||||||
forwardPort := internalPort
|
|
||||||
|
|
||||||
if settings.NpmRemote && settings.ProxyProvider == "npm" {
|
|
||||||
if settings.ServerIP != "" {
|
|
||||||
hostPort, err := m.docker.InspectContainerPort(ctx, containerID, containerPort+"/tcp")
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: could not get host port for remote NPM", "site", site.Name, "error", err)
|
|
||||||
} else {
|
|
||||||
forwardHost = settings.ServerIP
|
|
||||||
forwardPort = int(hostPort)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configure proxy if domain is set.
|
|
||||||
proxyRouteID := site.ProxyRouteID
|
|
||||||
if site.Domain != "" {
|
|
||||||
// Remove old proxy route if exists.
|
|
||||||
if site.ProxyRouteID != "" {
|
|
||||||
m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID)
|
|
||||||
}
|
|
||||||
|
|
||||||
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
|
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: failed to configure proxy", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "error", err)
|
|
||||||
} else {
|
|
||||||
proxyRouteID = routeID
|
|
||||||
slog.Info("static site: proxy configured", "site", site.Name, "domain", site.Domain, "target", fmt.Sprintf("%s:%d", forwardHost, forwardPort), "routeID", routeID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove old container if different.
|
|
||||||
if site.ContainerID != "" && site.ContainerID != containerID {
|
|
||||||
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
|
||||||
m.docker.RemoveContainer(ctx, site.ContainerID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update site status.
|
|
||||||
if err := m.store.UpdateStaticSiteContainer(site.ID, containerID, proxyRouteID); err != nil {
|
|
||||||
slog.Error("static site: failed to update container info", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
site.ContainerID = containerID
|
|
||||||
site.ProxyRouteID = proxyRouteID
|
|
||||||
m.upsertSiteContainer(site, containerID, "running")
|
|
||||||
m.updateStatus(site.ID, "deployed", latestSHA, "")
|
|
||||||
m.publishEvent(site.ID, site.Name, "deployed")
|
|
||||||
|
|
||||||
slog.Info("static site deployed", "site", site.Name, "sha", latestSHA[:8], "mode", mode)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove stops and removes a static site's container and proxy route.
|
|
||||||
func (m *Manager) Remove(ctx context.Context, siteID string) error {
|
|
||||||
site, err := m.store.GetStaticSiteByID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get site: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove proxy route (best effort).
|
|
||||||
if site.ProxyRouteID != "" {
|
|
||||||
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
|
|
||||||
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop and remove container (best effort).
|
|
||||||
if site.ContainerID != "" {
|
|
||||||
m.docker.StopContainer(ctx, site.ContainerID, 10)
|
|
||||||
if err := m.docker.RemoveContainer(ctx, site.ContainerID, true); err != nil {
|
|
||||||
slog.Warn("static site: failed to remove container", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove storage volume if it was enabled (best effort).
|
|
||||||
if site.StorageEnabled {
|
|
||||||
if err := m.docker.RemoveSiteVolume(ctx, site.Name); err != nil {
|
|
||||||
slog.Warn("static site: failed to remove storage volume", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops a running static site container and removes its proxy route.
|
|
||||||
// The container is kept (not removed) so Start can bring it back without a full rebuild.
|
|
||||||
func (m *Manager) Stop(ctx context.Context, siteID string) error {
|
|
||||||
site, err := m.store.GetStaticSiteByID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get site: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove proxy route first (best effort).
|
|
||||||
if site.ProxyRouteID != "" {
|
|
||||||
if err := m.proxyProvider.DeleteRoute(ctx, site.ProxyRouteID); err != nil {
|
|
||||||
slog.Warn("static site: failed to remove proxy route", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop container.
|
|
||||||
if site.ContainerID != "" {
|
|
||||||
if err := m.docker.StopContainer(ctx, site.ContainerID, 10); err != nil {
|
|
||||||
slog.Warn("static site: failed to stop container", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear proxy route ID; keep container ID.
|
|
||||||
if err := m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, ""); err != nil {
|
|
||||||
slog.Error("static site: failed to clear proxy route", "site", site.Name, "error", err)
|
|
||||||
}
|
|
||||||
m.markSiteContainerState(site.ID, "stopped")
|
|
||||||
m.updateStatus(site.ID, "stopped", site.LastCommitSHA, "")
|
|
||||||
m.publishEvent(site.ID, site.Name, "stopped")
|
|
||||||
|
|
||||||
slog.Info("static site stopped", "site", site.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start starts a previously stopped static site container and reconfigures the proxy.
|
|
||||||
// If the container no longer exists, it triggers a full redeploy.
|
|
||||||
func (m *Manager) Start(ctx context.Context, siteID string) error {
|
|
||||||
site, err := m.store.GetStaticSiteByID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get site: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no container exists, do a full deploy.
|
|
||||||
if site.ContainerID == "" {
|
|
||||||
return m.Deploy(ctx, siteID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to start the existing container.
|
|
||||||
if err := m.docker.StartContainer(ctx, site.ContainerID); err != nil {
|
|
||||||
slog.Warn("static site: failed to start container, falling back to redeploy", "site", site.Name, "error", err)
|
|
||||||
return m.Deploy(ctx, siteID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify it's running after a brief wait.
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
running, _ := m.docker.IsContainerRunning(ctx, site.ContainerID)
|
|
||||||
if !running {
|
|
||||||
return m.Deploy(ctx, siteID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reconfigure proxy if domain is set.
|
|
||||||
settings, err := m.store.GetSettings()
|
|
||||||
if err == nil && site.Domain != "" {
|
|
||||||
containerPort := "80"
|
|
||||||
if site.Mode == "deno" {
|
|
||||||
containerPort = "8000"
|
|
||||||
}
|
|
||||||
internalPort, _ := strconv.Atoi(containerPort)
|
|
||||||
containerName := fmt.Sprintf("dw-site-%s", site.Name)
|
|
||||||
forwardHost := containerName
|
|
||||||
forwardPort := internalPort
|
|
||||||
|
|
||||||
if settings.NpmRemote && settings.ProxyProvider == "npm" && settings.ServerIP != "" {
|
|
||||||
if hp, err := m.docker.InspectContainerPort(ctx, site.ContainerID, containerPort+"/tcp"); err == nil {
|
|
||||||
forwardHost = settings.ServerIP
|
|
||||||
forwardPort = int(hp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
routeID, err := m.proxyProvider.ConfigureRoute(ctx, site.Domain, forwardHost, forwardPort, proxy.RouteOptions{
|
|
||||||
SSLCertificateID: settings.SSLCertificateID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: failed to reconfigure proxy on start", "site", site.Name, "error", err)
|
|
||||||
} else {
|
|
||||||
m.store.UpdateStaticSiteContainer(site.ID, site.ContainerID, routeID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
m.markSiteContainerState(site.ID, "running")
|
|
||||||
m.updateStatus(site.ID, "deployed", site.LastCommitSHA, "")
|
|
||||||
m.publishEvent(site.ID, site.Name, "deployed")
|
|
||||||
|
|
||||||
slog.Info("static site started", "site", site.Name)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestConnection tests connectivity to a Git repository.
|
|
||||||
func (m *Manager) TestConnection(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) error {
|
|
||||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return provider.TestConnection(ctx, owner, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListBranches returns branches for a Git repository.
|
|
||||||
func (m *Manager) ListBranches(ctx context.Context, providerType, baseURL, accessToken, owner, repo string) ([]string, error) {
|
|
||||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return provider.ListBranches(ctx, owner, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListTree returns the repository tree for the folder picker.
|
|
||||||
func (m *Manager) ListTree(ctx context.Context, providerType, baseURL, accessToken, owner, repo, branch string) ([]FolderEntry, error) {
|
|
||||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return provider.ListTree(ctx, owner, repo, branch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListRepos returns repositories from a Git server.
|
|
||||||
func (m *Manager) ListRepos(ctx context.Context, providerType, baseURL, accessToken, query string) ([]RepoInfo, error) {
|
|
||||||
provider, err := m.createProvider(providerType, baseURL, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return provider.ListRepos(ctx, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DetectProvider autodetects the Git provider from a URL, with API probing.
|
|
||||||
func (m *Manager) DetectProvider(ctx context.Context, baseURL string) string {
|
|
||||||
return string(DetectProviderWithProbe(ctx, baseURL))
|
|
||||||
}
|
|
||||||
|
|
||||||
// createProvider builds a GitProvider from encrypted credentials.
|
|
||||||
func (m *Manager) createProvider(providerType, baseURL, accessToken string) (GitProvider, error) {
|
|
||||||
token := ""
|
|
||||||
if accessToken != "" {
|
|
||||||
decrypted, err := crypto.Decrypt(m.encKey, accessToken)
|
|
||||||
if err != nil {
|
|
||||||
token = accessToken // might be plaintext
|
|
||||||
} else {
|
|
||||||
token = decrypted
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return NewGitProvider(ProviderType(providerType), baseURL, token)
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepareDenoBuild sets up the build context for a Deno container.
|
|
||||||
func (m *Manager) prepareDenoBuild(srcDir, contextDir string) error {
|
|
||||||
// Move api/ to context.
|
|
||||||
apiSrc := filepath.Join(srcDir, "api")
|
|
||||||
apiDst := filepath.Join(contextDir, "api")
|
|
||||||
if err := os.Rename(apiSrc, apiDst); err != nil {
|
|
||||||
return fmt.Errorf("move api dir: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move remaining files to public/.
|
|
||||||
publicDir := filepath.Join(contextDir, "public")
|
|
||||||
if err := os.Rename(srcDir, publicDir); err != nil {
|
|
||||||
// If rename fails (cross-device), use copy.
|
|
||||||
if err := copyDir(srcDir, publicDir); err != nil {
|
|
||||||
return fmt.Errorf("copy public dir: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan routes and generate router.
|
|
||||||
routes, err := deno.ScanRoutes(apiDst)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("scan routes: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
routerSrc, err := deno.GenerateRouter(routes)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("generate router: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(contextDir, "router.ts"), []byte(routerSrc), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write router.ts: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Dockerfile.
|
|
||||||
dockerfile := deno.GenerateDockerfile()
|
|
||||||
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write Dockerfile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// prepareStaticBuild sets up the build context for a static nginx container.
|
|
||||||
func (m *Manager) prepareStaticBuild(srcDir, contextDir string) error {
|
|
||||||
// Copy all files to context directory.
|
|
||||||
if err := copyDir(srcDir, contextDir); err != nil {
|
|
||||||
return fmt.Errorf("copy files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate Dockerfile.
|
|
||||||
dockerfile := deno.GenerateStaticDockerfile()
|
|
||||||
if err := os.WriteFile(filepath.Join(contextDir, "Dockerfile"), []byte(dockerfile), 0o644); err != nil {
|
|
||||||
return fmt.Errorf("write Dockerfile: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildEnvVars decrypts secrets and builds environment variable list.
|
|
||||||
func (m *Manager) buildEnvVars(siteID string) ([]string, error) {
|
|
||||||
secrets, err := m.store.GetStaticSiteSecretsBySiteID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("get secrets: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
env := make([]string, 0, len(secrets))
|
|
||||||
for _, s := range secrets {
|
|
||||||
value := s.Value
|
|
||||||
if s.Encrypted {
|
|
||||||
decrypted, err := crypto.Decrypt(m.encKey, value)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decrypt secret %s: %w", s.Key, err)
|
|
||||||
}
|
|
||||||
value = decrypted
|
|
||||||
}
|
|
||||||
env = append(env, s.Key+"="+value)
|
|
||||||
}
|
|
||||||
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// removeContainerByName removes a container by its name (best effort).
|
|
||||||
func (m *Manager) removeContainerByName(ctx context.Context, name string) {
|
|
||||||
containers, err := m.docker.ListContainers(ctx, nil)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, c := range containers {
|
|
||||||
if c.Name == name {
|
|
||||||
m.docker.StopContainer(ctx, c.ID, 10)
|
|
||||||
m.docker.RemoveContainer(ctx, c.ID, true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStatus updates the site status in the database.
|
|
||||||
// On failure, it also publishes an event to the event log. On terminal
|
|
||||||
// state transitions (deployed / failed), it dispatches an outgoing
|
|
||||||
// notification using the per-site URL+secret with fall-through to global.
|
|
||||||
func (m *Manager) updateStatus(id, status, commitSHA, errMsg string) {
|
|
||||||
if err := m.store.UpdateStaticSiteStatus(id, status, commitSHA, errMsg); err != nil {
|
|
||||||
slog.Error("static site: failed to update status", "id", id, "status", status, "error", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist failures to event log automatically.
|
|
||||||
if status == "failed" {
|
|
||||||
site, err := m.store.GetStaticSiteByID(id)
|
|
||||||
siteName := id
|
|
||||||
if err == nil {
|
|
||||||
siteName = site.Name
|
|
||||||
}
|
|
||||||
m.publishEvent(id, siteName, "failed: "+errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
if status == "deployed" || status == "failed" {
|
|
||||||
m.dispatchSiteNotification(id, status, errMsg)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// dispatchSiteNotification emits a site_sync_success or site_sync_failure
|
|
||||||
// event to the configured outgoing webhook. Resolution: per-site URL+secret
|
|
||||||
// first, falling through to the global settings.notification_url/secret.
|
|
||||||
// Always best-effort — failures are logged but never block status updates.
|
|
||||||
func (m *Manager) dispatchSiteNotification(siteID, status, errMsg string) {
|
|
||||||
if m.notifier == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
site, err := m.store.GetStaticSiteByID(siteID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: notify lookup failed", "site", siteID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
settings, err := m.store.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("static site: notify settings lookup failed", "site", siteID, "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
url, secret, tier := resolveSiteTarget(site, settings)
|
|
||||||
if url == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
eventType := "site_sync_success"
|
|
||||||
if status == "failed" {
|
|
||||||
eventType = "site_sync_failure"
|
|
||||||
}
|
|
||||||
siteURL := ""
|
|
||||||
if site.Domain != "" {
|
|
||||||
siteURL = "https://" + site.Domain
|
|
||||||
}
|
|
||||||
m.notifier.SendSigned(url, secret, tier, notify.Event{
|
|
||||||
Type: eventType,
|
|
||||||
Project: site.Name,
|
|
||||||
URL: siteURL,
|
|
||||||
Error: errMsg,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveSiteTarget mirrors resolveDeployTarget for the site path: per-site
|
|
||||||
// URL beats global, secret travels with the URL that sourced it.
|
|
||||||
func resolveSiteTarget(site store.StaticSite, settings store.Settings) (string, string, notify.Tier) {
|
|
||||||
if site.NotificationURL != "" {
|
|
||||||
return site.NotificationURL, site.NotificationSecret, notify.TierSite
|
|
||||||
}
|
|
||||||
return settings.NotificationURL, settings.NotificationSecret, notify.TierSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// publishEvent publishes a static site status event on the event bus
|
|
||||||
// and persists it to the event log for the dashboard.
|
|
||||||
func (m *Manager) publishEvent(siteID, siteName, status string) {
|
|
||||||
m.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventStaticSiteStatus,
|
|
||||||
Payload: events.StaticSiteStatusPayload{
|
|
||||||
SiteID: siteID,
|
|
||||||
Name: siteName,
|
|
||||||
Status: status,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Persist to event log.
|
|
||||||
severity := "info"
|
|
||||||
message := fmt.Sprintf("Static site \"%s\": %s", siteName, status)
|
|
||||||
if status == "failed" {
|
|
||||||
severity = "error"
|
|
||||||
}
|
|
||||||
metadata := fmt.Sprintf(`{"site_id":"%s","site_name":"%s","status":"%s"}`, siteID, siteName, status)
|
|
||||||
|
|
||||||
evt, err := m.store.InsertEvent(store.EventLog{
|
|
||||||
Source: "static_site",
|
|
||||||
Severity: severity,
|
|
||||||
Message: message,
|
|
||||||
Metadata: metadata,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("static site: failed to persist event log", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish the persisted event for SSE clients.
|
|
||||||
m.eventBus.Publish(events.Event{
|
|
||||||
Type: events.EventLog,
|
|
||||||
Payload: events.EventLogPayload{
|
|
||||||
ID: evt.ID,
|
|
||||||
Source: "static_site",
|
|
||||||
Severity: severity,
|
|
||||||
Message: message,
|
|
||||||
Metadata: metadata,
|
|
||||||
CreatedAt: evt.CreatedAt,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// copyDir recursively copies a directory.
|
|
||||||
func copyDir(src, dst string) error {
|
|
||||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
relPath, err := filepath.Rel(src, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dstPath := filepath.Join(dst, relPath)
|
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
return os.MkdirAll(dstPath, 0o755)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return os.WriteFile(dstPath, data, info.Mode())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// hostPortStr converts a uint16 port to a string for proxy configuration.
|
|
||||||
func hostPortStr(port uint16) string {
|
|
||||||
return strconv.FormatUint(uint64(port), 10)
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
package staticsite
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/notify"
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestResolveSiteTarget locks the per-site → global precedence for static
|
|
||||||
// site sync notifications. Distinct from the deploy resolver because there
|
|
||||||
// is no project tier between site and settings; a regression that swapped
|
|
||||||
// the order would silently route per-site events to the global receiver.
|
|
||||||
func TestResolveSiteTarget(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
site store.StaticSite
|
|
||||||
settings store.Settings
|
|
||||||
wantURL string
|
|
||||||
wantSec string
|
|
||||||
wantTier notify.Tier
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "site wins when URL set",
|
|
||||||
site: store.StaticSite{NotificationURL: "https://site.example/wh", NotificationSecret: "site-key"},
|
|
||||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
|
||||||
wantURL: "https://site.example/wh",
|
|
||||||
wantSec: "site-key",
|
|
||||||
wantTier: notify.TierSite,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "site URL empty → global wins",
|
|
||||||
site: store.StaticSite{},
|
|
||||||
settings: store.Settings{NotificationURL: "https://global.example/wh", NotificationSecret: "global-key"},
|
|
||||||
wantURL: "https://global.example/wh",
|
|
||||||
wantSec: "global-key",
|
|
||||||
wantTier: notify.TierSettings,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "both empty → empty URL with settings tier",
|
|
||||||
site: store.StaticSite{},
|
|
||||||
settings: store.Settings{},
|
|
||||||
wantURL: "",
|
|
||||||
wantSec: "",
|
|
||||||
wantTier: notify.TierSettings,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
gotURL, gotSec, gotTier := resolveSiteTarget(tc.site, tc.settings)
|
|
||||||
if gotURL != tc.wantURL {
|
|
||||||
t.Errorf("url = %q, want %q", gotURL, tc.wantURL)
|
|
||||||
}
|
|
||||||
if gotSec != tc.wantSec {
|
|
||||||
t.Errorf("secret = %q, want %q", gotSec, tc.wantSec)
|
|
||||||
}
|
|
||||||
if gotTier != tc.wantTier {
|
|
||||||
t.Errorf("tier = %q, want %q", gotTier, tc.wantTier)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -187,23 +187,22 @@ func (s *Store) GetContainerByDockerID(dockerID string) (Container, error) {
|
|||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListProxyRoutes returns proxy-enabled project containers joined with
|
// ListProxyRoutes returns proxy-enabled containers joined with their
|
||||||
// project + stage names. Reads from the normalized containers index and
|
// owning workload's name. The legacy stages join is gone — Role is used
|
||||||
// joins through stage_id so a stage rename does not orphan the row's view.
|
// as the StageName fallback so the Proxies page still reads naturally
|
||||||
//
|
// for project-style workloads. Source is reported as "instance" for
|
||||||
// Source is reported as "instance" for back-compat with the Proxies page
|
// back-compat with the Proxies page filter (the frontend keys off the
|
||||||
// filter (the frontend keys off the literal string).
|
// literal string).
|
||||||
func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
|
func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
|
||||||
rows, err := s.db.Query(`
|
rows, err := s.db.Query(`
|
||||||
SELECT c.id, p.id, p.name, s.id, s.name,
|
SELECT c.id, w.id, w.name,
|
||||||
c.image_tag, c.subdomain, c.container_id, c.port,
|
c.image_tag, c.subdomain, c.container_id, c.port,
|
||||||
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at
|
c.proxy_route_id, c.npm_proxy_id, c.state, c.created_at,
|
||||||
|
c.role, c.stage_id
|
||||||
FROM containers c
|
FROM containers c
|
||||||
JOIN workloads w ON w.id = c.workload_id AND w.kind = 'project'
|
JOIN workloads w ON w.id = c.workload_id
|
||||||
JOIN projects p ON p.id = w.ref_id
|
|
||||||
JOIN stages s ON s.id = c.stage_id OR (c.stage_id = '' AND s.project_id = p.id AND s.name = c.role)
|
|
||||||
WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
|
WHERE c.subdomain != '' AND (c.proxy_route_id != '' OR c.npm_proxy_id > 0)
|
||||||
ORDER BY p.name, s.name, c.created_at DESC`,
|
ORDER BY w.name, c.role, c.created_at DESC`,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("query proxy routes: %w", err)
|
return nil, fmt.Errorf("query proxy routes: %w", err)
|
||||||
@@ -213,14 +212,18 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
|
|||||||
routes := []ProxyRoute{}
|
routes := []ProxyRoute{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ProxyRoute
|
var r ProxyRoute
|
||||||
|
var role, stageID string
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&r.InstanceID, &r.ProjectID, &r.ProjectName, &r.StageID, &r.StageName,
|
&r.InstanceID, &r.ProjectID, &r.ProjectName,
|
||||||
&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
|
&r.ImageTag, &r.Subdomain, &r.ContainerID, &r.Port,
|
||||||
&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
|
&r.ProxyRouteID, &r.NpmProxyID, &r.Status, &r.CreatedAt,
|
||||||
|
&role, &stageID,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, fmt.Errorf("scan proxy route: %w", err)
|
return nil, fmt.Errorf("scan proxy route: %w", err)
|
||||||
}
|
}
|
||||||
r.Source = "instance"
|
r.Source = "instance"
|
||||||
|
r.StageID = stageID
|
||||||
|
r.StageName = role
|
||||||
if domain != "" && r.Subdomain != "" {
|
if domain != "" && r.Subdomain != "" {
|
||||||
r.Domain = r.Subdomain + "." + domain
|
r.Domain = r.Subdomain + "." + domain
|
||||||
}
|
}
|
||||||
@@ -229,40 +232,6 @@ func (s *Store) ListProxyRoutes(domain string) ([]ProxyRoute, error) {
|
|||||||
return routes, rows.Err()
|
return routes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListContainersByStageID returns project containers for the given stage,
|
|
||||||
// newest first. Resolves via stage_id with a fallback to the legacy
|
|
||||||
// (stage.name = container.role) join for rows written before the stage_id
|
|
||||||
// column was populated. Replaces GetInstancesByStageID.
|
|
||||||
func (s *Store) ListContainersByStageID(stageID string) ([]Container, error) {
|
|
||||||
rows, err := s.db.Query(`
|
|
||||||
SELECT `+prefixCols(containerColumns, "c.")+`
|
|
||||||
FROM containers c
|
|
||||||
LEFT JOIN stages s ON s.id = ?
|
|
||||||
WHERE c.stage_id = ?
|
|
||||||
OR (c.stage_id = '' AND s.id IS NOT NULL
|
|
||||||
AND c.role = s.name
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM workloads w
|
|
||||||
WHERE w.id = c.workload_id
|
|
||||||
AND w.kind = 'project'
|
|
||||||
AND w.ref_id = s.project_id))
|
|
||||||
ORDER BY c.created_at DESC`, stageID, stageID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query containers by stage: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
out := []Container{}
|
|
||||||
for rows.Next() {
|
|
||||||
c, err := scanContainer(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan container: %w", err)
|
|
||||||
}
|
|
||||||
out = append(out, c)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListContainersByWorkload returns all containers for a given workload, newest first.
|
// ListContainersByWorkload returns all containers for a given workload, newest first.
|
||||||
func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) {
|
func (s *Store) ListContainersByWorkload(workloadID string) ([]Container, error) {
|
||||||
rows, err := s.db.Query(
|
rows, err := s.db.Query(
|
||||||
|
|||||||
@@ -1,212 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateDeploy inserts a new deploy record.
|
|
||||||
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
|
|
||||||
d.ID = uuid.New().String()
|
|
||||||
d.StartedAt = Now()
|
|
||||||
if d.Status == "" {
|
|
||||||
d.Status = "pending"
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO deploys (id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
d.ID, d.ProjectID, d.StageID, d.InstanceID, d.ImageTag, d.Status, d.StartedAt, d.FinishedAt, d.Error,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Deploy{}, fmt.Errorf("insert deploy: %w", err)
|
|
||||||
}
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeployByID returns a single deploy by its ID.
|
|
||||||
func (s *Store) GetDeployByID(id string) (Deploy, error) {
|
|
||||||
var d Deploy
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
|
|
||||||
FROM deploys WHERE id = ?`, id,
|
|
||||||
).Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Deploy{}, fmt.Errorf("deploy %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Deploy{}, fmt.Errorf("query deploy: %w", err)
|
|
||||||
}
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeploysByProjectID returns all deploys for a project, newest first.
|
|
||||||
func (s *Store) GetDeploysByProjectID(projectID string) ([]Deploy, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
|
|
||||||
FROM deploys WHERE project_id = ? ORDER BY started_at DESC`, projectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query deploys: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanDeploys(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRecentDeploys returns the most recent deploys across all projects.
|
|
||||||
func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error
|
|
||||||
FROM deploys ORDER BY started_at DESC LIMIT ?`, limit,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query recent deploys: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanDeploys(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy.
|
|
||||||
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
|
|
||||||
ts := Now()
|
|
||||||
var finishedAt string
|
|
||||||
if IsTerminalDeployStatus(status) {
|
|
||||||
finishedAt = ts
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE deploys SET status=?, error=?, finished_at=? WHERE id=?`,
|
|
||||||
status, deployErr, finishedAt, id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update deploy status: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("deploy %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDeployInstanceID links a deploy to the instance it created.
|
|
||||||
func (s *Store) SetDeployInstanceID(deployID string, instanceID string) error {
|
|
||||||
result, err := s.db.Exec(`UPDATE deploys SET instance_id=? WHERE id=?`, instanceID, deployID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set deploy instance: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("deploy %s: %w", deployID, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendDeployLog adds a log entry for a deploy.
|
|
||||||
func (s *Store) AppendDeployLog(deployID string, message string, level string) error {
|
|
||||||
if level == "" {
|
|
||||||
level = "info"
|
|
||||||
}
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
|
|
||||||
deployID, message, level, Now(),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("append deploy log: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeployLogs returns all log entries for a deploy, ordered chronologically.
|
|
||||||
func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, deploy_id, message, level, created_at
|
|
||||||
FROM deploy_logs WHERE deploy_id = ? ORDER BY id`, deployID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query deploy logs: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
logs := []DeployLog{}
|
|
||||||
for rows.Next() {
|
|
||||||
var l DeployLog
|
|
||||||
if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan deploy log: %w", err)
|
|
||||||
}
|
|
||||||
logs = append(logs, l)
|
|
||||||
}
|
|
||||||
return logs, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanDeploys is a helper that scans deploy rows from a cursor.
|
|
||||||
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
|
|
||||||
deploys := []Deploy{}
|
|
||||||
for rows.Next() {
|
|
||||||
var d Deploy
|
|
||||||
if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan deploy: %w", err)
|
|
||||||
}
|
|
||||||
deploys = append(deploys, d)
|
|
||||||
}
|
|
||||||
return deploys, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsTerminalDeployStatus returns true if the status indicates the deploy is finished.
|
|
||||||
func IsTerminalDeployStatus(status string) bool {
|
|
||||||
switch status {
|
|
||||||
case "success", "failed", "rolled_back":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetDeploys returns deploys with optional filtering by project and stage, with pagination.
|
|
||||||
func (s *Store) GetDeploys(projectID, stageID string, limit, offset int) ([]Deploy, error) {
|
|
||||||
query := `SELECT id, project_id, stage_id, instance_id, image_tag, status, started_at, finished_at, error FROM deploys`
|
|
||||||
var args []any
|
|
||||||
var conditions []string
|
|
||||||
|
|
||||||
if projectID != "" {
|
|
||||||
conditions = append(conditions, "project_id = ?")
|
|
||||||
args = append(args, projectID)
|
|
||||||
}
|
|
||||||
if stageID != "" {
|
|
||||||
conditions = append(conditions, "stage_id = ?")
|
|
||||||
args = append(args, stageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
query += " ORDER BY started_at DESC LIMIT ? OFFSET ?"
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
rows, err := s.db.Query(query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query deploys: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
return scanDeploys(rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CleanupOldDeploys removes deploy records and their logs older than the given
|
|
||||||
// number of days. Returns the number of deploys removed.
|
|
||||||
func (s *Store) CleanupOldDeploys(retentionDays int) (int64, error) {
|
|
||||||
cutoff := fmt.Sprintf("-%d days", retentionDays)
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`DELETE FROM deploys WHERE started_at < datetime('now', ?)`, cutoff,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("cleanup old deploys: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
return n, nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rowScanner is the subset of *sql.Row / *sql.Rows used by row scanners
|
||||||
|
// across this package. Kept package-private — callers should not need to
|
||||||
|
// implement it themselves.
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// BoolToInt converts a Go bool to the 0/1 INTEGER convention SQLite uses
|
||||||
|
// for boolean columns across this schema.
|
||||||
|
func BoolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateWebhookSecret returns a 256-bit hex-encoded random token.
|
||||||
|
// Exported so the api layer can share one implementation — keeping
|
||||||
|
// two copies invited drift (one panicked, one fell back to UUID).
|
||||||
|
// crypto/rand directly rather than uuid.New() so the intent ("secret
|
||||||
|
// token, not identifier") is explicit and the entropy is unambiguous.
|
||||||
|
func GenerateWebhookSecret() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
// crypto/rand is documented to never fail on supported platforms;
|
||||||
|
// fall back to a UUID rather than panicking.
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateWebhookSecret is the in-package alias kept for the existing
|
||||||
|
// CRUD call sites that don't reach across packages. New callers in
|
||||||
|
// other packages should use GenerateWebhookSecret directly.
|
||||||
|
func generateWebhookSecret() string { return GenerateWebhookSecret() }
|
||||||
+13
-174
@@ -1,45 +1,5 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
// Project represents a deployable application.
|
|
||||||
type Project struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Registry string `json:"registry"`
|
|
||||||
Image string `json:"image"`
|
|
||||||
Port int `json:"port"`
|
|
||||||
Healthcheck string `json:"healthcheck"`
|
|
||||||
Env string `json:"env"` // JSON-encoded map
|
|
||||||
Volumes string `json:"volumes"` // JSON-encoded map
|
|
||||||
NpmAccessListID int `json:"npm_access_list_id"` // per-project override, 0 = use global
|
|
||||||
WebhookSecret string `json:"-"` // per-project webhook secret (URL identifier); never serialized
|
|
||||||
WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized
|
|
||||||
WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests
|
|
||||||
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
|
||||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage represents a deployment stage within a project (e.g. dev, rel, prod).
|
|
||||||
type Stage struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
TagPattern string `json:"tag_pattern"`
|
|
||||||
AutoDeploy bool `json:"auto_deploy"`
|
|
||||||
MaxInstances int `json:"max_instances"`
|
|
||||||
Confirm bool `json:"confirm"`
|
|
||||||
EnableProxy bool `json:"enable_proxy"`
|
|
||||||
PromoteFrom string `json:"promote_from"`
|
|
||||||
Subdomain string `json:"subdomain"`
|
|
||||||
NotificationURL string `json:"notification_url"`
|
|
||||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
|
||||||
CpuLimit float64 `json:"cpu_limit"` // CPU cores (e.g., 0.5, 1, 2), 0 = unlimited
|
|
||||||
MemoryLimit int `json:"memory_limit"` // megabytes, 0 = unlimited
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registry represents a container image registry.
|
// Registry represents a container image registry.
|
||||||
type Registry struct {
|
type Registry struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -142,10 +102,15 @@ type DNSRecord struct {
|
|||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProxyRoute is a proxy-enabled container row joined with its project + stage
|
// ProxyRoute shapes one proxy-enabled container row for the Proxies
|
||||||
// names, shaped for the Proxies page. Source is "instance" for project
|
// page. The legacy field names (ProjectID, ProjectName, StageID,
|
||||||
// containers and "static_site" for site rows — the names are historical
|
// StageName, InstanceID) are retained verbatim for the existing
|
||||||
// (the table itself was renamed to containers in the workload refactor).
|
// frontend contract — after the workload-first cutover they map to:
|
||||||
|
// ProjectID/Name → workload id / workload name
|
||||||
|
// StageID/Name → containers.stage_id / containers.role
|
||||||
|
// InstanceID → container row id
|
||||||
|
// Source → "instance" for image/compose, "static_site" for static
|
||||||
|
// Renaming would require a coordinated frontend change; deferred.
|
||||||
type ProxyRoute struct {
|
type ProxyRoute struct {
|
||||||
Source string `json:"source"`
|
Source string `json:"source"`
|
||||||
InstanceID string `json:"instance_id"`
|
InstanceID string `json:"instance_id"`
|
||||||
@@ -164,39 +129,6 @@ type ProxyRoute struct {
|
|||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deploy represents a deployment attempt.
|
|
||||||
type Deploy struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
StageID string `json:"stage_id"`
|
|
||||||
InstanceID string `json:"instance_id"`
|
|
||||||
ImageTag string `json:"image_tag"`
|
|
||||||
Status string `json:"status"` // pending, pulling, starting, configuring_proxy, health_checking, success, failed, rolled_back
|
|
||||||
StartedAt string `json:"started_at"`
|
|
||||||
FinishedAt string `json:"finished_at"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeployLog is a single log entry for a deploy.
|
|
||||||
type DeployLog struct {
|
|
||||||
ID int64 `json:"id"`
|
|
||||||
DeployID string `json:"deploy_id"`
|
|
||||||
Message string `json:"message"`
|
|
||||||
Level string `json:"level"` // info, warn, error
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StageEnv represents a per-stage environment variable override.
|
|
||||||
type StageEnv struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
StageID string `json:"stage_id"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Encrypted bool `json:"encrypted"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// WorkloadVolume is the plugin-shape equivalent of legacy Volume: a
|
// WorkloadVolume is the plugin-shape equivalent of legacy Volume: a
|
||||||
// per-workload mount declaration. The Scope enum matches the existing
|
// per-workload mount declaration. The Scope enum matches the existing
|
||||||
// VolumeScope contract so the legacy resolver can be reused once its
|
// VolumeScope contract so the legacy resolver can be reused once its
|
||||||
@@ -256,101 +188,6 @@ func IsValidVolumeScope(s string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volume represents a volume mount configuration for a project.
|
|
||||||
type Volume struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
Target string `json:"target"`
|
|
||||||
Mode string `json:"mode,omitempty"` // legacy: shared/isolated — kept for DB compat
|
|
||||||
Scope string `json:"scope"` // instance, stage, project, project_named, named, ephemeral
|
|
||||||
Name string `json:"name"` // required for project_named and named scopes
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StaticSite represents a static site deployed from a Git repository folder.
|
|
||||||
type StaticSite struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Provider string `json:"provider"` // "gitea", "github", "gitlab"; empty = autodetect
|
|
||||||
GiteaURL string `json:"gitea_url"` // base URL, e.g. https://git.example.com
|
|
||||||
RepoOwner string `json:"repo_owner"`
|
|
||||||
RepoName string `json:"repo_name"`
|
|
||||||
Branch string `json:"branch"`
|
|
||||||
FolderPath string `json:"folder_path"` // path within repo, e.g. "Pages"
|
|
||||||
AccessToken string `json:"access_token"` // encrypted; optional for public repos
|
|
||||||
Domain string `json:"domain"` // full domain for proxy
|
|
||||||
Mode string `json:"mode"` // "static" or "deno"
|
|
||||||
RenderMarkdown bool `json:"render_markdown"`
|
|
||||||
SyncTrigger string `json:"sync_trigger"` // "push", "tag", "manual"
|
|
||||||
TagPattern string `json:"tag_pattern"` // glob pattern for tag-based sync
|
|
||||||
ContainerID string `json:"container_id"`
|
|
||||||
ProxyRouteID string `json:"proxy_route_id"`
|
|
||||||
Status string `json:"status"` // idle, syncing, deployed, failed
|
|
||||||
LastSyncAt string `json:"last_sync_at"`
|
|
||||||
LastCommitSHA string `json:"last_commit_sha"`
|
|
||||||
Error string `json:"error"`
|
|
||||||
StorageEnabled bool `json:"storage_enabled"`
|
|
||||||
StorageLimitMB int `json:"storage_limit_mb"` // 0 = unlimited
|
|
||||||
WebhookSecret string `json:"-"` // per-site webhook secret (URL identifier); never serialized
|
|
||||||
WebhookSigningSecret string `json:"-"` // HMAC-SHA256 key for inbound webhook signature verification; never serialized
|
|
||||||
WebhookRequireSignature bool `json:"webhook_require_signature"` // if true, reject unsigned/invalid-sig webhook requests
|
|
||||||
NotificationURL string `json:"notification_url"` // outgoing webhook target; empty = inherit from settings
|
|
||||||
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
UpdatedAt string `json:"updated_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StaticSiteSecret represents an encrypted environment variable for a static site's Deno backend.
|
|
||||||
type StaticSiteSecret struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
SiteID string `json:"site_id"`
|
|
||||||
Key string `json:"key"`
|
|
||||||
Value string `json:"value"`
|
|
||||||
Encrypted bool `json:"encrypted"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
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.
|
// EventLog represents a persistent event log entry.
|
||||||
type EventLog struct {
|
type EventLog struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
@@ -437,8 +274,10 @@ const (
|
|||||||
LogScanSeverityError = "error"
|
LogScanSeverityError = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WorkloadKind enumerates the kinds of things that own containers.
|
// WorkloadKind enumerates the legacy discriminator values written into
|
||||||
// Each kind has a corresponding row in projects/stacks/static_sites referenced via Workload.RefID.
|
// containers.workload_kind and workloads.kind. After the hard cutover the
|
||||||
|
// backing project / stack / static_site tables are gone — these constants
|
||||||
|
// are just strings used to filter the unified containers index in the UI.
|
||||||
type WorkloadKind string
|
type WorkloadKind string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PollState tracks the last polled tag for a stage, enabling the poller to
|
|
||||||
// detect new tags since the previous poll cycle.
|
|
||||||
type PollState struct {
|
|
||||||
StageID string `json:"stage_id"`
|
|
||||||
LastTag string `json:"last_tag"`
|
|
||||||
LastPolled string `json:"last_polled"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetPollState returns the poll state for a given stage.
|
|
||||||
func (s *Store) GetPollState(stageID string) (PollState, error) {
|
|
||||||
var ps PollState
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT stage_id, last_tag, last_polled FROM poll_states WHERE stage_id = ?`,
|
|
||||||
stageID,
|
|
||||||
).Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return PollState{}, fmt.Errorf("poll state for stage %s: %w", stageID, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return PollState{}, fmt.Errorf("query poll state: %w", err)
|
|
||||||
}
|
|
||||||
return ps, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpsertPollState inserts or updates the poll state for a stage.
|
|
||||||
func (s *Store) UpsertPollState(ps PollState) error {
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO poll_states (stage_id, last_tag, last_polled)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(stage_id) DO UPDATE SET last_tag=excluded.last_tag, last_polled=excluded.last_polled`,
|
|
||||||
ps.StageID, ps.LastTag, ps.LastPolled,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("upsert poll state: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeletePollState removes the poll state for a stage.
|
|
||||||
func (s *Store) DeletePollState(stageID string) error {
|
|
||||||
_, err := s.db.Exec(`DELETE FROM poll_states WHERE stage_id = ?`, stageID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete poll state: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllPollStates returns all poll states, ordered by last_polled descending.
|
|
||||||
func (s *Store) GetAllPollStates() ([]PollState, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT stage_id, last_tag, last_polled FROM poll_states ORDER BY last_polled DESC`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query poll states: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
states := []PollState{}
|
|
||||||
for rows.Next() {
|
|
||||||
var ps PollState
|
|
||||||
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan poll state: %w", err)
|
|
||||||
}
|
|
||||||
states = append(states, ps)
|
|
||||||
}
|
|
||||||
return states, rows.Err()
|
|
||||||
}
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/rand"
|
|
||||||
"database/sql"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// minWebhookSecretLength is the smallest user-supplied webhook secret accepted
|
|
||||||
// at insert time. Auto-generated secrets are 64 hex chars (256 bits); a
|
|
||||||
// 32-char floor still leaves > 128 bits of brute-force resistance for hex
|
|
||||||
// alphabets and rejects obvious typos / placeholder strings.
|
|
||||||
const minWebhookSecretLength = 32
|
|
||||||
|
|
||||||
// generateWebhookSecret returns a 256-bit hex-encoded random token. We use
|
|
||||||
// crypto/rand directly rather than uuid.New() so the intent ("secret token,
|
|
||||||
// not identifier") is explicit and the entropy is unambiguous.
|
|
||||||
func generateWebhookSecret() string {
|
|
||||||
b := make([]byte, 32)
|
|
||||||
if _, err := rand.Read(b); err != nil {
|
|
||||||
// crypto/rand is documented to never fail on supported platforms;
|
|
||||||
// fall back to a UUID rather than panicking.
|
|
||||||
return uuid.New().String()
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// projectCols is the canonical column list for projects queries.
|
|
||||||
const projectCols = `id, name, registry, image, port, healthcheck, env, volumes,
|
|
||||||
npm_access_list_id, webhook_secret, webhook_signing_secret, webhook_require_signature,
|
|
||||||
notification_url, notification_secret, created_at, updated_at`
|
|
||||||
|
|
||||||
// rowScanner is the subset of *sql.Row / *sql.Rows used by scanProject.
|
|
||||||
type rowScanner interface {
|
|
||||||
Scan(dest ...any) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanProject reads one row in projectCols order. webhook_require_signature
|
|
||||||
// is stored as INTEGER and converted to bool here.
|
|
||||||
func scanProject(r rowScanner) (Project, error) {
|
|
||||||
var p Project
|
|
||||||
var requireSig int
|
|
||||||
if err := r.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes,
|
|
||||||
&p.NpmAccessListID, &p.WebhookSecret, &p.WebhookSigningSecret, &requireSig,
|
|
||||||
&p.NotificationURL, &p.NotificationSecret, &p.CreatedAt, &p.UpdatedAt); err != nil {
|
|
||||||
return Project{}, err
|
|
||||||
}
|
|
||||||
p.WebhookRequireSignature = requireSig != 0
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateProject inserts a new project and returns it. A webhook secret is
|
|
||||||
// generated automatically if one is not already set on the input. Project
|
|
||||||
// row + matching workload row are written in a single transaction.
|
|
||||||
func (s *Store) CreateProject(p Project) (Project, error) {
|
|
||||||
p.ID = uuid.New().String()
|
|
||||||
p.CreatedAt = Now()
|
|
||||||
p.UpdatedAt = p.CreatedAt
|
|
||||||
if p.WebhookSecret == "" {
|
|
||||||
p.WebhookSecret = generateWebhookSecret()
|
|
||||||
} else if len(p.WebhookSecret) < minWebhookSecretLength {
|
|
||||||
return Project{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
requireSig := 0
|
|
||||||
if p.WebhookRequireSignature {
|
|
||||||
requireSig = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return Project{}, fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`INSERT INTO projects (`+projectCols+`)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
p.ID, p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
|
|
||||||
p.NpmAccessListID, p.WebhookSecret, p.WebhookSigningSecret, requireSig,
|
|
||||||
p.NotificationURL, p.NotificationSecret, p.CreatedAt, p.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return Project{}, fmt.Errorf("insert project: %w", err)
|
|
||||||
}
|
|
||||||
if err := SyncProjectWorkloadTx(tx, p); err != nil {
|
|
||||||
return Project{}, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return Project{}, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProjectByID returns a single project by its ID.
|
|
||||||
func (s *Store) GetProjectByID(id string) (Project, error) {
|
|
||||||
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
|
|
||||||
p, err := scanProject(row)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Project{}, fmt.Errorf("project %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Project{}, fmt.Errorf("query project: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProjectByWebhookSecret looks up a project by its webhook secret.
|
|
||||||
// Returns ErrNotFound if no project has this secret (including empty).
|
|
||||||
func (s *Store) GetProjectByWebhookSecret(secret string) (Project, error) {
|
|
||||||
if secret == "" {
|
|
||||||
return Project{}, ErrNotFound
|
|
||||||
}
|
|
||||||
row := s.db.QueryRow(`SELECT `+projectCols+` FROM projects WHERE webhook_secret = ?`, secret)
|
|
||||||
p, err := scanProject(row)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Project{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Project{}, fmt.Errorf("query project by webhook secret: %w", err)
|
|
||||||
}
|
|
||||||
return p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllProjects returns every project ordered by name.
|
|
||||||
func (s *Store) GetAllProjects() ([]Project, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT ` + projectCols + ` FROM projects ORDER BY name`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query projects: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
projects := []Project{}
|
|
||||||
for rows.Next() {
|
|
||||||
p, err := scanProject(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan project: %w", err)
|
|
||||||
}
|
|
||||||
projects = append(projects, p)
|
|
||||||
}
|
|
||||||
return projects, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProjectsByImage returns all projects using the given image, newest first.
|
|
||||||
func (s *Store) GetProjectsByImage(image string) ([]Project, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT `+projectCols+` FROM projects WHERE image = ? ORDER BY created_at DESC`, image,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query projects by image: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
projects := []Project{}
|
|
||||||
for rows.Next() {
|
|
||||||
p, err := scanProject(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("scan project: %w", err)
|
|
||||||
}
|
|
||||||
projects = append(projects, p)
|
|
||||||
}
|
|
||||||
return projects, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateProjectAndSyncWorkloadTx performs the parent UPDATE + workload sync in
|
|
||||||
// a single transaction. Used by every Set*Secret / UpdateProject path so the
|
|
||||||
// project row and the workload row never desync after a partial failure.
|
|
||||||
// updateSQL must be a parameterized UPDATE on `projects` ending with `WHERE id=?`;
|
|
||||||
// args are the parameter values in order, with the project ID last.
|
|
||||||
func (s *Store) updateProjectAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
result, err := tx.Exec(updateSQL, args...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update project: %w", err)
|
|
||||||
}
|
|
||||||
n, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("project %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Re-read the row inside the transaction so the workload sync sees the
|
|
||||||
// canonical values (the caller may have only updated one column).
|
|
||||||
row := tx.QueryRow(`SELECT `+projectCols+` FROM projects WHERE id = ?`, id)
|
|
||||||
p, err := scanProject(row)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reread project for workload sync: %w", err)
|
|
||||||
}
|
|
||||||
if err := SyncProjectWorkloadTx(tx, p); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProject updates an existing project's mutable fields. Webhook secret
|
|
||||||
// and notification_secret are intentionally not updated here — use the
|
|
||||||
// dedicated SetProjectWebhookSecret / SetProjectNotificationSecret helpers.
|
|
||||||
func (s *Store) UpdateProject(p Project) error {
|
|
||||||
p.UpdatedAt = Now()
|
|
||||||
return s.updateProjectAndSyncWorkloadTx(p.ID,
|
|
||||||
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?,
|
|
||||||
npm_access_list_id=?, notification_url=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
p.Name, p.Registry, p.Image, p.Port, p.Healthcheck, p.Env, p.Volumes,
|
|
||||||
p.NpmAccessListID, p.NotificationURL, p.UpdatedAt, p.ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProjectWebhookSecret assigns a webhook secret to a project.
|
|
||||||
// Pass an empty string to disable webhook access for the project.
|
|
||||||
func (s *Store) SetProjectWebhookSecret(id, secret string) error {
|
|
||||||
return s.updateProjectAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE projects SET webhook_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProjectWebhookSigningSecret assigns the HMAC signing secret used to
|
|
||||||
// verify inbound webhook payloads. Pass an empty string to clear it (which
|
|
||||||
// also implicitly disables signature enforcement on the next request).
|
|
||||||
func (s *Store) SetProjectWebhookSigningSecret(id, secret string) error {
|
|
||||||
return s.updateProjectAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE projects SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProjectWebhookRequireSignature toggles whether unsigned (or
|
|
||||||
// invalidly-signed) webhook requests are rejected with 401.
|
|
||||||
func (s *Store) SetProjectWebhookRequireSignature(id string, require bool) error {
|
|
||||||
v := 0
|
|
||||||
if require {
|
|
||||||
v = 1
|
|
||||||
}
|
|
||||||
return s.updateProjectAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE projects SET webhook_require_signature=?, updated_at=? WHERE id=?`,
|
|
||||||
v, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureProjectWebhookSecret returns the current webhook secret for a project,
|
|
||||||
// generating one on the fly if the stored value is empty (lazy backfill for
|
|
||||||
// projects created before the per-project webhook migration).
|
|
||||||
func (s *Store) EnsureProjectWebhookSecret(id string) (string, error) {
|
|
||||||
project, err := s.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if project.WebhookSecret != "" {
|
|
||||||
return project.WebhookSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetProjectWebhookSecret(id, secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetProjectNotificationSecret rotates the project's outgoing-webhook signing
|
|
||||||
// secret. Empty string disables HMAC signing for this project (notifications
|
|
||||||
// still send unsigned, falling through to the parent tier's secret if any).
|
|
||||||
func (s *Store) SetProjectNotificationSecret(id, secret string) error {
|
|
||||||
return s.updateProjectAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE projects SET notification_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureProjectNotificationSecret returns the current outgoing-webhook signing
|
|
||||||
// secret, generating one lazily if missing. Used when an operator first opens
|
|
||||||
// the outgoing-webhook panel for a project that predates this feature.
|
|
||||||
func (s *Store) EnsureProjectNotificationSecret(id string) (string, error) {
|
|
||||||
project, err := s.GetProjectByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if project.NotificationSecret != "" {
|
|
||||||
return project.NotificationSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetProjectNotificationSecret(id, secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteProject removes a project by ID. Cascading deletes handle stages, instances, and deploys.
|
|
||||||
// Workload row + container index entries are removed too so the global views
|
|
||||||
// don't show ghost rows after a project is gone. Atomic: the project, its
|
|
||||||
// container index entries, and its workload row all live or die together.
|
|
||||||
func (s *Store) DeleteProject(id string) error {
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
// Resolve the workload before deleting the project so we have the
|
|
||||||
// workload ID for the cascade.
|
|
||||||
var workloadID string
|
|
||||||
if err := tx.QueryRow(
|
|
||||||
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
|
|
||||||
string(WorkloadKindProject), id,
|
|
||||||
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return fmt.Errorf("lookup project workload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := tx.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete project: %w", err)
|
|
||||||
}
|
|
||||||
n, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("project %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if workloadID != "" {
|
|
||||||
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
|
|
||||||
return fmt.Errorf("delete project containers: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
|
|
||||||
return fmt.Errorf("delete project workload: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
// TestListProxyRoutesJoinShape verifies the new containers-based join produces
|
|
||||||
// the same ProxyRoute shape the /api/proxies frontend has consumed since this
|
|
||||||
// query was instances-based. Without this test, a missing column or a wrong
|
|
||||||
// join condition would silently break the Proxies page.
|
|
||||||
func TestListProxyRoutesJoinShape(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, err := s.CreateProject(Project{
|
|
||||||
Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProject: %v", err)
|
|
||||||
}
|
|
||||||
stage, err := s.CreateStage(Stage{
|
|
||||||
ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1, EnableProxy: true,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateStage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("workload: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container with both subdomain and proxy_route_id populated — the rule
|
|
||||||
// the WHERE clause filters on.
|
|
||||||
if _, err := s.CreateContainer(Container{
|
|
||||||
WorkloadID: w.ID,
|
|
||||||
WorkloadKind: "project",
|
|
||||||
Role: stage.Name,
|
|
||||||
ContainerID: "docker-abc",
|
|
||||||
ImageTag: "v1",
|
|
||||||
State: "running",
|
|
||||||
Port: 8080,
|
|
||||||
Subdomain: "wf-prod",
|
|
||||||
ProxyRouteID: "route-1",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("CreateContainer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Container without subdomain — must be filtered OUT.
|
|
||||||
if _, err := s.CreateContainer(Container{
|
|
||||||
WorkloadID: w.ID,
|
|
||||||
WorkloadKind: "project",
|
|
||||||
Role: stage.Name,
|
|
||||||
ContainerID: "docker-def",
|
|
||||||
ImageTag: "v2",
|
|
||||||
State: "running",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("CreateContainer 2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
routes, err := s.ListProxyRoutes("example.test")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListProxyRoutes: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(routes) != 1 {
|
|
||||||
t.Fatalf("expected 1 route, got %d (filter wrong?)", len(routes))
|
|
||||||
}
|
|
||||||
r := routes[0]
|
|
||||||
if r.Source != "instance" {
|
|
||||||
t.Errorf("Source: got %q, want 'instance' (back-compat)", r.Source)
|
|
||||||
}
|
|
||||||
if r.ProjectID != p.ID {
|
|
||||||
t.Errorf("ProjectID: got %q, want %q", r.ProjectID, p.ID)
|
|
||||||
}
|
|
||||||
if r.ProjectName != "wf" {
|
|
||||||
t.Errorf("ProjectName: got %q, want 'wf'", r.ProjectName)
|
|
||||||
}
|
|
||||||
if r.StageID != stage.ID {
|
|
||||||
t.Errorf("StageID: got %q, want %q", r.StageID, stage.ID)
|
|
||||||
}
|
|
||||||
if r.StageName != "prod" {
|
|
||||||
t.Errorf("StageName: got %q, want 'prod'", r.StageName)
|
|
||||||
}
|
|
||||||
if r.ImageTag != "v1" {
|
|
||||||
t.Errorf("ImageTag: got %q, want 'v1'", r.ImageTag)
|
|
||||||
}
|
|
||||||
if r.Subdomain != "wf-prod" {
|
|
||||||
t.Errorf("Subdomain: got %q, want 'wf-prod'", r.Subdomain)
|
|
||||||
}
|
|
||||||
if r.Domain != "wf-prod.example.test" {
|
|
||||||
t.Errorf("Domain: got %q, want 'wf-prod.example.test'", r.Domain)
|
|
||||||
}
|
|
||||||
if r.ContainerID != "docker-abc" {
|
|
||||||
t.Errorf("ContainerID: got %q, want 'docker-abc'", r.ContainerID)
|
|
||||||
}
|
|
||||||
if r.Port != 8080 {
|
|
||||||
t.Errorf("Port: got %d, want 8080", r.Port)
|
|
||||||
}
|
|
||||||
if r.ProxyRouteID != "route-1" {
|
|
||||||
t.Errorf("ProxyRouteID: got %q, want 'route-1'", r.ProxyRouteID)
|
|
||||||
}
|
|
||||||
if r.Status != "running" {
|
|
||||||
t.Errorf("Status (state): got %q, want 'running'", r.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListProxyRoutesNpmOnly(t *testing.T) {
|
|
||||||
// NPM-only routes (npm_proxy_id > 0, proxy_route_id == "") must still be
|
|
||||||
// returned — that's the original WHERE-clause OR branch.
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{
|
|
||||||
Name: "npm-only", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
stage, _ := s.CreateStage(Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 1, EnableProxy: true,
|
|
||||||
})
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
|
|
||||||
if _, err := s.CreateContainer(Container{
|
|
||||||
WorkloadID: w.ID,
|
|
||||||
WorkloadKind: "project",
|
|
||||||
Role: stage.Name,
|
|
||||||
ContainerID: "docker-1",
|
|
||||||
Subdomain: "npm-only-dev",
|
|
||||||
NpmProxyID: 42,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("CreateContainer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
routes, err := s.ListProxyRoutes("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListProxyRoutes: %v", err)
|
|
||||||
}
|
|
||||||
if len(routes) != 1 {
|
|
||||||
t.Fatalf("expected 1 npm route, got %d", len(routes))
|
|
||||||
}
|
|
||||||
if routes[0].NpmProxyID != 42 {
|
|
||||||
t.Errorf("NpmProxyID: got %d, want 42", routes[0].NpmProxyID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListProxyRoutesIgnoresWrongRole(t *testing.T) {
|
|
||||||
// Belt-and-suspenders: a container whose role doesn't match a stage name
|
|
||||||
// would orphan the JOIN. Verify the row falls out cleanly (LEFT JOIN
|
|
||||||
// would expose a real bug here).
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{
|
|
||||||
Name: "wf", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
_, _ = s.CreateStage(Stage{
|
|
||||||
ProjectID: p.ID, Name: "prod", TagPattern: "*", MaxInstances: 1,
|
|
||||||
})
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
|
|
||||||
if _, err := s.CreateContainer(Container{
|
|
||||||
WorkloadID: w.ID,
|
|
||||||
WorkloadKind: "project",
|
|
||||||
Role: "ghost-stage", // intentionally not a real stage name
|
|
||||||
ContainerID: "docker-x",
|
|
||||||
Subdomain: "wf-ghost",
|
|
||||||
ProxyRouteID: "route-x",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("CreateContainer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
routes, err := s.ListProxyRoutes("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("ListProxyRoutes: %v", err)
|
|
||||||
}
|
|
||||||
if len(routes) != 0 {
|
|
||||||
t.Fatalf("orphan-role row leaked into result: got %d", len(routes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -101,6 +101,27 @@ func (s *Store) UpdateSettings(st Settings) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureSettingsNotificationSecret returns the current global notification
|
||||||
|
// secret, lazily generating + persisting one if none is set. Lets the
|
||||||
|
// settings UI render a stable secret on first load for any install that
|
||||||
|
// predates the signing feature.
|
||||||
|
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
|
||||||
|
var secret string
|
||||||
|
if err := s.db.QueryRow(
|
||||||
|
`SELECT notification_secret FROM settings WHERE id = 1`,
|
||||||
|
).Scan(&secret); err != nil {
|
||||||
|
return "", fmt.Errorf("get settings notification secret: %w", err)
|
||||||
|
}
|
||||||
|
if secret != "" {
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
secret = generateWebhookSecret()
|
||||||
|
if err := s.SetSettingsNotificationSecret(secret); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
|
// SetSettingsNotificationSecret rewrites only the global outgoing-webhook
|
||||||
// signing secret on the singleton settings row. Pass an empty string to
|
// signing secret on the singleton settings row. Pass an empty string to
|
||||||
// disable signing globally (notifications still send, just without HMAC).
|
// disable signing globally (notifications still send, just without HMAC).
|
||||||
|
|||||||
@@ -1,398 +0,0 @@
|
|||||||
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. Stack row + matching
|
|
||||||
// workload row are written in a single transaction so a partial failure
|
|
||||||
// leaves no orphan.
|
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return Stack{}, fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`INSERT INTO stacks (`+stackCols+`)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
st.ID, st.Name, st.Description, st.ComposeProjectName, st.Status,
|
|
||||||
st.Error, st.CurrentRevisionID, st.CreatedAt, st.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return Stack{}, fmt.Errorf("insert stack: %w", err)
|
|
||||||
}
|
|
||||||
if err := SyncStackWorkloadTx(tx, st); err != nil {
|
|
||||||
return Stack{}, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return Stack{}, fmt.Errorf("commit: %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
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStackByComposeProjectName looks up a stack by its compose project name.
|
|
||||||
// Compose project names are unique per the stacks table schema, so this is an
|
|
||||||
// O(1) index lookup. Used by the reconciler to resolve compose-managed
|
|
||||||
// containers without scanning every stack.
|
|
||||||
func (s *Store) GetStackByComposeProjectName(name string) (Stack, error) {
|
|
||||||
if name == "" {
|
|
||||||
return Stack{}, ErrNotFound
|
|
||||||
}
|
|
||||||
st, err := scanStackRow(s.db.QueryRow(
|
|
||||||
`SELECT `+stackCols+` FROM stacks WHERE compose_project_name = ?`, name,
|
|
||||||
))
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Stack{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Stack{}, fmt.Errorf("query stack by compose project: %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).
|
|
||||||
// Atomic: stack row UPDATE and workload row sync share a transaction so the
|
|
||||||
// workload row's name never lags after a rename.
|
|
||||||
func (s *Store) UpdateStack(st Stack) error {
|
|
||||||
st.UpdatedAt = Now()
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
result, err := tx.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, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stack %s: %w", st.ID, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err := SyncStackWorkloadTx(tx, st); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
// Stack + workload + container index rows are dropped atomically.
|
|
||||||
func (s *Store) DeleteStack(id string) error {
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
var workloadID string
|
|
||||||
if err := tx.QueryRow(
|
|
||||||
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
|
|
||||||
string(WorkloadKindStack), id,
|
|
||||||
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return fmt.Errorf("lookup stack workload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := tx.Exec(`DELETE FROM stacks WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete stack: %w", err)
|
|
||||||
}
|
|
||||||
n, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stack %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if workloadID != "" {
|
|
||||||
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
|
|
||||||
return fmt.Errorf("delete stack containers: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
|
|
||||||
return fmt.Errorf("delete stack workload: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateStageEnv inserts a new stage environment variable override.
|
|
||||||
func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) {
|
|
||||||
env.ID = uuid.New().String()
|
|
||||||
env.CreatedAt = Now()
|
|
||||||
env.UpdatedAt = env.CreatedAt
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
env.ID, env.StageID, env.Key, env.Value, BoolToInt(env.Encrypted),
|
|
||||||
env.CreatedAt, env.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return StageEnv{}, fmt.Errorf("insert stage env: %w", err)
|
|
||||||
}
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStageEnvByStageID returns all environment variable overrides for a stage.
|
|
||||||
func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, stage_id, key, value, encrypted, created_at, updated_at
|
|
||||||
FROM stage_env WHERE stage_id = ? ORDER BY key`, stageID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query stage env: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
envs := []StageEnv{}
|
|
||||||
for rows.Next() {
|
|
||||||
env, err := scanStageEnv(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
envs = append(envs, env)
|
|
||||||
}
|
|
||||||
return envs, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStageEnvByID returns a single stage env override by ID.
|
|
||||||
func (s *Store) GetStageEnvByID(id string) (StageEnv, error) {
|
|
||||||
var env StageEnv
|
|
||||||
var encrypted int
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT id, stage_id, key, value, encrypted, created_at, updated_at
|
|
||||||
FROM stage_env WHERE id = ?`, id,
|
|
||||||
).Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted,
|
|
||||||
&env.CreatedAt, &env.UpdatedAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return StageEnv{}, fmt.Errorf("stage env %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return StageEnv{}, fmt.Errorf("query stage env: %w", err)
|
|
||||||
}
|
|
||||||
env.Encrypted = encrypted != 0
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStageEnv updates an existing stage environment variable override.
|
|
||||||
func (s *Store) UpdateStageEnv(env StageEnv) error {
|
|
||||||
env.UpdatedAt = Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
env.Key, env.Value, BoolToInt(env.Encrypted), env.UpdatedAt, env.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update stage env: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stage env %s: %w", env.ID, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteStageEnv removes a stage env override by ID.
|
|
||||||
func (s *Store) DeleteStageEnv(id string) error {
|
|
||||||
result, err := s.db.Exec(`DELETE FROM stage_env WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete stage env: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stage env %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanStageEnv scans a stage env row from a *sql.Rows cursor.
|
|
||||||
func scanStageEnv(rows *sql.Rows) (StageEnv, error) {
|
|
||||||
var env StageEnv
|
|
||||||
var encrypted int
|
|
||||||
err := rows.Scan(&env.ID, &env.StageID, &env.Key, &env.Value, &encrypted,
|
|
||||||
&env.CreatedAt, &env.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return StageEnv{}, fmt.Errorf("scan stage env: %w", err)
|
|
||||||
}
|
|
||||||
env.Encrypted = encrypted != 0
|
|
||||||
return env, nil
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
const stageColumns = `id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, enable_proxy, promote_from, subdomain, notification_url, notification_secret, cpu_limit, memory_limit, created_at, updated_at`
|
|
||||||
|
|
||||||
// CreateStage inserts a new stage for a project.
|
|
||||||
func (s *Store) CreateStage(st Stage) (Stage, error) {
|
|
||||||
st.ID = uuid.New().String()
|
|
||||||
st.CreatedAt = Now()
|
|
||||||
st.UpdatedAt = st.CreatedAt
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO stages (`+stageColumns+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
|
|
||||||
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
|
|
||||||
st.NotificationSecret,
|
|
||||||
st.CpuLimit, st.MemoryLimit, st.CreatedAt, st.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Stage{}, fmt.Errorf("insert stage: %w", err)
|
|
||||||
}
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStagesByProjectID returns all stages for a given project.
|
|
||||||
func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT `+stageColumns+` FROM stages WHERE project_id = ? ORDER BY name`, projectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query stages: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
stages := []Stage{}
|
|
||||||
for rows.Next() {
|
|
||||||
st, err := scanStage(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
stages = append(stages, st)
|
|
||||||
}
|
|
||||||
return stages, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStageByID returns a single stage by its ID.
|
|
||||||
func (s *Store) GetStageByID(id string) (Stage, error) {
|
|
||||||
var st Stage
|
|
||||||
var autoDeploy, confirm, enableProxy int
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT `+stageColumns+` FROM stages WHERE id = ?`, id,
|
|
||||||
).Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
|
|
||||||
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
|
|
||||||
&st.NotificationSecret,
|
|
||||||
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Stage{}, fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Stage{}, fmt.Errorf("query stage: %w", err)
|
|
||||||
}
|
|
||||||
st.AutoDeploy = autoDeploy != 0
|
|
||||||
st.Confirm = confirm != 0
|
|
||||||
st.EnableProxy = enableProxy != 0
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStage updates an existing stage's mutable fields.
|
|
||||||
func (s *Store) UpdateStage(st Stage) error {
|
|
||||||
st.UpdatedAt = Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, enable_proxy=?, promote_from=?, subdomain=?, notification_url=?, cpu_limit=?, memory_limit=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
|
|
||||||
BoolToInt(st.Confirm), BoolToInt(st.EnableProxy), st.PromoteFrom, st.Subdomain, st.NotificationURL,
|
|
||||||
st.CpuLimit, st.MemoryLimit, st.UpdatedAt, st.ID,
|
|
||||||
)
|
|
||||||
// notification_secret is intentionally not updated here — use the
|
|
||||||
// dedicated SetStageNotificationSecret rotation helper.
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update stage: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stage %s: %w", st.ID, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteStage removes a stage by ID. Cascading deletes handle child instances.
|
|
||||||
func (s *Store) DeleteStage(id string) error {
|
|
||||||
result, err := s.db.Exec(`DELETE FROM stages WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete stage: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStageNotificationSecret rotates the stage's outgoing-webhook signing
|
|
||||||
// secret. Empty string disables HMAC signing for this stage (notifications
|
|
||||||
// still send unsigned, falling through to project/global resolution).
|
|
||||||
func (s *Store) SetStageNotificationSecret(id, secret string) error {
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE stages SET notification_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("set stage notification secret: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("stage %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureStageNotificationSecret returns the stage's outgoing-webhook signing
|
|
||||||
// secret, generating one lazily if missing.
|
|
||||||
func (s *Store) EnsureStageNotificationSecret(id string) (string, error) {
|
|
||||||
stage, err := s.GetStageByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if stage.NotificationSecret != "" {
|
|
||||||
return stage.NotificationSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetStageNotificationSecret(id, secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BoolToInt converts a bool to an integer for SQLite storage.
|
|
||||||
func BoolToInt(b bool) int {
|
|
||||||
if b {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanStage scans a stage row from a *sql.Rows cursor.
|
|
||||||
func scanStage(rows *sql.Rows) (Stage, error) {
|
|
||||||
var st Stage
|
|
||||||
var autoDeploy, confirm, enableProxy int
|
|
||||||
err := rows.Scan(&st.ID, &st.ProjectID, &st.Name, &st.TagPattern, &autoDeploy, &st.MaxInstances,
|
|
||||||
&confirm, &enableProxy, &st.PromoteFrom, &st.Subdomain, &st.NotificationURL,
|
|
||||||
&st.NotificationSecret,
|
|
||||||
&st.CpuLimit, &st.MemoryLimit, &st.CreatedAt, &st.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return Stage{}, fmt.Errorf("scan stage: %w", err)
|
|
||||||
}
|
|
||||||
st.AutoDeploy = autoDeploy != 0
|
|
||||||
st.Confirm = confirm != 0
|
|
||||||
st.EnableProxy = enableProxy != 0
|
|
||||||
return st, nil
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateStaticSiteSecret inserts a new secret for a static site.
|
|
||||||
func (s *Store) CreateStaticSiteSecret(secret StaticSiteSecret) (StaticSiteSecret, error) {
|
|
||||||
secret.ID = uuid.New().String()
|
|
||||||
secret.CreatedAt = Now()
|
|
||||||
secret.UpdatedAt = secret.CreatedAt
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO static_site_secrets (id, site_id, key, value, encrypted, created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
secret.ID, secret.SiteID, secret.Key, secret.Value,
|
|
||||||
BoolToInt(secret.Encrypted), secret.CreatedAt, secret.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return StaticSiteSecret{}, fmt.Errorf("insert static site secret: %w", err)
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStaticSiteSecretsBySiteID returns all secrets for a static site.
|
|
||||||
func (s *Store) GetStaticSiteSecretsBySiteID(siteID string) ([]StaticSiteSecret, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
|
|
||||||
FROM static_site_secrets WHERE site_id = ? ORDER BY key`, siteID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query static site secrets: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
secrets := []StaticSiteSecret{}
|
|
||||||
for rows.Next() {
|
|
||||||
secret, err := scanStaticSiteSecret(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
secrets = append(secrets, secret)
|
|
||||||
}
|
|
||||||
return secrets, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStaticSiteSecretByID returns a single secret by ID.
|
|
||||||
func (s *Store) GetStaticSiteSecretByID(id string) (StaticSiteSecret, error) {
|
|
||||||
var secret StaticSiteSecret
|
|
||||||
var encrypted int
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT id, site_id, key, value, encrypted, created_at, updated_at
|
|
||||||
FROM static_site_secrets WHERE id = ?`, id,
|
|
||||||
).Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
|
|
||||||
&secret.CreatedAt, &secret.UpdatedAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return StaticSiteSecret{}, fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return StaticSiteSecret{}, fmt.Errorf("query static site secret: %w", err)
|
|
||||||
}
|
|
||||||
secret.Encrypted = encrypted != 0
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStaticSiteSecret updates an existing secret.
|
|
||||||
func (s *Store) UpdateStaticSiteSecret(secret StaticSiteSecret) error {
|
|
||||||
secret.UpdatedAt = Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE static_site_secrets SET key=?, value=?, encrypted=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
secret.Key, secret.Value, BoolToInt(secret.Encrypted), secret.UpdatedAt, secret.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update static site secret: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("static site secret %s: %w", secret.ID, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteStaticSiteSecret removes a secret by ID.
|
|
||||||
func (s *Store) DeleteStaticSiteSecret(id string) error {
|
|
||||||
result, err := s.db.Exec(`DELETE FROM static_site_secrets WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete static site secret: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("static site secret %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanStaticSiteSecret scans a secret row from a *sql.Rows cursor.
|
|
||||||
func scanStaticSiteSecret(rows *sql.Rows) (StaticSiteSecret, error) {
|
|
||||||
var secret StaticSiteSecret
|
|
||||||
var encrypted int
|
|
||||||
err := rows.Scan(&secret.ID, &secret.SiteID, &secret.Key, &secret.Value, &encrypted,
|
|
||||||
&secret.CreatedAt, &secret.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return StaticSiteSecret{}, fmt.Errorf("scan static site secret: %w", err)
|
|
||||||
}
|
|
||||||
secret.Encrypted = encrypted != 0
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
@@ -1,502 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// staticSiteCols is the column list for static_sites queries.
|
|
||||||
const staticSiteCols = `id, name, provider, gitea_url, repo_owner, repo_name, branch, folder_path,
|
|
||||||
access_token, domain, mode, render_markdown, sync_trigger, tag_pattern,
|
|
||||||
container_id, proxy_route_id, status, last_sync_at, last_commit_sha, error,
|
|
||||||
storage_enabled, storage_limit_mb,
|
|
||||||
webhook_secret, webhook_signing_secret, webhook_require_signature,
|
|
||||||
notification_url, notification_secret,
|
|
||||||
created_at, updated_at`
|
|
||||||
|
|
||||||
// UpsertStaticSiteWithID inserts or replaces a static site, keeping the
|
|
||||||
// caller-supplied ID. Used by the plugin static-source Backend adapter
|
|
||||||
// to keep a phantom row keyed on the workload ID so staticsite.Manager
|
|
||||||
// (which reads from this table) can serve plugin-native workloads
|
|
||||||
// without being refactored. Skips workload-row sync since the caller
|
|
||||||
// already owns the workload row.
|
|
||||||
func (s *Store) UpsertStaticSiteWithID(site StaticSite) error {
|
|
||||||
if site.ID == "" {
|
|
||||||
return fmt.Errorf("UpsertStaticSiteWithID: id is required")
|
|
||||||
}
|
|
||||||
if site.WebhookSecret == "" {
|
|
||||||
site.WebhookSecret = generateWebhookSecret()
|
|
||||||
}
|
|
||||||
if site.SyncTrigger == "" {
|
|
||||||
site.SyncTrigger = "manual"
|
|
||||||
}
|
|
||||||
if site.Mode == "" {
|
|
||||||
site.Mode = "static"
|
|
||||||
}
|
|
||||||
if site.Branch == "" {
|
|
||||||
site.Branch = "main"
|
|
||||||
}
|
|
||||||
if site.Status == "" {
|
|
||||||
site.Status = "idle"
|
|
||||||
}
|
|
||||||
now := Now()
|
|
||||||
site.UpdatedAt = now
|
|
||||||
if site.CreatedAt == "" {
|
|
||||||
site.CreatedAt = now
|
|
||||||
}
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT OR REPLACE INTO static_sites (`+staticSiteCols+`)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
|
||||||
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
|
||||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
|
||||||
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
|
||||||
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
|
||||||
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
|
|
||||||
site.NotificationURL, site.NotificationSecret,
|
|
||||||
site.CreatedAt, site.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("upsert static site: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateStaticSite inserts a new static site and returns it. A webhook secret
|
|
||||||
// is generated automatically if one is not already set on the input. Site row
|
|
||||||
// + matching workload row are written in a single transaction.
|
|
||||||
func (s *Store) CreateStaticSite(site StaticSite) (StaticSite, error) {
|
|
||||||
site.ID = uuid.New().String()
|
|
||||||
site.CreatedAt = Now()
|
|
||||||
site.UpdatedAt = site.CreatedAt
|
|
||||||
if site.WebhookSecret == "" {
|
|
||||||
site.WebhookSecret = generateWebhookSecret()
|
|
||||||
} else if len(site.WebhookSecret) < minWebhookSecretLength {
|
|
||||||
return StaticSite{}, fmt.Errorf("webhook_secret must be at least %d characters", minWebhookSecretLength)
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return StaticSite{}, fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
if _, err := tx.Exec(
|
|
||||||
`INSERT INTO static_sites (`+staticSiteCols+`)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
site.ID, site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName,
|
|
||||||
site.Branch, site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
|
||||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
|
||||||
site.ContainerID, site.ProxyRouteID, site.Status, site.LastSyncAt,
|
|
||||||
site.LastCommitSHA, site.Error, BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
|
||||||
site.WebhookSecret, site.WebhookSigningSecret, BoolToInt(site.WebhookRequireSignature),
|
|
||||||
site.NotificationURL, site.NotificationSecret,
|
|
||||||
site.CreatedAt, site.UpdatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return StaticSite{}, fmt.Errorf("insert static site: %w", err)
|
|
||||||
}
|
|
||||||
if err := SyncStaticSiteWorkloadTx(tx, site); err != nil {
|
|
||||||
return StaticSite{}, err
|
|
||||||
}
|
|
||||||
if err := tx.Commit(); err != nil {
|
|
||||||
return StaticSite{}, fmt.Errorf("commit: %w", err)
|
|
||||||
}
|
|
||||||
return site, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStaticSiteByID returns a single static site by its ID.
|
|
||||||
func (s *Store) GetStaticSiteByID(id string) (StaticSite, error) {
|
|
||||||
site, err := scanStaticSiteRow(s.db.QueryRow(
|
|
||||||
`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id,
|
|
||||||
))
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return StaticSite{}, fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return StaticSite{}, fmt.Errorf("query static site: %w", err)
|
|
||||||
}
|
|
||||||
return site, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAllStaticSites returns every static site ordered by name.
|
|
||||||
func (s *Store) GetAllStaticSites() ([]StaticSite, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT ` + staticSiteCols + ` FROM static_sites ORDER BY name`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query static sites: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
sites := []StaticSite{}
|
|
||||||
for rows.Next() {
|
|
||||||
site, err := scanStaticSiteRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sites = append(sites, site)
|
|
||||||
}
|
|
||||||
return sites, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStaticSitesByRepo returns all static sites for a given repo owner/name.
|
|
||||||
func (s *Store) GetStaticSitesByRepo(giteaURL, owner, name string) ([]StaticSite, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT `+staticSiteCols+`
|
|
||||||
FROM static_sites WHERE gitea_url = ? AND repo_owner = ? AND repo_name = ?
|
|
||||||
ORDER BY name`,
|
|
||||||
giteaURL, owner, name,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query static sites by repo: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
sites := []StaticSite{}
|
|
||||||
for rows.Next() {
|
|
||||||
site, err := scanStaticSiteRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
sites = append(sites, site)
|
|
||||||
}
|
|
||||||
return sites, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateStaticSiteAndSyncWorkloadTx wraps a parameterized UPDATE on
|
|
||||||
// static_sites with the workload sync, all inside a single transaction.
|
|
||||||
// updateSQL must end with `WHERE id=?`; args end with the site ID.
|
|
||||||
func (s *Store) updateStaticSiteAndSyncWorkloadTx(id string, updateSQL string, args ...any) error {
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
result, err := tx.Exec(updateSQL, args...)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update static site: %w", err)
|
|
||||||
}
|
|
||||||
n, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
row := tx.QueryRow(`SELECT `+staticSiteCols+` FROM static_sites WHERE id = ?`, id)
|
|
||||||
current, err := scanStaticSiteRowFromQuery(row)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reread static site for workload sync: %w", err)
|
|
||||||
}
|
|
||||||
if err := SyncStaticSiteWorkloadTx(tx, current); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanStaticSiteRowFromQuery is a thin wrapper around scanStaticSiteRow that
|
|
||||||
// accepts a *sql.Row from either s.db or a transaction. Kept private so the
|
|
||||||
// public surface stays narrow.
|
|
||||||
func scanStaticSiteRowFromQuery(row *sql.Row) (StaticSite, error) {
|
|
||||||
return scanStaticSiteRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStaticSite updates an existing static site's configuration fields.
|
|
||||||
// notification_secret is intentionally not updated here — use the dedicated
|
|
||||||
// SetStaticSiteNotificationSecret rotation helper.
|
|
||||||
func (s *Store) UpdateStaticSite(site StaticSite) error {
|
|
||||||
site.UpdatedAt = Now()
|
|
||||||
return s.updateStaticSiteAndSyncWorkloadTx(site.ID,
|
|
||||||
`UPDATE static_sites SET name=?, provider=?, gitea_url=?, repo_owner=?, repo_name=?, branch=?,
|
|
||||||
folder_path=?, access_token=?, domain=?, mode=?, render_markdown=?,
|
|
||||||
sync_trigger=?, tag_pattern=?, storage_enabled=?, storage_limit_mb=?,
|
|
||||||
notification_url=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
site.Name, site.Provider, site.GiteaURL, site.RepoOwner, site.RepoName, site.Branch,
|
|
||||||
site.FolderPath, site.AccessToken, site.Domain, site.Mode,
|
|
||||||
BoolToInt(site.RenderMarkdown), site.SyncTrigger, site.TagPattern,
|
|
||||||
BoolToInt(site.StorageEnabled), site.StorageLimitMB,
|
|
||||||
site.NotificationURL, site.UpdatedAt, site.ID,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStaticSiteStatus updates the deployment status fields.
|
|
||||||
func (s *Store) UpdateStaticSiteStatus(id, status, commitSHA, errMsg string) error {
|
|
||||||
now := Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE static_sites SET status=?, last_commit_sha=?, last_sync_at=?, error=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
status, commitSHA, now, errMsg, now, id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update static site status: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStaticSiteContainer updates the container and proxy route IDs after deployment.
|
|
||||||
func (s *Store) UpdateStaticSiteContainer(id, containerID, proxyRouteID string) error {
|
|
||||||
now := Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE static_sites SET container_id=?, proxy_route_id=?, updated_at=? WHERE id=?`,
|
|
||||||
containerID, proxyRouteID, now, id,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update static site container: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListStaticSiteProxyRoutes returns proxy routes backed by static sites,
|
|
||||||
// shaped to match the unified ProxyRoute model used by the Proxies page.
|
|
||||||
// Sites without an active proxy route are skipped.
|
|
||||||
func (s *Store) ListStaticSiteProxyRoutes(domain string) ([]ProxyRoute, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT id, name, mode, provider, domain, container_id, proxy_route_id, status, created_at
|
|
||||||
FROM static_sites
|
|
||||||
WHERE proxy_route_id != ''
|
|
||||||
ORDER BY name`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query static site proxy routes: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
suffix := ""
|
|
||||||
if domain != "" {
|
|
||||||
suffix = "." + strings.ToLower(domain)
|
|
||||||
}
|
|
||||||
|
|
||||||
routes := []ProxyRoute{}
|
|
||||||
for rows.Next() {
|
|
||||||
var r ProxyRoute
|
|
||||||
var mode, provider, fullDomain string
|
|
||||||
if err := rows.Scan(
|
|
||||||
&r.InstanceID, &r.ProjectName, &mode, &provider, &fullDomain,
|
|
||||||
&r.ContainerID, &r.ProxyRouteID, &r.Status, &r.CreatedAt,
|
|
||||||
); err != nil {
|
|
||||||
return nil, fmt.Errorf("scan static site proxy route: %w", err)
|
|
||||||
}
|
|
||||||
r.Source = "static_site"
|
|
||||||
r.StageName = mode
|
|
||||||
r.ImageTag = provider
|
|
||||||
r.Domain = fullDomain
|
|
||||||
if suffix != "" && strings.HasSuffix(strings.ToLower(fullDomain), suffix) {
|
|
||||||
r.Subdomain = fullDomain[:len(fullDomain)-len(suffix)]
|
|
||||||
} else {
|
|
||||||
r.Subdomain = fullDomain
|
|
||||||
}
|
|
||||||
routes = append(routes, r)
|
|
||||||
}
|
|
||||||
return routes, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteStaticSite removes a static site by ID. Cascading deletes handle
|
|
||||||
// secrets. Site + workload + container index rows are dropped atomically.
|
|
||||||
func (s *Store) DeleteStaticSite(id string) error {
|
|
||||||
tx, err := s.db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("begin: %w", err)
|
|
||||||
}
|
|
||||||
defer tx.Rollback()
|
|
||||||
|
|
||||||
var workloadID string
|
|
||||||
if err := tx.QueryRow(
|
|
||||||
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
|
|
||||||
string(WorkloadKindSite), id,
|
|
||||||
).Scan(&workloadID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return fmt.Errorf("lookup site workload: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := tx.Exec(`DELETE FROM static_sites WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete static site: %w", err)
|
|
||||||
}
|
|
||||||
n, err := result.RowsAffected()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("rows affected: %w", err)
|
|
||||||
}
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("static site %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
if workloadID != "" {
|
|
||||||
if _, err := tx.Exec(`DELETE FROM containers WHERE workload_id = ?`, workloadID); err != nil {
|
|
||||||
return fmt.Errorf("delete static site containers: %w", err)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(`DELETE FROM workloads WHERE id = ?`, workloadID); err != nil {
|
|
||||||
return fmt.Errorf("delete static site workload: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tx.Commit()
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanStaticSiteRow scans a static site from a *sql.Row.
|
|
||||||
func scanStaticSiteRow(row *sql.Row) (StaticSite, error) {
|
|
||||||
var site StaticSite
|
|
||||||
var renderMarkdown, storageEnabled, requireSig int
|
|
||||||
err := row.Scan(
|
|
||||||
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
|
||||||
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
|
||||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
|
||||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
|
||||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
|
||||||
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
|
|
||||||
&site.NotificationURL, &site.NotificationSecret,
|
|
||||||
&site.CreatedAt, &site.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return StaticSite{}, err
|
|
||||||
}
|
|
||||||
site.RenderMarkdown = renderMarkdown != 0
|
|
||||||
site.StorageEnabled = storageEnabled != 0
|
|
||||||
site.WebhookRequireSignature = requireSig != 0
|
|
||||||
return site, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanStaticSiteRows scans a static site from a *sql.Rows cursor.
|
|
||||||
func scanStaticSiteRows(rows *sql.Rows) (StaticSite, error) {
|
|
||||||
var site StaticSite
|
|
||||||
var renderMarkdown, storageEnabled, requireSig int
|
|
||||||
err := rows.Scan(
|
|
||||||
&site.ID, &site.Name, &site.Provider, &site.GiteaURL, &site.RepoOwner, &site.RepoName,
|
|
||||||
&site.Branch, &site.FolderPath, &site.AccessToken, &site.Domain, &site.Mode,
|
|
||||||
&renderMarkdown, &site.SyncTrigger, &site.TagPattern,
|
|
||||||
&site.ContainerID, &site.ProxyRouteID, &site.Status, &site.LastSyncAt,
|
|
||||||
&site.LastCommitSHA, &site.Error, &storageEnabled, &site.StorageLimitMB,
|
|
||||||
&site.WebhookSecret, &site.WebhookSigningSecret, &requireSig,
|
|
||||||
&site.NotificationURL, &site.NotificationSecret,
|
|
||||||
&site.CreatedAt, &site.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return StaticSite{}, fmt.Errorf("scan static site: %w", err)
|
|
||||||
}
|
|
||||||
site.RenderMarkdown = renderMarkdown != 0
|
|
||||||
site.StorageEnabled = storageEnabled != 0
|
|
||||||
site.WebhookRequireSignature = requireSig != 0
|
|
||||||
return site, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStaticSiteWebhookSigningSecret assigns the inbound HMAC signing secret.
|
|
||||||
// Pass an empty string to clear it (also implicitly disables enforcement).
|
|
||||||
func (s *Store) SetStaticSiteWebhookSigningSecret(id, secret string) error {
|
|
||||||
return s.updateStaticSiteAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE static_sites SET webhook_signing_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStaticSiteWebhookRequireSignature toggles whether unsigned (or
|
|
||||||
// invalidly-signed) inbound webhook requests are rejected with 401.
|
|
||||||
func (s *Store) SetStaticSiteWebhookRequireSignature(id string, require bool) error {
|
|
||||||
v := 0
|
|
||||||
if require {
|
|
||||||
v = 1
|
|
||||||
}
|
|
||||||
return s.updateStaticSiteAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE static_sites SET webhook_require_signature=?, updated_at=? WHERE id=?`,
|
|
||||||
v, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStaticSiteNotificationSecret rotates the static site's outgoing-webhook
|
|
||||||
// signing secret. Empty string disables HMAC signing for this site
|
|
||||||
// (notifications still send unsigned, falling through to global resolution).
|
|
||||||
func (s *Store) SetStaticSiteNotificationSecret(id, secret string) error {
|
|
||||||
return s.updateStaticSiteAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE static_sites SET notification_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureStaticSiteNotificationSecret returns the static site's outgoing-webhook
|
|
||||||
// signing secret, generating one lazily if missing.
|
|
||||||
func (s *Store) EnsureStaticSiteNotificationSecret(id string) (string, error) {
|
|
||||||
site, err := s.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if site.NotificationSecret != "" {
|
|
||||||
return site.NotificationSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetStaticSiteNotificationSecret(id, secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureSettingsNotificationSecret returns the global outgoing-webhook signing
|
|
||||||
// secret, generating one lazily if missing.
|
|
||||||
func (s *Store) EnsureSettingsNotificationSecret() (string, error) {
|
|
||||||
st, err := s.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if st.NotificationSecret != "" {
|
|
||||||
return st.NotificationSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetSettingsNotificationSecret(secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetStaticSiteByWebhookSecret looks up a static site by its webhook secret.
|
|
||||||
// Returns ErrNotFound if no site has this secret (including empty).
|
|
||||||
func (s *Store) GetStaticSiteByWebhookSecret(secret string) (StaticSite, error) {
|
|
||||||
if secret == "" {
|
|
||||||
return StaticSite{}, ErrNotFound
|
|
||||||
}
|
|
||||||
site, err := scanStaticSiteRow(s.db.QueryRow(
|
|
||||||
`SELECT `+staticSiteCols+` FROM static_sites WHERE webhook_secret = ?`, secret,
|
|
||||||
))
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return StaticSite{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return StaticSite{}, fmt.Errorf("query static site by webhook secret: %w", err)
|
|
||||||
}
|
|
||||||
return site, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetStaticSiteWebhookSecret assigns a webhook secret to a static site.
|
|
||||||
// Pass an empty string to disable webhook access for the site.
|
|
||||||
func (s *Store) SetStaticSiteWebhookSecret(id, secret string) error {
|
|
||||||
return s.updateStaticSiteAndSyncWorkloadTx(id,
|
|
||||||
`UPDATE static_sites SET webhook_secret=?, updated_at=? WHERE id=?`,
|
|
||||||
secret, Now(), id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureStaticSiteWebhookSecret returns the current webhook secret for a site,
|
|
||||||
// generating one on the fly if the stored value is empty (lazy backfill).
|
|
||||||
func (s *Store) EnsureStaticSiteWebhookSecret(id string) (string, error) {
|
|
||||||
site, err := s.GetStaticSiteByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if site.WebhookSecret != "" {
|
|
||||||
return site.WebhookSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetStaticSiteWebhookSecret(id, secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
+39
-269
@@ -97,97 +97,44 @@ func (s *Store) migrate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runMigrations applies additive schema changes that cannot be expressed
|
// runMigrations applies additive schema changes that cannot be expressed
|
||||||
// with CREATE TABLE IF NOT EXISTS.
|
// with CREATE TABLE IF NOT EXISTS, plus the hard-cutover drops that
|
||||||
|
// remove every legacy project/stage/stack/static_site/deploy table.
|
||||||
func (s *Store) runMigrations() error {
|
func (s *Store) runMigrations() error {
|
||||||
migrations := []string{
|
migrations := []string{
|
||||||
// Add owner column to registries (2026-03-28).
|
// Set default network for existing databases with empty network.
|
||||||
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
|
||||||
// Add base_volume_path to settings (2026-03-28).
|
// Settings column adds that survive the cutover. SQLite is tolerant
|
||||||
|
// of "duplicate column" errors at the apply step, so re-running on
|
||||||
|
// a fully-migrated DB is a no-op.
|
||||||
`ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN base_volume_path TEXT NOT NULL DEFAULT ''`,
|
||||||
// Add enable_proxy to stages (2026-03-29). Default true for backwards compat.
|
|
||||||
`ALTER TABLE stages ADD COLUMN enable_proxy INTEGER NOT NULL DEFAULT 1`,
|
|
||||||
// Add ssl_certificate_id to settings (2026-03-29).
|
|
||||||
`ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE settings ADD COLUMN ssl_certificate_id INTEGER NOT NULL DEFAULT 0`,
|
||||||
// Add stale_threshold_days to settings (2026-03-30).
|
|
||||||
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`,
|
`ALTER TABLE settings ADD COLUMN stale_threshold_days INTEGER NOT NULL DEFAULT 7`,
|
||||||
// Add last_alive_at to instances for stale container detection (2026-03-30).
|
|
||||||
`ALTER TABLE instances ADD COLUMN last_alive_at TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Add name column and rename mode→scope for volume scopes redesign (2026-03-31).
|
|
||||||
`ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01).
|
|
||||||
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
|
`ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`,
|
||||||
// Add DNS management fields to settings (2026-04-02).
|
|
||||||
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
|
`ALTER TABLE settings ADD COLUMN wildcard_dns INTEGER NOT NULL DEFAULT 1`,
|
||||||
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN dns_provider TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN cloudflare_api_token TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN cloudflare_zone_id TEXT NOT NULL DEFAULT ''`,
|
||||||
// Add backup management fields to settings (2026-04-02).
|
|
||||||
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE settings ADD COLUMN backup_enabled INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
|
`ALTER TABLE settings ADD COLUMN backup_interval_hours INTEGER NOT NULL DEFAULT 24`,
|
||||||
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
|
`ALTER TABLE settings ADD COLUMN backup_retention_count INTEGER NOT NULL DEFAULT 10`,
|
||||||
`ALTER TABLE stages ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Add proxy_route_id to instances for provider-agnostic route tracking (2026-04-04).
|
|
||||||
`ALTER TABLE instances ADD COLUMN proxy_route_id TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Add proxy_provider to settings (2026-04-04). Default to npm for backward compat.
|
|
||||||
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
|
`ALTER TABLE settings ADD COLUMN proxy_provider TEXT NOT NULL DEFAULT 'npm'`,
|
||||||
// Add Traefik provider settings (2026-04-04).
|
|
||||||
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
|
`ALTER TABLE settings ADD COLUMN traefik_entrypoint TEXT NOT NULL DEFAULT 'websecure'`,
|
||||||
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
|
`ALTER TABLE settings ADD COLUMN traefik_cert_resolver TEXT NOT NULL DEFAULT 'letsencrypt'`,
|
||||||
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN traefik_network TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN traefik_api_url TEXT NOT NULL DEFAULT ''`,
|
||||||
// Set default network for existing databases with empty network.
|
|
||||||
`UPDATE settings SET network = 'tinyforge' WHERE network = ''`,
|
|
||||||
// NPM remote mode: forward to server_ip instead of container name.
|
|
||||||
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE settings ADD COLUMN npm_remote INTEGER NOT NULL DEFAULT 0`,
|
||||||
// Resource limits per stage.
|
|
||||||
`ALTER TABLE stages ADD COLUMN cpu_limit REAL NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE stages ADD COLUMN memory_limit INTEGER NOT NULL DEFAULT 0`,
|
|
||||||
// NPM access list support (global default + per-project override).
|
|
||||||
`ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE settings ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE projects ADD COLUMN npm_access_list_id INTEGER NOT NULL DEFAULT 0`,
|
|
||||||
// Separate public IP for DNS A records.
|
|
||||||
`ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN public_ip TEXT NOT NULL DEFAULT ''`,
|
||||||
// Image prune threshold (MB). Warn on dashboard when exceeded. 0 = disabled.
|
|
||||||
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
|
`ALTER TABLE settings ADD COLUMN image_prune_threshold_mb INTEGER NOT NULL DEFAULT 1024`,
|
||||||
// Add provider column to static_sites (2026-04-11).
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN provider TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Add persistent storage columns to static_sites (2026-04-12).
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN storage_enabled INTEGER NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN storage_limit_mb INTEGER NOT NULL DEFAULT 0`,
|
|
||||||
// Per-project + per-site webhook secrets (2026-04-23). Global
|
|
||||||
// settings.webhook_secret is deprecated; its column is retained to
|
|
||||||
// avoid a destructive migration on SQLite.
|
|
||||||
`ALTER TABLE projects ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN webhook_secret TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Resource metrics collection (2026-04-24). Interval in seconds,
|
|
||||||
// retention in hours. 0 in either disables collection.
|
|
||||||
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
|
`ALTER TABLE settings ADD COLUMN stats_interval_seconds INTEGER NOT NULL DEFAULT 15`,
|
||||||
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
|
`ALTER TABLE settings ADD COLUMN stats_retention_hours INTEGER NOT NULL DEFAULT 2`,
|
||||||
// Outgoing-webhook signing secrets per tier (2026-05-07). Plain hex
|
|
||||||
// tokens (matches the inbound webhook_secret pattern). Empty = no
|
|
||||||
// signing; existing rows stay unsigned on upgrade for back-compat.
|
|
||||||
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE settings ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE projects ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE projects ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE stages ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN notification_url TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN notification_secret TEXT NOT NULL DEFAULT ''`,
|
|
||||||
// Auto-backup before deploy (2026-05-07). When enabled, the deployer
|
|
||||||
// triggers a "pre-deploy" Tinyforge DB backup before any project deploy
|
|
||||||
// so a corrupted deploy is recoverable without data loss.
|
|
||||||
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
|
`ALTER TABLE settings ADD COLUMN auto_backup_before_deploy INTEGER NOT NULL DEFAULT 0`,
|
||||||
// Per-entity inbound HMAC signing (2026-05-07). webhook_signing_secret
|
// Registries — owner column.
|
||||||
// is the HMAC-SHA256 key separate from the URL secret so a leaked URL
|
`ALTER TABLE registries ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||||
// alone is not sufficient to forge a valid request. require_signature
|
// Webhook delivery audit log persists every inbound webhook
|
||||||
// rejects unsigned requests when set (defense-in-depth opt-in).
|
// request so operators can debug "why didn't my deploy fire?"
|
||||||
`ALTER TABLE projects ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
|
// without grepping daemon logs.
|
||||||
`ALTER TABLE projects ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN webhook_signing_secret TEXT NOT NULL DEFAULT ''`,
|
|
||||||
`ALTER TABLE static_sites ADD COLUMN webhook_require_signature INTEGER NOT NULL DEFAULT 0`,
|
|
||||||
// Webhook delivery audit log (2026-05-07). Persists every inbound
|
|
||||||
// webhook request (project or site) with its outcome so users can
|
|
||||||
// debug "why didn't my deploy fire?" without grepping daemon logs.
|
|
||||||
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
`CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
target_type TEXT NOT NULL,
|
target_type TEXT NOT NULL,
|
||||||
@@ -203,19 +150,36 @@ func (s *Store) runMigrations() error {
|
|||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_target ON webhook_deliveries(target_type, target_id, received_at)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
|
`CREATE INDEX IF NOT EXISTS idx_webhook_deliveries_received_at ON webhook_deliveries(received_at)`,
|
||||||
// Add stage_id to containers (2026-05-09). Backfill via the deployer
|
// Containers — stage_id is now an opaque string set by the source
|
||||||
// re-write path; the LEFT JOIN in ListContainersByStageID falls back
|
// plugin (image plugin uses it for the deploy-target tag). No FK
|
||||||
// to (project_id, role=stage_name) so legacy rows still resolve.
|
// semantics: the legacy `stages` table this column once joined to
|
||||||
|
// is gone; the column is just a free-form discriminator the
|
||||||
|
// proxies / dashboard views read to disambiguate sibling rows.
|
||||||
`ALTER TABLE containers ADD COLUMN stage_id TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE containers ADD COLUMN stage_id TEXT NOT NULL DEFAULT ''`,
|
||||||
// Workload-first refactor columns (2026-05-10). Land additively so
|
// Workload-first refactor columns. Land additively so old databases
|
||||||
// the legacy kind/ref_id columns continue to serve existing
|
// (which have a bare workloads table) pick them up on the next boot.
|
||||||
// project/stack/site rows during cutover.
|
|
||||||
`ALTER TABLE workloads ADD COLUMN source_kind TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE workloads ADD COLUMN source_kind TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE workloads ADD COLUMN source_config TEXT NOT NULL DEFAULT '{}'`,
|
`ALTER TABLE workloads ADD COLUMN source_config TEXT NOT NULL DEFAULT '{}'`,
|
||||||
`ALTER TABLE workloads ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE workloads ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT ''`,
|
||||||
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
|
`ALTER TABLE workloads ADD COLUMN trigger_config TEXT NOT NULL DEFAULT '{}'`,
|
||||||
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
|
`ALTER TABLE workloads ADD COLUMN public_faces TEXT NOT NULL DEFAULT '[]'`,
|
||||||
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
|
`ALTER TABLE workloads ADD COLUMN parent_workload_id TEXT NOT NULL DEFAULT ''`,
|
||||||
|
// Hard cutover: drop every legacy table. Idempotent — DROP TABLE
|
||||||
|
// IF EXISTS is a no-op once the table is gone. Operators upgrading
|
||||||
|
// from a pre-cutover build will lose any project / stack / static
|
||||||
|
// site rows; the upgrade notes call this out explicitly.
|
||||||
|
`DROP TABLE IF EXISTS deploy_logs`,
|
||||||
|
`DROP TABLE IF EXISTS deploys`,
|
||||||
|
`DROP TABLE IF EXISTS stage_env`,
|
||||||
|
`DROP TABLE IF EXISTS stages`,
|
||||||
|
`DROP TABLE IF EXISTS poll_states`,
|
||||||
|
`DROP TABLE IF EXISTS volumes`,
|
||||||
|
`DROP TABLE IF EXISTS static_site_secrets`,
|
||||||
|
`DROP TABLE IF EXISTS static_sites`,
|
||||||
|
`DROP TABLE IF EXISTS stack_deploys`,
|
||||||
|
`DROP TABLE IF EXISTS stack_revisions`,
|
||||||
|
`DROP TABLE IF EXISTS stacks`,
|
||||||
|
`DROP TABLE IF EXISTS projects`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workload refactor tables (2026-05-09). Workload is the unifying primitive
|
// Workload refactor tables (2026-05-09). Workload is the unifying primitive
|
||||||
@@ -369,46 +333,6 @@ func (s *Store) runMigrations() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Observability: event_triggers — consume EventLog entries off the
|
// Observability: event_triggers — consume EventLog entries off the
|
||||||
// bus and dispatch webhook actions. Schema kept flat (comma-list
|
// bus and dispatch webhook actions. Schema kept flat (comma-list
|
||||||
// filters, single optional regex) — see LOGSCAN_AND_TRIGGERS_TODO.md.
|
// filters, single optional regex) — see LOGSCAN_AND_TRIGGERS_TODO.md.
|
||||||
@@ -469,34 +393,18 @@ func (s *Store) runMigrations() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create indexes on foreign key columns for query performance.
|
// Create indexes on foreign key columns for query performance. Only
|
||||||
|
// indexes targeting tables that still exist after the hard cutover.
|
||||||
indexes := []string{
|
indexes := []string{
|
||||||
// instances table dropped 2026-05-09 (workload refactor) — no indexes
|
|
||||||
// needed; containers replaces it with idx_containers_workload below.
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_deploys_project_id ON deploys(project_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_deploys_stage_id ON deploys(stage_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy_id ON deploy_logs(deploy_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stages_project_id ON stages(project_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_stage_env_stage_id ON stage_env(stage_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_volumes_project_id ON volumes(project_id)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
|
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
|
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
|
`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_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)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_webhook_secret ON projects(webhook_secret) WHERE webhook_secret != ''`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_static_sites_webhook_secret ON static_sites(webhook_secret) WHERE webhook_secret != ''`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
|
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
|
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`,
|
`CREATE INDEX IF NOT EXISTS idx_container_stats_ts ON container_stats_samples(ts)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`,
|
`CREATE INDEX IF NOT EXISTS idx_system_stats_ts ON system_stats_samples(ts)`,
|
||||||
// Drop the legacy instances table — containers is the canonical index
|
// Workload refactor indexes.
|
||||||
// after the workload refactor (2026-05-09). Idempotent: SQLite's
|
|
||||||
// DROP TABLE IF EXISTS is a no-op on databases that already shed it.
|
|
||||||
`DROP TABLE IF EXISTS instances`,
|
|
||||||
// Workload refactor indexes (2026-05-09).
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`,
|
`CREATE INDEX IF NOT EXISTS idx_workloads_kind ON workloads(kind)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`,
|
`CREATE INDEX IF NOT EXISTS idx_workloads_app_id ON workloads(app_id) WHERE app_id != ''`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_workloads_ref ON workloads(kind, ref_id)`,
|
||||||
@@ -508,7 +416,7 @@ func (s *Store) runMigrations() error {
|
|||||||
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
|
`CREATE INDEX IF NOT EXISTS idx_containers_stage_id ON containers(stage_id) WHERE stage_id != ''`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_workload_env_workload ON workload_env(workload_id)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_workload_volumes_workload ON workload_volumes(workload_id)`,
|
||||||
// Trigger-split indexes (2026-05-16).
|
// Trigger-split indexes.
|
||||||
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
|
`CREATE INDEX IF NOT EXISTS idx_triggers_kind ON triggers(kind)`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
|
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
|
||||||
@@ -520,19 +428,6 @@ func (s *Store) runMigrations() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Data migration: copy mode→scope for volumes that have scope still empty.
|
|
||||||
// shared→project, isolated→instance. Log errors but don't fail startup.
|
|
||||||
dataMigrations := []struct{ query, desc string }{
|
|
||||||
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = 'shared'`, "migrate shared→project"},
|
|
||||||
{`UPDATE volumes SET scope = 'instance' WHERE scope = '' AND mode = 'isolated'`, "migrate isolated→instance"},
|
|
||||||
{`UPDATE volumes SET scope = 'project' WHERE scope = '' AND mode = ''`, "migrate empty→project"},
|
|
||||||
}
|
|
||||||
for _, dm := range dataMigrations {
|
|
||||||
if _, err := s.db.Exec(dm.query); err != nil {
|
|
||||||
fmt.Printf("volume scope migration warning (%s): %v\n", dm.desc, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.backfillTriggersFromWorkloads(); err != nil {
|
if err := s.backfillTriggersFromWorkloads(); err != nil {
|
||||||
slog.Warn("trigger backfill", "error", err)
|
slog.Warn("trigger backfill", "error", err)
|
||||||
}
|
}
|
||||||
@@ -658,42 +553,6 @@ func (s *Store) backfillOneTrigger(workloadID, workloadName, kind, config,
|
|||||||
}
|
}
|
||||||
|
|
||||||
const schema = `
|
const schema = `
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
registry TEXT NOT NULL DEFAULT '',
|
|
||||||
image TEXT NOT NULL,
|
|
||||||
port INTEGER NOT NULL DEFAULT 0,
|
|
||||||
healthcheck TEXT NOT NULL DEFAULT '',
|
|
||||||
env TEXT NOT NULL DEFAULT '{}',
|
|
||||||
volumes TEXT NOT NULL DEFAULT '{}',
|
|
||||||
npm_access_list_id INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notification_url TEXT NOT NULL DEFAULT '',
|
|
||||||
notification_secret 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 stages (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
tag_pattern TEXT NOT NULL DEFAULT '*',
|
|
||||||
auto_deploy INTEGER NOT NULL DEFAULT 0,
|
|
||||||
max_instances INTEGER NOT NULL DEFAULT 1,
|
|
||||||
confirm INTEGER NOT NULL DEFAULT 0,
|
|
||||||
enable_proxy INTEGER NOT NULL DEFAULT 1,
|
|
||||||
promote_from TEXT NOT NULL DEFAULT '',
|
|
||||||
subdomain TEXT NOT NULL DEFAULT '',
|
|
||||||
notification_url TEXT NOT NULL DEFAULT '',
|
|
||||||
notification_secret TEXT NOT NULL DEFAULT '',
|
|
||||||
cpu_limit REAL NOT NULL DEFAULT 0,
|
|
||||||
memory_limit INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(project_id, name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS registries (
|
CREATE TABLE IF NOT EXISTS registries (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL UNIQUE,
|
name TEXT NOT NULL UNIQUE,
|
||||||
@@ -730,36 +589,6 @@ CREATE TABLE IF NOT EXISTS settings (
|
|||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
-- The instances table was removed in the workload refactor (2026-05-09).
|
|
||||||
-- Container state lives in the containers table; see runMigrations for the
|
|
||||||
-- current schema. The DROP TABLE migration runs unconditionally on boot.
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS deploys (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
||||||
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
|
|
||||||
instance_id TEXT NOT NULL DEFAULT '',
|
|
||||||
image_tag TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
finished_at TEXT NOT NULL DEFAULT '',
|
|
||||||
error TEXT NOT NULL DEFAULT ''
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS deploy_logs (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
deploy_id TEXT NOT NULL REFERENCES deploys(id) ON DELETE CASCADE,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
level TEXT NOT NULL DEFAULT 'info',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS poll_states (
|
|
||||||
stage_id TEXT PRIMARY KEY REFERENCES stages(id) ON DELETE CASCADE,
|
|
||||||
last_tag TEXT NOT NULL DEFAULT '',
|
|
||||||
last_polled TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
@@ -785,27 +614,6 @@ INSERT OR IGNORE INTO settings (id) VALUES (1);
|
|||||||
-- Seed the auth_settings row if it does not exist.
|
-- Seed the auth_settings row if it does not exist.
|
||||||
INSERT OR IGNORE INTO auth_settings (id) VALUES (1);
|
INSERT OR IGNORE INTO auth_settings (id) VALUES (1);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stage_env (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
stage_id TEXT NOT NULL REFERENCES stages(id) ON DELETE CASCADE,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL DEFAULT '',
|
|
||||||
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(stage_id, key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS volumes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
|
||||||
source TEXT NOT NULL,
|
|
||||||
target TEXT NOT NULL,
|
|
||||||
mode TEXT NOT NULL DEFAULT 'shared',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS event_log (
|
CREATE TABLE IF NOT EXISTS event_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
source TEXT NOT NULL DEFAULT '',
|
source TEXT NOT NULL DEFAULT '',
|
||||||
@@ -845,44 +653,6 @@ CREATE TABLE IF NOT EXISTS backups (
|
|||||||
backup_type TEXT NOT NULL DEFAULT 'manual',
|
backup_type TEXT NOT NULL DEFAULT 'manual',
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS static_sites (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
name TEXT NOT NULL UNIQUE,
|
|
||||||
provider TEXT NOT NULL DEFAULT '',
|
|
||||||
gitea_url TEXT NOT NULL DEFAULT '',
|
|
||||||
repo_owner TEXT NOT NULL DEFAULT '',
|
|
||||||
repo_name TEXT NOT NULL DEFAULT '',
|
|
||||||
branch TEXT NOT NULL DEFAULT 'main',
|
|
||||||
folder_path TEXT NOT NULL DEFAULT '',
|
|
||||||
access_token TEXT NOT NULL DEFAULT '',
|
|
||||||
domain TEXT NOT NULL DEFAULT '',
|
|
||||||
mode TEXT NOT NULL DEFAULT 'static',
|
|
||||||
render_markdown INTEGER NOT NULL DEFAULT 0,
|
|
||||||
sync_trigger TEXT NOT NULL DEFAULT 'manual',
|
|
||||||
tag_pattern TEXT NOT NULL DEFAULT '',
|
|
||||||
container_id TEXT NOT NULL DEFAULT '',
|
|
||||||
proxy_route_id TEXT NOT NULL DEFAULT '',
|
|
||||||
status TEXT NOT NULL DEFAULT 'idle',
|
|
||||||
last_sync_at TEXT NOT NULL DEFAULT '',
|
|
||||||
last_commit_sha TEXT NOT NULL DEFAULT '',
|
|
||||||
error TEXT NOT NULL DEFAULT '',
|
|
||||||
notification_url TEXT NOT NULL DEFAULT '',
|
|
||||||
notification_secret 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 static_site_secrets (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
site_id TEXT NOT NULL REFERENCES static_sites(id) ON DELETE CASCADE,
|
|
||||||
key TEXT NOT NULL,
|
|
||||||
value TEXT NOT NULL DEFAULT '',
|
|
||||||
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(site_id, key)
|
|
||||||
);
|
|
||||||
`
|
`
|
||||||
|
|
||||||
// Now returns the current time formatted for SQLite storage.
|
// Now returns the current time formatted for SQLite storage.
|
||||||
|
|||||||
@@ -14,62 +14,6 @@ func newTestStore(t *testing.T) *Store {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateAndGetProject(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, err := s.CreateProject(Project{
|
|
||||||
Name: "test-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProject: %v", err)
|
|
||||||
}
|
|
||||||
if p.ID == "" {
|
|
||||||
t.Fatal("project ID should be set")
|
|
||||||
}
|
|
||||||
|
|
||||||
got, err := s.GetProjectByID(p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetProjectByID: %v", err)
|
|
||||||
}
|
|
||||||
if got.Name != "test-project" {
|
|
||||||
t.Fatalf("got name %q, want %q", got.Name, "test-project")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetAllProjects(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
s.CreateProject(Project{Name: "bravo", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
s.CreateProject(Project{Name: "alpha", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
|
|
||||||
projects, err := s.GetAllProjects()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetAllProjects: %v", err)
|
|
||||||
}
|
|
||||||
if len(projects) != 2 {
|
|
||||||
t.Fatalf("expected 2 projects, got %d", len(projects))
|
|
||||||
}
|
|
||||||
// Should be ordered by name
|
|
||||||
if projects[0].Name != "alpha" {
|
|
||||||
t.Fatalf("expected first project 'alpha', got %q", projects[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteProject(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "del-me", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
err := s.DeleteProject(p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DeleteProject: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = s.GetProjectByID(p.ID)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error getting deleted project")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateAndGetUser(t *testing.T) {
|
func TestCreateAndGetUser(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|
||||||
@@ -110,80 +54,6 @@ func TestUserCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateStageAndDeploy(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
stage, err := s.CreateStage(Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", MaxInstances: 2,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateStage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
d, err := s.CreateDeploy(Deploy{
|
|
||||||
ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1.0",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateDeploy: %v", err)
|
|
||||||
}
|
|
||||||
if d.Status != "pending" {
|
|
||||||
t.Fatalf("expected pending status, got %q", d.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = s.UpdateDeployStatus(d.ID, "success", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpdateDeployStatus: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ := s.GetDeployByID(d.ID)
|
|
||||||
if got.Status != "success" {
|
|
||||||
t.Fatalf("expected success, got %q", got.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetDeploysByProjectID(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
|
|
||||||
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
_, err := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateDeploy %d: %v", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deploys, err := s.GetDeploysByProjectID(p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetDeploysByProjectID: %v", err)
|
|
||||||
}
|
|
||||||
if len(deploys) != 5 {
|
|
||||||
t.Fatalf("expected 5 deploys, got %d", len(deploys))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetRecentDeploys(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
|
|
||||||
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v" + string(rune('0'+i))})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limit to 2
|
|
||||||
deploys, err := s.GetRecentDeploys(2)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetRecentDeploys: %v", err)
|
|
||||||
}
|
|
||||||
if len(deploys) != 2 {
|
|
||||||
t.Fatalf("expected 2 deploys with limit, got %d", len(deploys))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteUser(t *testing.T) {
|
func TestDeleteUser(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|
||||||
@@ -199,27 +69,6 @@ func TestDeleteUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateProject(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "original", Image: "nginx", Env: "{}", Volumes: "{}"})
|
|
||||||
|
|
||||||
p.Name = "updated"
|
|
||||||
p.Image = "alpine"
|
|
||||||
err := s.UpdateProject(p)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("UpdateProject: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
got, _ := s.GetProjectByID(p.ID)
|
|
||||||
if got.Name != "updated" {
|
|
||||||
t.Fatalf("expected name 'updated', got %q", got.Name)
|
|
||||||
}
|
|
||||||
if got.Image != "alpine" {
|
|
||||||
t.Fatalf("expected image 'alpine', got %q", got.Image)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateUser(t *testing.T) {
|
func TestUpdateUser(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|
||||||
@@ -241,88 +90,3 @@ func TestUpdateUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDeployLogs(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
|
|
||||||
d, _ := s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"})
|
|
||||||
|
|
||||||
err := s.AppendDeployLog(d.ID, "pulling image", "info")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AppendDeployLog: %v", err)
|
|
||||||
}
|
|
||||||
err = s.AppendDeployLog(d.ID, "something failed", "error")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("AppendDeployLog: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logs, err := s.GetDeployLogs(d.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetDeployLogs: %v", err)
|
|
||||||
}
|
|
||||||
if len(logs) != 2 {
|
|
||||||
t.Fatalf("expected 2 logs, got %d", len(logs))
|
|
||||||
}
|
|
||||||
if logs[0].Message != "pulling image" {
|
|
||||||
t.Fatalf("expected first log 'pulling image', got %q", logs[0].Message)
|
|
||||||
}
|
|
||||||
if logs[1].Level != "error" {
|
|
||||||
t.Fatalf("expected second log level 'error', got %q", logs[1].Level)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestGetStagesByProjectID(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
s.CreateStage(Stage{ProjectID: p.ID, Name: "prod", TagPattern: "v*"})
|
|
||||||
s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
|
|
||||||
|
|
||||||
stages, err := s.GetStagesByProjectID(p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetStagesByProjectID: %v", err)
|
|
||||||
}
|
|
||||||
if len(stages) != 2 {
|
|
||||||
t.Fatalf("expected 2 stages, got %d", len(stages))
|
|
||||||
}
|
|
||||||
// Ordered by name
|
|
||||||
if stages[0].Name != "dev" {
|
|
||||||
t.Fatalf("expected first stage 'dev', got %q", stages[0].Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsTerminalDeployStatus(t *testing.T) {
|
|
||||||
terminals := []string{"success", "failed", "rolled_back"}
|
|
||||||
for _, s := range terminals {
|
|
||||||
if !IsTerminalDeployStatus(s) {
|
|
||||||
t.Fatalf("expected %q to be terminal", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nonTerminals := []string{"pending", "pulling", "starting", "configuring_proxy", "health_checking"}
|
|
||||||
for _, s := range nonTerminals {
|
|
||||||
if IsTerminalDeployStatus(s) {
|
|
||||||
t.Fatalf("expected %q to be non-terminal", s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCascadeDeleteProjectRemovesStagesAndDeploys(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "proj", Image: "img", Env: "{}", Volumes: "{}"})
|
|
||||||
stage, _ := s.CreateStage(Stage{ProjectID: p.ID, Name: "dev", TagPattern: "*"})
|
|
||||||
s.CreateDeploy(Deploy{ProjectID: p.ID, StageID: stage.ID, ImageTag: "v1"})
|
|
||||||
|
|
||||||
err := s.DeleteProject(p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("DeleteProject: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage should be gone
|
|
||||||
_, err = s.GetStageByID(stage.ID)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected stage to be deleted by cascade")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// volumeColumns is the canonical column list for volume queries.
|
|
||||||
const volumeColumns = `id, project_id, source, target, mode, scope, name, created_at, updated_at`
|
|
||||||
|
|
||||||
// CreateVolume inserts a new volume configuration for a project.
|
|
||||||
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
|
|
||||||
vol.ID = uuid.New().String()
|
|
||||||
vol.CreatedAt = Now()
|
|
||||||
vol.UpdatedAt = vol.CreatedAt
|
|
||||||
|
|
||||||
// Default scope for backward compatibility.
|
|
||||||
if vol.Scope == "" {
|
|
||||||
switch vol.Mode {
|
|
||||||
case "isolated":
|
|
||||||
vol.Scope = "instance"
|
|
||||||
default:
|
|
||||||
vol.Scope = "project"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
|
||||||
`INSERT INTO volumes (`+volumeColumns+`)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
vol.ID, vol.ProjectID, vol.Source, vol.Target, vol.Mode,
|
|
||||||
vol.Scope, vol.Name, vol.CreatedAt, vol.UpdatedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return Volume{}, fmt.Errorf("insert volume: %w", err)
|
|
||||||
}
|
|
||||||
return vol, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVolumesByProjectID returns all volume configurations for a project.
|
|
||||||
func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
|
|
||||||
rows, err := s.db.Query(
|
|
||||||
`SELECT `+volumeColumns+` FROM volumes WHERE project_id = ? ORDER BY target`, projectID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("query volumes: %w", err)
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
|
|
||||||
vols := []Volume{}
|
|
||||||
for rows.Next() {
|
|
||||||
vol, err := scanVolume(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
vols = append(vols, vol)
|
|
||||||
}
|
|
||||||
return vols, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetVolumeByID returns a single volume by its ID.
|
|
||||||
func (s *Store) GetVolumeByID(id string) (Volume, error) {
|
|
||||||
var vol Volume
|
|
||||||
err := s.db.QueryRow(
|
|
||||||
`SELECT `+volumeColumns+` FROM volumes WHERE id = ?`, id,
|
|
||||||
).Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
|
|
||||||
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Volume{}, fmt.Errorf("volume %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Volume{}, fmt.Errorf("query volume: %w", err)
|
|
||||||
}
|
|
||||||
return vol, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateVolume updates an existing volume configuration.
|
|
||||||
func (s *Store) UpdateVolume(vol Volume) error {
|
|
||||||
vol.UpdatedAt = Now()
|
|
||||||
result, err := s.db.Exec(
|
|
||||||
`UPDATE volumes SET source=?, target=?, mode=?, scope=?, name=?, updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
vol.Source, vol.Target, vol.Mode, vol.Scope, vol.Name, vol.UpdatedAt, vol.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update volume: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("volume %s: %w", vol.ID, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteVolume removes a volume configuration by ID.
|
|
||||||
func (s *Store) DeleteVolume(id string) error {
|
|
||||||
result, err := s.db.Exec(`DELETE FROM volumes WHERE id = ?`, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("delete volume: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("volume %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// scanVolume scans a volume row from a *sql.Rows cursor.
|
|
||||||
func scanVolume(rows *sql.Rows) (Volume, error) {
|
|
||||||
var vol Volume
|
|
||||||
err := rows.Scan(&vol.ID, &vol.ProjectID, &vol.Source, &vol.Target, &vol.Mode,
|
|
||||||
&vol.Scope, &vol.Name, &vol.CreatedAt, &vol.UpdatedAt)
|
|
||||||
if err != nil {
|
|
||||||
return Volume{}, fmt.Errorf("scan volume: %w", err)
|
|
||||||
}
|
|
||||||
return vol, nil
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
// handler decides what to do so the row reflects the final outcome.
|
// handler decides what to do so the row reflects the final outcome.
|
||||||
type WebhookDelivery struct {
|
type WebhookDelivery struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
TargetType string `json:"target_type"` // "project" or "site"
|
TargetType string `json:"target_type"` // "trigger" today; legacy rows may carry "project" or "site"
|
||||||
TargetID string `json:"target_id"`
|
TargetID string `json:"target_id"`
|
||||||
TargetName string `json:"target_name"`
|
TargetName string `json:"target_name"`
|
||||||
ReceivedAt string `json:"received_at"`
|
ReceivedAt string `json:"received_at"`
|
||||||
@@ -38,9 +38,9 @@ func (s *Store) InsertWebhookDelivery(d WebhookDelivery) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for a
|
// ListWebhookDeliveriesByTarget returns the most recent N deliveries for
|
||||||
// specific target. Used by the per-entity panel on the project / site detail
|
// a specific target. Used by the trigger detail panel after the legacy
|
||||||
// pages.
|
// project / site detail pages were removed.
|
||||||
func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) {
|
func (s *Store) ListWebhookDeliveriesByTarget(targetType, targetID string, limit int) ([]WebhookDelivery, error) {
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 50
|
limit = 50
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// dbExec is the subset of *sql.DB and *sql.Tx used by the sync helpers so
|
|
||||||
// CRUD callers can pass in either a transaction or the raw DB handle. Keeps
|
|
||||||
// the sync logic atomic with the parent row when wrapped in a Begin/Commit.
|
|
||||||
type dbExec interface {
|
|
||||||
Exec(query string, args ...any) (sql.Result, error)
|
|
||||||
QueryRow(query string, args ...any) *sql.Row
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncWorkloadTx is the shared upsert path used by every kind-specific
|
|
||||||
// sync helper. Caller passes the kind, ref, and the projection of fields
|
|
||||||
// that map onto the workload row. Idempotent — uses the (kind, ref_id) UNIQUE
|
|
||||||
// constraint to decide INSERT vs UPDATE.
|
|
||||||
func syncWorkloadTx(ex dbExec, kind WorkloadKind, refID, name, notifURL, notifSecret, hookSecret, signSecret string, requireSig bool) error {
|
|
||||||
now := Now()
|
|
||||||
requireInt := 0
|
|
||||||
if requireSig {
|
|
||||||
requireInt = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingID string
|
|
||||||
err := ex.QueryRow(
|
|
||||||
`SELECT id FROM workloads WHERE kind = ? AND ref_id = ?`,
|
|
||||||
string(kind), refID,
|
|
||||||
).Scan(&existingID)
|
|
||||||
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
_, err := ex.Exec(
|
|
||||||
`INSERT INTO workloads (id, kind, ref_id, name, app_id,
|
|
||||||
notification_url, notification_secret,
|
|
||||||
webhook_secret, webhook_signing_secret, webhook_require_signature,
|
|
||||||
created_at, updated_at)
|
|
||||||
VALUES (?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
uuid.New().String(), string(kind), refID, name,
|
|
||||||
notifURL, notifSecret, hookSecret, signSecret, requireInt,
|
|
||||||
now, now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("insert %s workload: %w", kind, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("lookup %s workload: %w", kind, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = ex.Exec(
|
|
||||||
`UPDATE workloads SET name=?,
|
|
||||||
notification_url=?, notification_secret=?,
|
|
||||||
webhook_secret=?, webhook_signing_secret=?, webhook_require_signature=?,
|
|
||||||
updated_at=?
|
|
||||||
WHERE id=?`,
|
|
||||||
name, notifURL, notifSecret, hookSecret, signSecret, requireInt, now, existingID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update %s workload: %w", kind, err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncProjectWorkloadTx upserts the workload row paired with a project inside
|
|
||||||
// the caller's transaction. Used by CreateProject / UpdateProject /
|
|
||||||
// SetProject*Secret so the parent UPDATE and the workload sync share atomicity.
|
|
||||||
func SyncProjectWorkloadTx(tx *sql.Tx, p Project) error {
|
|
||||||
return syncWorkloadTx(tx, WorkloadKindProject, p.ID, p.Name,
|
|
||||||
p.NotificationURL, p.NotificationSecret,
|
|
||||||
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStackWorkloadTx upserts the workload row paired with a stack inside the
|
|
||||||
// caller's transaction. Stacks don't carry notification or webhook config yet.
|
|
||||||
func SyncStackWorkloadTx(tx *sql.Tx, st Stack) error {
|
|
||||||
return syncWorkloadTx(tx, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStaticSiteWorkloadTx upserts the workload row paired with a static site
|
|
||||||
// inside the caller's transaction.
|
|
||||||
func SyncStaticSiteWorkloadTx(tx *sql.Tx, site StaticSite) error {
|
|
||||||
return syncWorkloadTx(tx, WorkloadKindSite, site.ID, site.Name,
|
|
||||||
site.NotificationURL, site.NotificationSecret,
|
|
||||||
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncProjectWorkload is the non-transactional convenience used by
|
|
||||||
// BackfillWorkloads (a boot-time, single-row, idempotent recovery pass).
|
|
||||||
// CRUD paths must use SyncProjectWorkloadTx instead, with their parent
|
|
||||||
// UPDATE inside the same transaction.
|
|
||||||
func (s *Store) SyncProjectWorkload(p Project) error {
|
|
||||||
return syncWorkloadTx(s.db, WorkloadKindProject, p.ID, p.Name,
|
|
||||||
p.NotificationURL, p.NotificationSecret,
|
|
||||||
p.WebhookSecret, p.WebhookSigningSecret, p.WebhookRequireSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStackWorkload is the non-transactional convenience used by BackfillWorkloads.
|
|
||||||
func (s *Store) SyncStackWorkload(st Stack) error {
|
|
||||||
return syncWorkloadTx(s.db, WorkloadKindStack, st.ID, st.Name, "", "", "", "", false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SyncStaticSiteWorkload is the non-transactional convenience used by BackfillWorkloads.
|
|
||||||
func (s *Store) SyncStaticSiteWorkload(site StaticSite) error {
|
|
||||||
return syncWorkloadTx(s.db, WorkloadKindSite, site.ID, site.Name,
|
|
||||||
site.NotificationURL, site.NotificationSecret,
|
|
||||||
site.WebhookSecret, site.WebhookSigningSecret, site.WebhookRequireSignature)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BackfillWorkloads scans every project / stack / static_site row and ensures
|
|
||||||
// each has a matching workload row. Called once at boot before HTTP starts so
|
|
||||||
// any pre-Workload-refactor data is upgraded transparently. Idempotent.
|
|
||||||
func (s *Store) BackfillWorkloads() error {
|
|
||||||
projects, err := s.GetAllProjects()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("backfill: list projects: %w", err)
|
|
||||||
}
|
|
||||||
for _, p := range projects {
|
|
||||||
if err := s.SyncProjectWorkload(p); err != nil {
|
|
||||||
return fmt.Errorf("backfill project %s: %w", p.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stacks, err := s.GetAllStacks()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("backfill: list stacks: %w", err)
|
|
||||||
}
|
|
||||||
for _, st := range stacks {
|
|
||||||
if err := s.SyncStackWorkload(st); err != nil {
|
|
||||||
return fmt.Errorf("backfill stack %s: %w", st.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sites, err := s.GetAllStaticSites()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("backfill: list static sites: %w", err)
|
|
||||||
}
|
|
||||||
for _, site := range sites {
|
|
||||||
if err := s.SyncStaticSiteWorkload(site); err != nil {
|
|
||||||
return fmt.Errorf("backfill static site %s: %w", site.ID, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCreateProjectAlsoCreatesWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, err := s.CreateProject(Project{
|
|
||||||
Name: "wf-project", Image: "nginx", Port: 80, Env: "{}", Volumes: "{}",
|
|
||||||
NotificationURL: "https://example.test/hook",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateProject: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("workload should exist after CreateProject: %v", err)
|
|
||||||
}
|
|
||||||
if w.Name != "wf-project" {
|
|
||||||
t.Fatalf("workload name not synced: got %q", w.Name)
|
|
||||||
}
|
|
||||||
if w.WebhookSecret == "" {
|
|
||||||
t.Fatal("webhook secret should be carried into workload row")
|
|
||||||
}
|
|
||||||
if w.NotificationURL != "https://example.test/hook" {
|
|
||||||
t.Fatalf("notification url not synced: got %q", w.NotificationURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateProjectSyncsWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{
|
|
||||||
Name: "before", Image: "i", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
|
|
||||||
p.Name = "after"
|
|
||||||
p.NotificationURL = "https://new.test/hook"
|
|
||||||
if err := s.UpdateProject(p); err != nil {
|
|
||||||
t.Fatalf("UpdateProject: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
if w.Name != "after" {
|
|
||||||
t.Fatalf("workload name not updated: got %q", w.Name)
|
|
||||||
}
|
|
||||||
if w.NotificationURL != "https://new.test/hook" {
|
|
||||||
t.Fatalf("workload notification url not updated: got %q", w.NotificationURL)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteProjectCascadesWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "doomed", Image: "i", Env: "{}", Volumes: "{}"})
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
|
|
||||||
// Add a container under this workload to verify cascade.
|
|
||||||
if _, err := s.CreateContainer(Container{
|
|
||||||
WorkloadID: w.ID, WorkloadKind: "project", State: "running",
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("CreateContainer: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.DeleteProject(p.ID); err != nil {
|
|
||||||
t.Fatalf("DeleteProject: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Fatalf("workload should be deleted, got %v", err)
|
|
||||||
}
|
|
||||||
containers, _ := s.ListContainersByWorkload(w.ID)
|
|
||||||
if len(containers) != 0 {
|
|
||||||
t.Fatalf("containers should be deleted, got %d", len(containers))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetProjectWebhookSecretSyncsWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "n", Image: "i", Env: "{}", Volumes: "{}"})
|
|
||||||
|
|
||||||
newSecret := "new-secret-value-with-enough-entropy-1234"
|
|
||||||
if err := s.SetProjectWebhookSecret(p.ID, newSecret); err != nil {
|
|
||||||
t.Fatalf("SetProjectWebhookSecret: %v", err)
|
|
||||||
}
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
if w.WebhookSecret != newSecret {
|
|
||||||
t.Fatalf("workload webhook secret not synced: got %q", w.WebhookSecret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateStackAlsoCreatesWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
st, err := s.CreateStack(Stack{Name: "wf-stack", ComposeProjectName: "wf-stack"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateStack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("workload should exist after CreateStack: %v", err)
|
|
||||||
}
|
|
||||||
if w.Name != "wf-stack" {
|
|
||||||
t.Fatalf("workload name not synced: got %q", w.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestUpdateStackSyncsWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
st, _ := s.CreateStack(Stack{Name: "before", ComposeProjectName: "before-cp"})
|
|
||||||
st.Name = "after"
|
|
||||||
if err := s.UpdateStack(st); err != nil {
|
|
||||||
t.Fatalf("UpdateStack: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
|
|
||||||
if w.Name != "after" {
|
|
||||||
t.Fatalf("workload name not updated: got %q", w.Name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDeleteStackCascadesWorkload(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
st, _ := s.CreateStack(Stack{Name: "doomed-stack", ComposeProjectName: "doomed-cp"})
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindStack, st.ID)
|
|
||||||
|
|
||||||
if err := s.DeleteStack(st.ID); err != nil {
|
|
||||||
t.Fatalf("DeleteStack: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.GetWorkloadByID(w.ID); !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Fatalf("workload should be deleted, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfillWorkloadsIdempotent(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
// Create rows directly via the store (which already auto-syncs), then run
|
|
||||||
// the backfill twice — it must be a no-op the second time and not error.
|
|
||||||
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
|
|
||||||
st, _ := s.CreateStack(Stack{Name: "s1", ComposeProjectName: "s1-cp"})
|
|
||||||
|
|
||||||
if err := s.BackfillWorkloads(); err != nil {
|
|
||||||
t.Fatalf("first backfill: %v", err)
|
|
||||||
}
|
|
||||||
if err := s.BackfillWorkloads(); err != nil {
|
|
||||||
t.Fatalf("second backfill (should be idempotent): %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
all, _ := s.ListWorkloads("")
|
|
||||||
// Expect exactly 2: one project workload, one stack workload, no duplicates.
|
|
||||||
if len(all) != 2 {
|
|
||||||
t.Fatalf("expected 2 workloads after backfill, got %d", len(all))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Confirm both refs are findable.
|
|
||||||
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
|
|
||||||
t.Fatalf("project workload not found: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.GetWorkloadByRef(WorkloadKindStack, st.ID); err != nil {
|
|
||||||
t.Fatalf("stack workload not found: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackfillRecoversFromMissingWorkloads(t *testing.T) {
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
p, _ := s.CreateProject(Project{Name: "p1", Image: "i", Env: "{}", Volumes: "{}"})
|
|
||||||
|
|
||||||
// Simulate the legacy state: a project exists but its workload row is gone
|
|
||||||
// (e.g. the rollout from before the refactor). Backfill must restore it.
|
|
||||||
w, _ := s.GetWorkloadByRef(WorkloadKindProject, p.ID)
|
|
||||||
_ = s.DeleteWorkload(w.ID)
|
|
||||||
|
|
||||||
if err := s.BackfillWorkloads(); err != nil {
|
|
||||||
t.Fatalf("backfill: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.GetWorkloadByRef(WorkloadKindProject, p.ID); err != nil {
|
|
||||||
t.Fatalf("workload should be restored: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -93,24 +93,6 @@ func (s *Store) GetWorkloadByRef(kind WorkloadKind, refID string) (Workload, err
|
|||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWorkloadByWebhookSecret looks up a workload by its inbound webhook URL secret.
|
|
||||||
// Returns ErrNotFound when no match — used by the webhook router.
|
|
||||||
func (s *Store) GetWorkloadByWebhookSecret(secret string) (Workload, error) {
|
|
||||||
if secret == "" {
|
|
||||||
return Workload{}, fmt.Errorf("empty secret: %w", ErrNotFound)
|
|
||||||
}
|
|
||||||
w, err := scanWorkload(s.db.QueryRow(
|
|
||||||
`SELECT `+workloadColumns+` FROM workloads WHERE webhook_secret = ?`, secret,
|
|
||||||
))
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
return Workload{}, ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return Workload{}, fmt.Errorf("query workload by webhook secret: %w", err)
|
|
||||||
}
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListWorkloads returns all workloads, optionally filtered by kind. Pass
|
// ListWorkloads returns all workloads, optionally filtered by kind. Pass
|
||||||
// empty string to get every workload regardless of kind.
|
// empty string to get every workload regardless of kind.
|
||||||
func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) {
|
func (s *Store) ListWorkloads(kind WorkloadKind) ([]Workload, error) {
|
||||||
@@ -231,40 +213,12 @@ func (s *Store) ListChildrenByParent(parentID string) ([]Workload, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetWorkloadWebhookSecret rotates the inbound webhook URL secret. Pass
|
// Workload-level webhook secret accessors (Get/Set/Ensure) were dropped
|
||||||
// empty to disable inbound webhooks for this workload.
|
// in the hard legacy cutover: the inbound `/api/webhook/workloads/...`
|
||||||
func (s *Store) SetWorkloadWebhookSecret(id, secret string) error {
|
// route is gone. The trigger-split refactor's boot backfill still reads
|
||||||
result, err := s.db.Exec(
|
// the `workloads.webhook_secret` column directly via SQL to lift any
|
||||||
`UPDATE workloads SET webhook_secret=?, updated_at=? WHERE id=?`,
|
// pre-cutover embedded secret onto its standalone Trigger row, then the
|
||||||
secret, Now(), id,
|
// column is effectively dead.
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("update workload webhook_secret: %w", err)
|
|
||||||
}
|
|
||||||
n, _ := result.RowsAffected()
|
|
||||||
if n == 0 {
|
|
||||||
return fmt.Errorf("workload %s: %w", id, ErrNotFound)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureWorkloadWebhookSecret returns the current secret, generating one
|
|
||||||
// lazily for workloads that predate the column. Mirrors the project /
|
|
||||||
// site equivalents.
|
|
||||||
func (s *Store) EnsureWorkloadWebhookSecret(id string) (string, error) {
|
|
||||||
w, err := s.GetWorkloadByID(id)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if w.WebhookSecret != "" {
|
|
||||||
return w.WebhookSecret, nil
|
|
||||||
}
|
|
||||||
secret := generateWebhookSecret()
|
|
||||||
if err := s.SetWorkloadWebhookSecret(id, secret); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return secret, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id).
|
// DeleteWorkloadByRef removes the workload paired with a given (kind, ref_id).
|
||||||
// Idempotent — returns nil if no row exists, since the kind-specific Delete
|
// Idempotent — returns nil if no row exists, since the kind-specific Delete
|
||||||
|
|||||||
@@ -84,28 +84,9 @@ func TestUpdateWorkload(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetWorkloadByWebhookSecret(t *testing.T) {
|
// GetWorkloadByWebhookSecret was deleted with the legacy
|
||||||
s := newTestStore(t)
|
// `/api/webhook/workloads/{secret}` route in the hard cutover; the
|
||||||
|
// inbound webhook surface is now first-class Triggers.
|
||||||
w, _ := s.CreateWorkload(Workload{
|
|
||||||
Kind: "project", RefID: "p1", Name: "n", WebhookSecret: "deadbeef",
|
|
||||||
})
|
|
||||||
|
|
||||||
got, err := s.GetWorkloadByWebhookSecret("deadbeef")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GetWorkloadByWebhookSecret: %v", err)
|
|
||||||
}
|
|
||||||
if got.ID != w.ID {
|
|
||||||
t.Fatalf("got workload %s, want %s", got.ID, w.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := s.GetWorkloadByWebhookSecret(""); !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Fatalf("empty secret should be NotFound, got %v", err)
|
|
||||||
}
|
|
||||||
if _, err := s.GetWorkloadByWebhookSecret("nope"); !errors.Is(err, ErrNotFound) {
|
|
||||||
t.Fatalf("unknown secret should be NotFound, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestListWorkloads(t *testing.T) {
|
func TestListWorkloads(t *testing.T) {
|
||||||
s := newTestStore(t)
|
s := newTestStore(t)
|
||||||
|
|||||||
@@ -10,58 +10,6 @@ import (
|
|||||||
"github.com/alexei/tinyforge/internal/store"
|
"github.com/alexei/tinyforge/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveParams holds the parameters needed to resolve a volume's host path.
|
|
||||||
type ResolveParams struct {
|
|
||||||
BasePath string
|
|
||||||
ProjectName string
|
|
||||||
StageName string // required for instance and stage scopes
|
|
||||||
ImageTag string // required for instance scope
|
|
||||||
AllowedVolumePaths string // JSON array of allowed absolute paths (from settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResolvePath returns the absolute host path for a volume based on its scope.
|
|
||||||
// Returns an error for ephemeral volumes (no host path) or missing parameters.
|
|
||||||
func ResolvePath(vol store.Volume, params ResolveParams) (string, error) {
|
|
||||||
scope := vol.Scope
|
|
||||||
if scope == "" {
|
|
||||||
switch vol.Mode {
|
|
||||||
case "isolated":
|
|
||||||
scope = "instance"
|
|
||||||
default:
|
|
||||||
scope = "project"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if scope == "ephemeral" {
|
|
||||||
return "", fmt.Errorf("ephemeral volumes have no host path")
|
|
||||||
}
|
|
||||||
|
|
||||||
if scope == "absolute" {
|
|
||||||
return resolveAbsolute(vol.Source, params.AllowedVolumePaths)
|
|
||||||
}
|
|
||||||
|
|
||||||
switch scope {
|
|
||||||
case "instance":
|
|
||||||
if params.StageName == "" || params.ImageTag == "" {
|
|
||||||
return "", fmt.Errorf("instance scope requires stage and tag parameters")
|
|
||||||
}
|
|
||||||
return filepath.Join(params.BasePath, params.ProjectName, fmt.Sprintf("%s-%s", params.StageName, params.ImageTag), vol.Source), nil
|
|
||||||
case "stage":
|
|
||||||
if params.StageName == "" {
|
|
||||||
return "", fmt.Errorf("stage scope requires stage parameter")
|
|
||||||
}
|
|
||||||
return filepath.Join(params.BasePath, params.ProjectName, params.StageName, vol.Source), nil
|
|
||||||
case "project":
|
|
||||||
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
|
||||||
case "project_named":
|
|
||||||
return filepath.Join(params.BasePath, params.ProjectName, "_named", vol.Name, vol.Source), nil
|
|
||||||
case "named":
|
|
||||||
return filepath.Join(params.BasePath, "_named", vol.Name, vol.Source), nil
|
|
||||||
default:
|
|
||||||
return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveAbsolute validates that the source path is under one of the allowed prefixes.
|
// resolveAbsolute validates that the source path is under one of the allowed prefixes.
|
||||||
func resolveAbsolute(source, allowedPathsJSON string) (string, error) {
|
func resolveAbsolute(source, allowedPathsJSON string) (string, error) {
|
||||||
if source == "" {
|
if source == "" {
|
||||||
|
|||||||
+33
-458
@@ -6,13 +6,10 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -125,28 +122,12 @@ func verifyHMAC(signingSecret string, body []byte, headerValue string) (verified
|
|||||||
return hmac.Equal(provided, expected), true
|
return hmac.Equal(provided, expected), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxSiteConcurrentSyncs caps fan-out of background site syncs triggered by
|
|
||||||
// webhooks. Above this limit, requests are rejected with 503.
|
|
||||||
const maxSiteConcurrentSyncs = 4
|
|
||||||
|
|
||||||
// maxWebhookBodyBytes caps the request body size for webhook payloads. The
|
// maxWebhookBodyBytes caps the request body size for webhook payloads. The
|
||||||
// /api routes already wrap the body with MaxBytesReader, but the webhook
|
// /api routes already wrap the body with MaxBytesReader, but the webhook
|
||||||
// router relies on its own limit so changes to the parent middleware can't
|
// router relies on its own limit so changes to the parent middleware can't
|
||||||
// silently increase the cap.
|
// silently increase the cap.
|
||||||
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
|
const maxWebhookBodyBytes = 256 * 1024 // 256 KiB
|
||||||
|
|
||||||
// DeployTriggerer is called when a webhook determines a deploy should happen.
|
|
||||||
// Same interface as registry.DeployTriggerer — kept separate to avoid import cycles.
|
|
||||||
type DeployTriggerer interface {
|
|
||||||
TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// SiteSyncTriggerer is called when a static-site webhook determines a sync
|
|
||||||
// should happen. The manager handles the actual git-pull + redeploy.
|
|
||||||
type SiteSyncTriggerer interface {
|
|
||||||
Deploy(ctx context.Context, siteID string, force bool) error
|
|
||||||
}
|
|
||||||
|
|
||||||
// PluginDispatcher is what the plugin-workload webhook handler needs from
|
// PluginDispatcher is what the plugin-workload webhook handler needs from
|
||||||
// the deployer: the canonical Source-dispatch entry point plus access to
|
// the deployer: the canonical Source-dispatch entry point plus access to
|
||||||
// the same Deps bundle so Trigger.Match can read store / crypto.
|
// the same Deps bundle so Trigger.Match can read store / crypto.
|
||||||
@@ -155,23 +136,10 @@ type PluginDispatcher interface {
|
|||||||
PluginDeps() pluginDeps
|
PluginDeps() pluginDeps
|
||||||
}
|
}
|
||||||
|
|
||||||
// Payload is the expected JSON body for a project webhook request.
|
// parsedImage holds the components extracted from a full image reference
|
||||||
type Payload struct {
|
// string. Package-private — the only callers are buildInboundEvent and the
|
||||||
// Image is the full image reference including tag, e.g.
|
// vendor parsers in this package.
|
||||||
// "git.dolgolyov-family.by/alexei/web-app-launcher:dev-abc123".
|
type parsedImage struct {
|
||||||
Image string `json:"image"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// SitePayload is the expected JSON body for a static-site webhook request.
|
|
||||||
// Callers point Gitea/GitHub/GitLab webhooks at the site URL; only the ref
|
|
||||||
// matters for branch filtering. Body is optional — an empty body triggers
|
|
||||||
// a sync using the site's configured branch.
|
|
||||||
type SitePayload struct {
|
|
||||||
Ref string `json:"ref"` // e.g. "refs/heads/main"; optional
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParsedImage holds the components extracted from a full image reference string.
|
|
||||||
type ParsedImage struct {
|
|
||||||
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
|
// Registry is the hostname, e.g. "git.dolgolyov-family.by".
|
||||||
Registry string
|
Registry string
|
||||||
// Owner is the namespace/org, e.g. "alexei".
|
// Owner is the namespace/org, e.g. "alexei".
|
||||||
@@ -182,28 +150,28 @@ type ParsedImage struct {
|
|||||||
Tag string
|
Tag string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FullName returns "owner/name" (the image path without registry and tag).
|
// fullName returns "owner/name" (the image path without registry and tag).
|
||||||
func (p ParsedImage) FullName() string {
|
func (p parsedImage) fullName() string {
|
||||||
if p.Owner != "" {
|
if p.Owner != "" {
|
||||||
return p.Owner + "/" + p.Name
|
return p.Owner + "/" + p.Name
|
||||||
}
|
}
|
||||||
return p.Name
|
return p.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseImageRef splits a full image reference into its components.
|
// parseImageRef splits a full image reference into its components.
|
||||||
// Accepted formats:
|
// Accepted formats:
|
||||||
//
|
//
|
||||||
// registry.example.com/owner/name:tag
|
// registry.example.com/owner/name:tag
|
||||||
// registry.example.com/owner/name
|
// registry.example.com/owner/name
|
||||||
// owner/name:tag
|
// owner/name:tag
|
||||||
// name:tag
|
// name:tag
|
||||||
func ParseImageRef(ref string) (ParsedImage, error) {
|
func parseImageRef(ref string) (parsedImage, error) {
|
||||||
ref = strings.TrimSpace(ref)
|
ref = strings.TrimSpace(ref)
|
||||||
if ref == "" {
|
if ref == "" {
|
||||||
return ParsedImage{}, fmt.Errorf("empty image reference")
|
return parsedImage{}, fmt.Errorf("empty image reference")
|
||||||
}
|
}
|
||||||
|
|
||||||
var parsed ParsedImage
|
var parsed parsedImage
|
||||||
|
|
||||||
// Split off tag.
|
// Split off tag.
|
||||||
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
if idx := strings.LastIndex(ref, ":"); idx != -1 {
|
||||||
@@ -232,81 +200,45 @@ func ParseImageRef(ref string) (ParsedImage, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Name == "" {
|
if parsed.Name == "" {
|
||||||
return ParsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
|
return parsedImage{}, fmt.Errorf("invalid image reference: missing name in %q", ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
return parsed, nil
|
return parsed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler is the HTTP handler for webhook requests.
|
// Handler is the HTTP handler for webhook requests. After the legacy
|
||||||
|
// project / site webhook routes were dropped, the only inbound path is
|
||||||
|
// the trigger fan-out — every project / site / stack webhook was lifted
|
||||||
|
// into a first-class Trigger row by the boot backfill.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
deployer DeployTriggerer
|
plugins PluginDispatcher // optional; nil disables /triggers/{secret}
|
||||||
sites SiteSyncTriggerer
|
|
||||||
plugins PluginDispatcher // optional; nil disables /workloads/{secret}
|
|
||||||
|
|
||||||
// Site sync coordination — webhooks fire syncs in the background; Drain
|
|
||||||
// blocks until those goroutines finish, so a graceful shutdown does not
|
|
||||||
// kill an in-flight git fetch + container rebuild.
|
|
||||||
siteSyncCtx context.Context
|
|
||||||
siteSyncCancel context.CancelFunc
|
|
||||||
siteSyncWG sync.WaitGroup
|
|
||||||
siteSyncSem chan struct{}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new webhook Handler. The sites triggerer is optional
|
// NewHandler creates a new webhook Handler bound to a store.
|
||||||
// and may be nil (site webhooks will return 404).
|
func NewHandler(st *store.Store) *Handler {
|
||||||
func NewHandler(st *store.Store, deployer DeployTriggerer, sites SiteSyncTriggerer) *Handler {
|
return &Handler{store: st}
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
return &Handler{
|
|
||||||
store: st,
|
|
||||||
deployer: deployer,
|
|
||||||
sites: sites,
|
|
||||||
siteSyncCtx: ctx,
|
|
||||||
siteSyncCancel: cancel,
|
|
||||||
siteSyncSem: make(chan struct{}, maxSiteConcurrentSyncs),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetSiteSyncTriggerer injects the static-site manager after construction.
|
|
||||||
// The site manager depends on the store + docker client, which are wired up
|
|
||||||
// in the same startup path as the handler; this setter lets callers defer the
|
|
||||||
// dependency if needed.
|
|
||||||
func (h *Handler) SetSiteSyncTriggerer(s SiteSyncTriggerer) {
|
|
||||||
h.sites = s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
|
// SetPluginDispatcher injects the plugin-pipeline dispatcher. Until this
|
||||||
// is called the /workloads/{secret} route returns 503 — preventing partial
|
// is called the /triggers/{secret} route returns 503 — preventing partial
|
||||||
// initialization from silently dropping deploys.
|
// initialization from silently dropping deploys.
|
||||||
func (h *Handler) SetPluginDispatcher(d PluginDispatcher) {
|
func (h *Handler) SetPluginDispatcher(d PluginDispatcher) {
|
||||||
h.plugins = d
|
h.plugins = d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drain cancels in-flight site syncs and waits for their goroutines to exit.
|
// Drain is a no-op kept for symmetry with the previous shutdown path.
|
||||||
// Safe to call from a graceful-shutdown path.
|
// The trigger fan-out runs synchronously inside the request goroutine,
|
||||||
func (h *Handler) Drain() {
|
// so there is nothing to drain at the handler level.
|
||||||
h.siteSyncCancel()
|
func (h *Handler) Drain() {}
|
||||||
h.siteSyncWG.Wait()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route returns a chi router with the webhook endpoints mounted.
|
// Route returns a chi router with the single inbound webhook endpoint
|
||||||
//
|
// mounted at /triggers/{secret}. Legacy /{secret} and /sites/{secret}
|
||||||
// Routes:
|
// routes were removed in the hard cutover; their secrets were lifted
|
||||||
//
|
// into Trigger rows on boot.
|
||||||
// POST /{secret} — per-project deploy trigger (legacy)
|
|
||||||
// POST /sites/{secret} — per-site sync trigger (legacy)
|
|
||||||
// POST /triggers/{secret} — first-class trigger fan-out to all bound workloads
|
|
||||||
//
|
|
||||||
// The legacy POST /workloads/{secret} route was dropped in the
|
|
||||||
// trigger-split refactor. Existing inbound webhook secrets were lifted
|
|
||||||
// into trigger rows by the boot backfill, so the same secret value
|
|
||||||
// works at /triggers/{secret} after the upgrade.
|
|
||||||
func (h *Handler) Route() chi.Router {
|
func (h *Handler) Route() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Post("/sites/{secret}", h.handleSiteWebhook)
|
|
||||||
r.Post("/triggers/{secret}", h.handleTriggerWebhook)
|
r.Post("/triggers/{secret}", h.handleTriggerWebhook)
|
||||||
r.Post("/{secret}", h.handleWebhook)
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,363 +254,6 @@ func respondWebhookError(w http.ResponseWriter, status int, msg string) {
|
|||||||
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
|
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWebhook processes an incoming project webhook request.
|
|
||||||
//
|
|
||||||
// URL: POST /api/webhook/{secret}
|
|
||||||
//
|
|
||||||
// The secret identifies exactly one project. Stage routing is delegated to
|
|
||||||
// the project's configured stages (tag_pattern match). Returns 404 for
|
|
||||||
// unknown secrets (no information leak).
|
|
||||||
func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
// Build the audit record incrementally; record on every return path so
|
|
||||||
// users can debug "why didn't my deploy fire?" without grepping logs.
|
|
||||||
delivery := store.WebhookDelivery{
|
|
||||||
TargetType: "project",
|
|
||||||
SourceIP: clientIP(r),
|
|
||||||
SignatureState: sigStateUnconfigured,
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Outcome: outcomeSkip,
|
|
||||||
}
|
|
||||||
defer func() { h.recordDelivery(delivery) }()
|
|
||||||
|
|
||||||
secret := chi.URLParam(r, "secret")
|
|
||||||
if secret == "" {
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeNotFound
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the secret via the workload row only. The project's own
|
|
||||||
// webhook_secret column is the source of truth, but lookups go through
|
|
||||||
// workloads.webhook_secret which is kept in lock-step by the
|
|
||||||
// transactional sync in the project CRUD path. Reading from workloads
|
|
||||||
// alone closes the rotation-durability gap: any rotation that didn't
|
|
||||||
// commit also didn't update the workload row, so an old secret
|
|
||||||
// surfaces here as 404 rather than being silently accepted.
|
|
||||||
var (
|
|
||||||
project store.Project
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
|
|
||||||
if wErr == nil && wl.Kind == string(store.WorkloadKindProject) {
|
|
||||||
project, err = h.store.GetProjectByID(wl.RefID)
|
|
||||||
} else {
|
|
||||||
err = store.ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeNotFound
|
|
||||||
delivery.Detail = "unknown webhook secret"
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("webhook: project lookup failed", "error", err)
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeError
|
|
||||||
delivery.Detail = "lookup failed"
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delivery.TargetID = project.ID
|
|
||||||
delivery.TargetName = project.Name
|
|
||||||
|
|
||||||
// Read body once so we can both verify HMAC and decode JSON.
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = "failed to read request body"
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delivery.BodySize = len(body)
|
|
||||||
|
|
||||||
// HMAC enforcement: a configured signing secret + the require_signature
|
|
||||||
// flag together produce a hard reject on missing/invalid signatures.
|
|
||||||
// When the flag is off we still verify any submitted signature so a
|
|
||||||
// CI misconfiguration surfaces as a 401 rather than silent acceptance.
|
|
||||||
header := r.Header.Get(signatureHeader)
|
|
||||||
verified, attempted := verifyHMAC(project.WebhookSigningSecret, body, header)
|
|
||||||
delivery.SignatureState = signatureStateFor(project.WebhookSigningSecret, header, verified, attempted)
|
|
||||||
if project.WebhookRequireSignature && !verified {
|
|
||||||
slog.Warn("webhook: signature required but invalid/missing", "project", project.Name)
|
|
||||||
delivery.StatusCode = http.StatusUnauthorized
|
|
||||||
delivery.Outcome = outcomeRejected
|
|
||||||
delivery.Detail = "invalid or missing signature"
|
|
||||||
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if attempted && !verified {
|
|
||||||
slog.Warn("webhook: bad signature", "project", project.Name)
|
|
||||||
delivery.StatusCode = http.StatusUnauthorized
|
|
||||||
delivery.Outcome = outcomeRejected
|
|
||||||
delivery.Detail = "invalid signature"
|
|
||||||
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload Payload
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = "invalid JSON payload"
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Image == "" {
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = "missing image field"
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "missing image field")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := ParseImageRef(payload.Image)
|
|
||||||
if err != nil {
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = "invalid image reference"
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if parsed.Tag == "" {
|
|
||||||
parsed.Tag = "latest"
|
|
||||||
}
|
|
||||||
|
|
||||||
if project.Image != "" && !imageMatches(project.Image, parsed.FullName()) {
|
|
||||||
slog.Warn("webhook: image mismatch",
|
|
||||||
"project", project.Name, "expected", project.Image, "received", parsed.FullName())
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = fmt.Sprintf("image %q does not match project image %q", parsed.FullName(), project.Image)
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, delivery.Detail)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("webhook: received push",
|
|
||||||
"project", project.Name, "image", parsed.FullName(), "tag", parsed.Tag)
|
|
||||||
|
|
||||||
stage, found, err := matchStage(h.store, project.ID, parsed.Tag)
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("webhook: stage match failed", "project", project.Name, "error", err)
|
|
||||||
delivery.StatusCode = http.StatusInternalServerError
|
|
||||||
delivery.Outcome = outcomeError
|
|
||||||
delivery.Detail = "stage match failed"
|
|
||||||
respondWebhookError(w, http.StatusInternalServerError, "internal error")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !found {
|
|
||||||
slog.Info("webhook: no stage matches tag",
|
|
||||||
"project", project.Name, "tag", parsed.Tag)
|
|
||||||
delivery.Detail = fmt.Sprintf("no stage matches tag %q", parsed.Tag)
|
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"success": true, "deploy": false, "project": project.Name,
|
|
||||||
"reason": "no stage pattern matched tag",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !stage.AutoDeploy {
|
|
||||||
slog.Info("webhook: auto_deploy disabled, skipping",
|
|
||||||
"project", project.Name, "stage", stage.Name)
|
|
||||||
delivery.Detail = fmt.Sprintf("stage %q has auto_deploy disabled", stage.Name)
|
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"success": true, "deploy": false,
|
|
||||||
"project": project.Name, "stage": stage.Name,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
|
|
||||||
slog.Error("webhook: deploy trigger failed", "error", err)
|
|
||||||
delivery.StatusCode = http.StatusInternalServerError
|
|
||||||
delivery.Outcome = outcomeError
|
|
||||||
delivery.Detail = "deploy trigger failed: " + err.Error()
|
|
||||||
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("webhook: triggered deploy",
|
|
||||||
"project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
|
|
||||||
delivery.Outcome = outcomeDeploy
|
|
||||||
delivery.Detail = fmt.Sprintf("stage=%s tag=%s", stage.Name, parsed.Tag)
|
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"success": true, "deploy": true,
|
|
||||||
"project": project.Name, "stage": stage.Name, "tag": parsed.Tag,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSiteWebhook processes an incoming static-site webhook request.
|
|
||||||
//
|
|
||||||
// URL: POST /api/webhook/sites/{secret}
|
|
||||||
//
|
|
||||||
// The secret identifies exactly one static site. If the payload includes a
|
|
||||||
// ref (Git push event), it must match the site's configured branch (when the
|
|
||||||
// site's sync_trigger is "push"). For tag-based sync, the ref must match the
|
|
||||||
// stored tag pattern. Manual-trigger sites ignore webhooks entirely.
|
|
||||||
func (h *Handler) handleSiteWebhook(w http.ResponseWriter, r *http.Request) {
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
delivery := store.WebhookDelivery{
|
|
||||||
TargetType: "site",
|
|
||||||
SourceIP: clientIP(r),
|
|
||||||
SignatureState: sigStateUnconfigured,
|
|
||||||
StatusCode: http.StatusOK,
|
|
||||||
Outcome: outcomeSkip,
|
|
||||||
}
|
|
||||||
defer func() { h.recordDelivery(delivery) }()
|
|
||||||
|
|
||||||
if h.sites == nil {
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeNotFound
|
|
||||||
delivery.Detail = "static site manager not configured"
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
secret := chi.URLParam(r, "secret")
|
|
||||||
if secret == "" {
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeNotFound
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workload-only lookup, mirroring the project handler. Reading from
|
|
||||||
// workloads.webhook_secret keeps rotation-durability honest — a
|
|
||||||
// rotation that didn't commit doesn't update the workload row, so the
|
|
||||||
// stale secret returns 404 instead of being silently accepted.
|
|
||||||
var (
|
|
||||||
site store.StaticSite
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
wl, wErr := h.store.GetWorkloadByWebhookSecret(secret)
|
|
||||||
if wErr == nil && wl.Kind == string(store.WorkloadKindSite) {
|
|
||||||
site, err = h.store.GetStaticSiteByID(wl.RefID)
|
|
||||||
} else {
|
|
||||||
err = store.ErrNotFound
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeNotFound
|
|
||||||
delivery.Detail = "unknown webhook secret"
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slog.Error("webhook: site lookup failed", "error", err)
|
|
||||||
delivery.StatusCode = http.StatusNotFound
|
|
||||||
delivery.Outcome = outcomeError
|
|
||||||
delivery.Detail = "lookup failed"
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delivery.TargetID = site.ID
|
|
||||||
delivery.TargetName = site.Name
|
|
||||||
|
|
||||||
if site.SyncTrigger == "manual" {
|
|
||||||
slog.Info("webhook: site sync_trigger=manual, skipping",
|
|
||||||
"site", site.Name)
|
|
||||||
delivery.Detail = "sync_trigger=manual"
|
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"success": true, "sync": false, "site": site.Name,
|
|
||||||
"reason": "sync_trigger is manual",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload SitePayload
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodyBytes))
|
|
||||||
if err != nil {
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = "failed to read request body"
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "failed to read request body")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
delivery.BodySize = len(body)
|
|
||||||
|
|
||||||
header := r.Header.Get(signatureHeader)
|
|
||||||
verified, attempted := verifyHMAC(site.WebhookSigningSecret, body, header)
|
|
||||||
delivery.SignatureState = signatureStateFor(site.WebhookSigningSecret, header, verified, attempted)
|
|
||||||
if site.WebhookRequireSignature && !verified {
|
|
||||||
slog.Warn("webhook: site signature required but invalid/missing", "site", site.Name)
|
|
||||||
delivery.StatusCode = http.StatusUnauthorized
|
|
||||||
delivery.Outcome = outcomeRejected
|
|
||||||
delivery.Detail = "invalid or missing signature"
|
|
||||||
respondWebhookError(w, http.StatusUnauthorized, "invalid or missing signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if attempted && !verified {
|
|
||||||
slog.Warn("webhook: site bad signature", "site", site.Name)
|
|
||||||
delivery.StatusCode = http.StatusUnauthorized
|
|
||||||
delivery.Outcome = outcomeRejected
|
|
||||||
delivery.Detail = "invalid signature"
|
|
||||||
respondWebhookError(w, http.StatusUnauthorized, "invalid signature")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(body) > 0 {
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
delivery.StatusCode = http.StatusBadRequest
|
|
||||||
delivery.Outcome = outcomeBadRequest
|
|
||||||
delivery.Detail = "invalid JSON payload"
|
|
||||||
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if payload.Ref != "" && !siteRefMatches(site, payload.Ref) {
|
|
||||||
slog.Info("webhook: site ref does not match configured branch/tag",
|
|
||||||
"site", site.Name, "ref", payload.Ref,
|
|
||||||
"branch", site.Branch, "tag_pattern", site.TagPattern,
|
|
||||||
"trigger", site.SyncTrigger)
|
|
||||||
delivery.Detail = fmt.Sprintf("ref %q does not match", payload.Ref)
|
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"success": true, "sync": false, "site": site.Name,
|
|
||||||
"reason": "ref does not match configured branch or tag pattern",
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case h.siteSyncSem <- struct{}{}:
|
|
||||||
default:
|
|
||||||
delivery.StatusCode = http.StatusServiceUnavailable
|
|
||||||
delivery.Outcome = outcomeError
|
|
||||||
delivery.Detail = "site sync queue full"
|
|
||||||
respondWebhookError(w, http.StatusServiceUnavailable, "site sync queue full")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
h.siteSyncWG.Add(1)
|
|
||||||
go func(siteID, siteName string) {
|
|
||||||
defer h.siteSyncWG.Done()
|
|
||||||
defer func() { <-h.siteSyncSem }()
|
|
||||||
if err := h.sites.Deploy(h.siteSyncCtx, siteID, false); err != nil {
|
|
||||||
slog.Error("webhook: site sync failed", "site", siteName, "error", err)
|
|
||||||
}
|
|
||||||
}(site.ID, site.Name)
|
|
||||||
|
|
||||||
_ = ctx
|
|
||||||
slog.Info("webhook: triggered site sync", "site", site.Name, "ref", payload.Ref)
|
|
||||||
delivery.Outcome = outcomeDeploy
|
|
||||||
if payload.Ref != "" {
|
|
||||||
delivery.Detail = fmt.Sprintf("ref=%s", payload.Ref)
|
|
||||||
} else {
|
|
||||||
delivery.Detail = "no ref filter"
|
|
||||||
}
|
|
||||||
respondWebhookJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"success": true, "sync": true, "site": site.Name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildInboundEvent normalizes the incoming webhook body into the
|
// buildInboundEvent normalizes the incoming webhook body into the
|
||||||
// plugin.InboundEvent shape. The dispatch order is:
|
// plugin.InboundEvent shape. The dispatch order is:
|
||||||
//
|
//
|
||||||
@@ -730,14 +305,14 @@ func buildInboundEvent(body []byte, headers http.Header) (plugin.InboundEvent, e
|
|||||||
return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
|
return plugin.InboundEvent{}, fmt.Errorf("invalid JSON payload")
|
||||||
}
|
}
|
||||||
if probe.Image != "" {
|
if probe.Image != "" {
|
||||||
parsed, err := ParseImageRef(probe.Image)
|
parsed, err := parseImageRef(probe.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return plugin.InboundEvent{}, fmt.Errorf("invalid image reference")
|
return plugin.InboundEvent{}, fmt.Errorf("invalid image reference")
|
||||||
}
|
}
|
||||||
evt.Kind = "image-push"
|
evt.Kind = "image-push"
|
||||||
evt.Image = &plugin.ImagePushEvent{
|
evt.Image = &plugin.ImagePushEvent{
|
||||||
Registry: parsed.Registry,
|
Registry: parsed.Registry,
|
||||||
Repo: parsed.FullName(),
|
Repo: parsed.fullName(),
|
||||||
Tag: parsed.Tag,
|
Tag: parsed.Tag,
|
||||||
}
|
}
|
||||||
return evt, nil
|
return evt, nil
|
||||||
@@ -776,8 +351,8 @@ func toPluginWorkload(w store.Workload) plugin.Workload {
|
|||||||
TriggerKind: w.TriggerKind,
|
TriggerKind: w.TriggerKind,
|
||||||
TriggerConfig: json.RawMessage(w.TriggerConfig),
|
TriggerConfig: json.RawMessage(w.TriggerConfig),
|
||||||
PublicFaces: faces,
|
PublicFaces: faces,
|
||||||
NotificationURL: w.NotificationURL,
|
NotificationURL: w.NotificationURL,
|
||||||
NotificationSecret: w.NotificationSecret,
|
NotificationSecret: w.NotificationSecret,
|
||||||
WebhookSecret: w.WebhookSecret,
|
WebhookSecret: w.WebhookSecret,
|
||||||
WebhookSigningSecret: w.WebhookSigningSecret,
|
WebhookSigningSecret: w.WebhookSigningSecret,
|
||||||
WebhookRequireSignature: w.WebhookRequireSignature,
|
WebhookRequireSignature: w.WebhookRequireSignature,
|
||||||
|
|||||||
@@ -1,457 +0,0 @@
|
|||||||
package webhook_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
"github.com/alexei/tinyforge/internal/webhook"
|
|
||||||
)
|
|
||||||
|
|
||||||
// signBody computes the HMAC-SHA256 hex digest used by the X-Hub-Signature-256 header.
|
|
||||||
func signBody(secret, body string) string {
|
|
||||||
mac := hmac.New(sha256.New, []byte(secret))
|
|
||||||
mac.Write([]byte(body))
|
|
||||||
return "sha256=" + hex.EncodeToString(mac.Sum(nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
// doJSONSigned mirrors doJSON but adds the X-Hub-Signature-256 header.
|
|
||||||
func doJSONSigned(t *testing.T, r chi.Router, method, path, body, signingSecret string) (*http.Response, string) {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
if signingSecret != "" {
|
|
||||||
req.Header.Set("X-Hub-Signature-256", signBody(signingSecret, body))
|
|
||||||
}
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
resp := w.Result()
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp, string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeDeployer records the last trigger for assertion.
|
|
||||||
type fakeDeployer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
calls int
|
|
||||||
lastProj string
|
|
||||||
lastStg string
|
|
||||||
lastTag string
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeDeployer) TriggerDeploy(_ context.Context, projectID, stageID, tag string) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
defer f.mu.Unlock()
|
|
||||||
f.calls++
|
|
||||||
f.lastProj = projectID
|
|
||||||
f.lastStg = stageID
|
|
||||||
f.lastTag = tag
|
|
||||||
return f.err
|
|
||||||
}
|
|
||||||
|
|
||||||
// fakeSiteTriggerer records Deploy calls.
|
|
||||||
type fakeSiteTriggerer struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
calls int
|
|
||||||
done chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *fakeSiteTriggerer) Deploy(_ context.Context, _ string, _ bool) error {
|
|
||||||
f.mu.Lock()
|
|
||||||
f.calls++
|
|
||||||
ch := f.done
|
|
||||||
f.mu.Unlock()
|
|
||||||
if ch != nil {
|
|
||||||
select {
|
|
||||||
case ch <- struct{}{}:
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRouter(t *testing.T, h *webhook.Handler) chi.Router {
|
|
||||||
t.Helper()
|
|
||||||
r := chi.NewRouter()
|
|
||||||
r.Mount("/api/webhook", h.Route())
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func newStore(t *testing.T) *store.Store {
|
|
||||||
t.Helper()
|
|
||||||
s, err := store.New(":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create store: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { s.Close() })
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func doJSON(t *testing.T, r chi.Router, method, path, body string) (*http.Response, string) {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(method, path, strings.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
resp := w.Result()
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp, string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_UnknownSecretReturns404(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
h := webhook.NewHandler(st, &fakeDeployer{}, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/bogus-secret", `{"image":"x"}`)
|
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
|
||||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_DeploysOnMatchingStage(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
|
|
||||||
p, err := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create project: %v", err)
|
|
||||||
}
|
|
||||||
stage, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "dev-*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create stage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
path := "/api/webhook/" + p.WebhookSecret
|
|
||||||
resp, body := doJSON(t, r, http.MethodPost, path, `{"image":"alexei/app:dev-abc"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
if dep.calls != 1 {
|
|
||||||
t.Fatalf("expected 1 deploy call, got %d", dep.calls)
|
|
||||||
}
|
|
||||||
if dep.lastProj != p.ID || dep.lastStg != stage.ID || dep.lastTag != "dev-abc" {
|
|
||||||
t.Errorf("deploy called with wrong args: proj=%s stage=%s tag=%s",
|
|
||||||
dep.lastProj, dep.lastStg, dep.lastTag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_ImageMismatchRejected(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, err := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create project: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create stage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
|
||||||
`{"image":"otheruser/other:dev"}`)
|
|
||||||
if resp.StatusCode != http.StatusBadRequest {
|
|
||||||
t.Errorf("expected 400 on image mismatch, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if dep.calls != 0 {
|
|
||||||
t.Errorf("deploy should not have been triggered on image mismatch")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_NoMatchingStageReturns200NoDeploy(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, err := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create project: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "prod", TagPattern: "v*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("create stage: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, body := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
|
||||||
`{"image":"alexei/app:dev-abc"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
if dep.calls != 0 {
|
|
||||||
t.Errorf("expected no deploy call, got %d", dep.calls)
|
|
||||||
}
|
|
||||||
var parsed map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
|
|
||||||
t.Fatalf("response is not JSON: %v", err)
|
|
||||||
}
|
|
||||||
if parsed["deploy"] != false {
|
|
||||||
t.Errorf("expected deploy=false, got %v", parsed["deploy"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_AutoDeployDisabled(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, _ := st.CreateProject(store.Project{Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}"})
|
|
||||||
_, _ = st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: false, MaxInstances: 1,
|
|
||||||
})
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
|
||||||
`{"image":"alexei/app:dev-1"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if dep.calls != 0 {
|
|
||||||
t.Errorf("auto_deploy=false should suppress deploy call; got %d", dep.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteWebhook_UnknownSecretReturns404(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
h := webhook.NewHandler(st, &fakeDeployer{}, &fakeSiteTriggerer{})
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/sites/bogus", "{}")
|
|
||||||
if resp.StatusCode != http.StatusNotFound {
|
|
||||||
t.Errorf("expected 404, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteWebhook_ManualTriggerShortCircuits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
site, err := st.CreateStaticSite(store.StaticSite{
|
|
||||||
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
|
||||||
Branch: "main", SyncTrigger: "manual", Status: "idle",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create site: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ft := &fakeSiteTriggerer{}
|
|
||||||
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost,
|
|
||||||
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if ft.calls != 0 {
|
|
||||||
t.Errorf("manual-trigger site must not invoke sync; got %d calls", ft.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteWebhook_PushTriggersSyncOnBranchMatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
site, err := st.CreateStaticSite(store.StaticSite{
|
|
||||||
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
|
||||||
Branch: "main", SyncTrigger: "push", Status: "idle",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("create site: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ft := &fakeSiteTriggerer{done: make(chan struct{}, 1)}
|
|
||||||
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, body := doJSON(t, r, http.MethodPost,
|
|
||||||
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/main"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync runs in a goroutine — wait for the signal.
|
|
||||||
<-ft.done
|
|
||||||
ft.mu.Lock()
|
|
||||||
calls := ft.calls
|
|
||||||
ft.mu.Unlock()
|
|
||||||
if calls != 1 {
|
|
||||||
t.Errorf("expected 1 sync call, got %d", calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteWebhook_PushSkippedForNonMatchingBranch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
site, _ := st.CreateStaticSite(store.StaticSite{
|
|
||||||
Name: "docs", GiteaURL: "https://git.example", RepoOwner: "x", RepoName: "y",
|
|
||||||
Branch: "main", SyncTrigger: "push", Status: "idle",
|
|
||||||
})
|
|
||||||
|
|
||||||
ft := &fakeSiteTriggerer{}
|
|
||||||
h := webhook.NewHandler(st, &fakeDeployer{}, ft)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost,
|
|
||||||
"/api/webhook/sites/"+site.WebhookSecret, `{"ref":"refs/heads/feature-x"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if ft.calls != 0 {
|
|
||||||
t.Errorf("non-matching branch must not trigger sync; got %d calls", ft.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// HMAC enforcement scenarios.
|
|
||||||
|
|
||||||
func TestProjectWebhook_HMACRequiredAndValid(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, _ := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if _, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
const sig = "deadbeef-signing-secret-1234567890abcdef"
|
|
||||||
if err := st.SetProjectWebhookSigningSecret(p.ID, sig); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
body := `{"image":"alexei/app:dev-abc"}`
|
|
||||||
resp, msg := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, body, sig)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 with valid sig, got %d: %s", resp.StatusCode, msg)
|
|
||||||
}
|
|
||||||
if dep.calls != 1 {
|
|
||||||
t.Errorf("valid signed deploy should fire once, got %d", dep.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_HMACRequiredButMissing(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, _ := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if _, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := st.SetProjectWebhookSigningSecret(p.ID, "abc-signing-secret-12345678901234567890"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := st.SetProjectWebhookRequireSignature(p.ID, true); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-abc"}`)
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("missing signature must return 401 when required, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if dep.calls != 0 {
|
|
||||||
t.Errorf("deploy must not fire when required signature is missing")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_HMACPresentButWrong(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, _ := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if _, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := st.SetProjectWebhookSigningSecret(p.ID, "real-signing-secret-1234567890abcdef"); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
// Note: require_signature stays false — but a wrong sig must still 401.
|
|
||||||
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
|
|
||||||
resp, _ := doJSONSigned(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret,
|
|
||||||
`{"image":"alexei/app:dev-abc"}`, "wrong-secret-xxxxxxxxxxxxxxxxxxxxxxxxxxxx")
|
|
||||||
if resp.StatusCode != http.StatusUnauthorized {
|
|
||||||
t.Fatalf("wrong signature must 401, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if dep.calls != 0 {
|
|
||||||
t.Errorf("deploy must not fire on wrong signature")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestProjectWebhook_HMACOptionalUnsignedAccepted(t *testing.T) {
|
|
||||||
// require_signature=false AND signing_secret="": unsigned requests pass.
|
|
||||||
t.Parallel()
|
|
||||||
st := newStore(t)
|
|
||||||
p, _ := st.CreateProject(store.Project{
|
|
||||||
Name: "app", Image: "alexei/app", Env: "{}", Volumes: "{}",
|
|
||||||
})
|
|
||||||
if _, err := st.CreateStage(store.Stage{
|
|
||||||
ProjectID: p.ID, Name: "dev", TagPattern: "*", AutoDeploy: true, MaxInstances: 1,
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
dep := &fakeDeployer{}
|
|
||||||
h := webhook.NewHandler(st, dep, nil)
|
|
||||||
r := newRouter(t, h)
|
|
||||||
resp, _ := doJSON(t, r, http.MethodPost, "/api/webhook/"+p.WebhookSecret, `{"image":"alexei/app:dev-x"}`)
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
t.Fatalf("unsigned + unconfigured should pass, got %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
if dep.calls != 1 {
|
|
||||||
t.Errorf("expected 1 deploy, got %d", dep.calls)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"path"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
// matchStage finds the first stage of a project whose tag pattern matches the
|
|
||||||
// given tag. Uses path.Match for glob-style matching (same as the registry poller).
|
|
||||||
func matchStage(st *store.Store, projectID, tag string) (store.Stage, bool, error) {
|
|
||||||
stages, err := st.GetStagesByProjectID(projectID)
|
|
||||||
if err != nil {
|
|
||||||
return store.Stage{}, false, fmt.Errorf("get stages: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, stage := range stages {
|
|
||||||
pattern := stage.TagPattern
|
|
||||||
if pattern == "" {
|
|
||||||
pattern = "*"
|
|
||||||
}
|
|
||||||
|
|
||||||
matched, err := path.Match(pattern, tag)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("webhook: invalid tag pattern, skipping stage",
|
|
||||||
"project", projectID, "stage", stage.Name, "pattern", pattern, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if matched {
|
|
||||||
return stage, true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return store.Stage{}, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// imageMatches reports whether an incoming image reference matches the
|
|
||||||
// project's stored image. The registry hostname is matched case-insensitively
|
|
||||||
// (per RFC: registry hostnames are case-insensitive); the path/owner/name are
|
|
||||||
// matched exactly.
|
|
||||||
func imageMatches(projectImage, incomingImage string) bool {
|
|
||||||
if projectImage == incomingImage {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
pIdx := strings.IndexByte(projectImage, '/')
|
|
||||||
iIdx := strings.IndexByte(incomingImage, '/')
|
|
||||||
if pIdx <= 0 || iIdx <= 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pHost, pPath := projectImage[:pIdx], projectImage[pIdx:]
|
|
||||||
iHost, iPath := incomingImage[:iIdx], incomingImage[iIdx:]
|
|
||||||
return strings.EqualFold(pHost, iHost) && pPath == iPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// siteRefMatches reports whether a Git ref (e.g. "refs/heads/main" or
|
|
||||||
// "refs/tags/v1.2.3") targets the site's configured branch or tag pattern.
|
|
||||||
//
|
|
||||||
// For sync_trigger = "push": the ref must be a heads/<branch> ref whose
|
|
||||||
// branch name equals site.Branch.
|
|
||||||
// For sync_trigger = "tag": the ref must be a tags/<tag> ref whose tag name
|
|
||||||
// matches site.TagPattern via glob semantics.
|
|
||||||
// Unknown triggers return false (caller should have filtered these out).
|
|
||||||
func siteRefMatches(site store.StaticSite, ref string) bool {
|
|
||||||
switch site.SyncTrigger {
|
|
||||||
case "push":
|
|
||||||
branch, ok := strings.CutPrefix(ref, "refs/heads/")
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if site.Branch == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return branch == site.Branch
|
|
||||||
case "tag":
|
|
||||||
tag, ok := strings.CutPrefix(ref, "refs/tags/")
|
|
||||||
if !ok {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
pattern := site.TagPattern
|
|
||||||
if pattern == "" {
|
|
||||||
pattern = "*"
|
|
||||||
}
|
|
||||||
matched, err := path.Match(pattern, tag)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return matched
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/alexei/tinyforge/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestSiteRefMatches_Push(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
site := store.StaticSite{SyncTrigger: "push", Branch: "main"}
|
|
||||||
cases := []struct {
|
|
||||||
ref string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"refs/heads/main", true},
|
|
||||||
{"refs/heads/develop", false},
|
|
||||||
{"refs/tags/v1.0.0", false},
|
|
||||||
{"", false},
|
|
||||||
{"main", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := siteRefMatches(site, tc.ref); got != tc.want {
|
|
||||||
t.Errorf("siteRefMatches(push, %q) = %v; want %v", tc.ref, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteRefMatches_PushEmptyBranchAcceptsAny(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
// When Branch is unset, any heads ref should match — tolerates the sites
|
|
||||||
// table having blank Branch values from legacy rows.
|
|
||||||
site := store.StaticSite{SyncTrigger: "push"}
|
|
||||||
if !siteRefMatches(site, "refs/heads/whatever") {
|
|
||||||
t.Error("expected empty Branch to accept any heads ref")
|
|
||||||
}
|
|
||||||
if siteRefMatches(site, "refs/tags/v1") {
|
|
||||||
t.Error("empty Branch must still reject tag refs")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteRefMatches_Tag(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
site := store.StaticSite{SyncTrigger: "tag", TagPattern: "v*"}
|
|
||||||
cases := []struct {
|
|
||||||
ref string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"refs/tags/v1.0.0", true},
|
|
||||||
{"refs/tags/v2", true},
|
|
||||||
{"refs/tags/hotfix", false},
|
|
||||||
{"refs/heads/main", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
if got := siteRefMatches(site, tc.ref); got != tc.want {
|
|
||||||
t.Errorf("siteRefMatches(tag, %q) = %v; want %v", tc.ref, got, tc.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSiteRefMatches_ManualIsIgnored(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
site := store.StaticSite{SyncTrigger: "manual", Branch: "main"}
|
|
||||||
if siteRefMatches(site, "refs/heads/main") {
|
|
||||||
t.Error("manual trigger must never match any ref — caller short-circuits")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseImageRef(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
cases := []struct {
|
|
||||||
in string
|
|
||||||
wantFull string
|
|
||||||
wantTag string
|
|
||||||
}{
|
|
||||||
{"registry.example.com/alexei/app:v1", "alexei/app", "v1"},
|
|
||||||
{"alexei/app:dev", "alexei/app", "dev"},
|
|
||||||
{"app", "app", ""},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
got, err := ParseImageRef(tc.in)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("ParseImageRef(%q) unexpected error: %v", tc.in, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if got.FullName() != tc.wantFull || got.Tag != tc.wantTag {
|
|
||||||
t.Errorf("ParseImageRef(%q) = %q:%q; want %q:%q",
|
|
||||||
tc.in, got.FullName(), got.Tag, tc.wantFull, tc.wantTag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseImageRef_Empty(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
if _, err := ParseImageRef(""); err == nil {
|
|
||||||
t.Error("expected error for empty image ref")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+75
-552
@@ -8,27 +8,19 @@ import type {
|
|||||||
SystemStats,
|
SystemStats,
|
||||||
SystemStatsSample,
|
SystemStatsSample,
|
||||||
TopContainerSample,
|
TopContainerSample,
|
||||||
Deploy,
|
|
||||||
DeployLog,
|
|
||||||
DockerHealth,
|
DockerHealth,
|
||||||
ProxyHealth,
|
ProxyHealth,
|
||||||
EventLogEntry,
|
EventLogEntry,
|
||||||
EventLogStats,
|
EventLogStats,
|
||||||
InspectResult,
|
InspectResult,
|
||||||
Instance,
|
|
||||||
LocalImage,
|
LocalImage,
|
||||||
NpmCertificate,
|
NpmCertificate,
|
||||||
NpmAccessList,
|
NpmAccessList,
|
||||||
ProxyRoute,
|
ProxyRoute,
|
||||||
Project,
|
|
||||||
ProjectDetail,
|
|
||||||
Registry,
|
Registry,
|
||||||
RegistryImage,
|
RegistryImage,
|
||||||
Settings,
|
Settings,
|
||||||
StaleContainer,
|
StaleContainer,
|
||||||
Stage,
|
|
||||||
StageEnv,
|
|
||||||
Volume,
|
|
||||||
VolumeScopeInfo,
|
VolumeScopeInfo,
|
||||||
BrowseResult,
|
BrowseResult,
|
||||||
DnsZone,
|
DnsZone,
|
||||||
@@ -174,125 +166,15 @@ function patch<T>(path: string, body: unknown): Promise<T> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Projects ────────────────────────────────────────────────────────
|
// ── Deploys (inspect only; quick-deploy retired with /deploy page) ────
|
||||||
|
// `inspectImage` survives because the new-app wizard can use it to pre-fill
|
||||||
export function listProjects(signal?: AbortSignal): Promise<Project[]> {
|
// image port/healthcheck. `quickDeploy` (POST /api/deploy/quick) is gone:
|
||||||
return get<Project[]>('/api/projects', signal);
|
// it created a legacy Project + Stage in the now-dead path.
|
||||||
}
|
|
||||||
|
|
||||||
export function getProject(id: string, signal?: AbortSignal): Promise<ProjectDetail> {
|
|
||||||
return get<ProjectDetail>(`/api/projects/${id}`, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createProject(data: Partial<Project>): Promise<Project> {
|
|
||||||
return post<Project>('/api/projects', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateProject(id: string, data: Partial<Project>): Promise<Project> {
|
|
||||||
return put<Project>(`/api/projects/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteProject(id: string): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(`/api/projects/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Stages ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function createStage(projectId: string, data: Partial<Stage>): Promise<Stage> {
|
|
||||||
return post<Stage>(`/api/projects/${projectId}/stages`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateStage(projectId: string, stageId: string, data: Partial<Stage>): Promise<Stage> {
|
|
||||||
return put<Stage>(`/api/projects/${projectId}/stages/${stageId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteStage(projectId: string, stageId: string): Promise<void> {
|
|
||||||
return del<void>(`/api/projects/${projectId}/stages/${stageId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Instances ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function listInstances(projectId: string, stageId: string, signal?: AbortSignal): Promise<Instance[]> {
|
|
||||||
return get<Instance[]>(`/api/projects/${projectId}/stages/${stageId}/instances`, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deployInstance(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
imageTag: string
|
|
||||||
): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(`/api/projects/${projectId}/stages/${stageId}/instances`, {
|
|
||||||
image_tag: imageTag
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeInstance(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
instanceId: string
|
|
||||||
): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(
|
|
||||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopInstance(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
instanceId: string
|
|
||||||
): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(
|
|
||||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stop`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startInstance(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
instanceId: string
|
|
||||||
): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(
|
|
||||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/start`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function restartInstance(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
instanceId: string
|
|
||||||
): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(
|
|
||||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/restart`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Deploys ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function listDeploys(limit = 50, signal?: AbortSignal): Promise<Deploy[]> {
|
|
||||||
return get<Deploy[]>(`/api/deploys?limit=${limit}`, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getDeployLogs(deployId: string): Promise<DeployLog[]> {
|
|
||||||
return get<DeployLog[]>(`/api/deploys/${deployId}/logs`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inspectImage(image: string): Promise<InspectResult> {
|
export function inspectImage(image: string): Promise<InspectResult> {
|
||||||
return post<InspectResult>('/api/deploy/inspect', { image });
|
return post<InspectResult>('/api/deploy/inspect', { image });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function quickDeploy(data: {
|
|
||||||
name?: string;
|
|
||||||
image: string;
|
|
||||||
tag?: string;
|
|
||||||
registry?: string;
|
|
||||||
port?: number;
|
|
||||||
force?: boolean;
|
|
||||||
enable_proxy?: boolean;
|
|
||||||
auto_deploy?: boolean;
|
|
||||||
}): Promise<{ project: Project; status: string }> {
|
|
||||||
return post<{ project: Project; status: string }>('/api/deploy/quick', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Registries ──────────────────────────────────────────────────────
|
// ── Registries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function listRegistries(): Promise<Registry[]> {
|
export function listRegistries(): Promise<Registry[]> {
|
||||||
@@ -335,7 +217,8 @@ export function updateSettings(data: Partial<Settings>): Promise<Settings> {
|
|||||||
return put<Settings>('/api/settings', data);
|
return put<Settings>('/api/settings', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Webhooks ───────────────────────────────────────────────────────
|
// ── Webhook envelopes ──────────────────────────────────────────────
|
||||||
|
// These shapes are reused by the workload + trigger webhook flows.
|
||||||
|
|
||||||
export interface WebhookUrlResponse {
|
export interface WebhookUrlResponse {
|
||||||
webhook_url: string;
|
webhook_url: string;
|
||||||
@@ -348,49 +231,9 @@ export interface SigningSecretResponse {
|
|||||||
signing_secret: string;
|
signing_secret: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
|
||||||
return get<WebhookUrlResponse>(`/api/projects/${projectId}/webhook`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regenerateProjectWebhook(projectId: string): Promise<WebhookUrlResponse> {
|
|
||||||
return post<WebhookUrlResponse>(`/api/projects/${projectId}/webhook/regenerate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regenerateProjectSigningSecret(projectId: string): Promise<SigningSecretResponse> {
|
|
||||||
return post<SigningSecretResponse>(`/api/projects/${projectId}/webhook/signing-secret/regenerate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disableProjectSigningSecret(projectId: string): Promise<void> {
|
|
||||||
await del<void>(`/api/projects/${projectId}/webhook/signing-secret`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setProjectRequireSignature(projectId: string, require: boolean): Promise<void> {
|
|
||||||
await put<void>(`/api/projects/${projectId}/webhook/require-signature`, { require_signature: require });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
|
||||||
return get<WebhookUrlResponse>(`/api/sites/${siteId}/webhook`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regenerateStaticSiteWebhook(siteId: string): Promise<WebhookUrlResponse> {
|
|
||||||
return post<WebhookUrlResponse>(`/api/sites/${siteId}/webhook/regenerate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regenerateStaticSiteSigningSecret(siteId: string): Promise<SigningSecretResponse> {
|
|
||||||
return post<SigningSecretResponse>(`/api/sites/${siteId}/webhook/signing-secret/regenerate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function disableStaticSiteSigningSecret(siteId: string): Promise<void> {
|
|
||||||
await del<void>(`/api/sites/${siteId}/webhook/signing-secret`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function setStaticSiteRequireSignature(siteId: string, require: boolean): Promise<void> {
|
|
||||||
await put<void>(`/api/sites/${siteId}/webhook/require-signature`, { require_signature: require });
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WebhookDelivery {
|
export interface WebhookDelivery {
|
||||||
id: number;
|
id: number;
|
||||||
target_type: 'project' | 'site';
|
target_type: 'project' | 'site' | 'workload' | 'trigger';
|
||||||
target_id: string;
|
target_id: string;
|
||||||
target_name: string;
|
target_name: string;
|
||||||
received_at: string;
|
received_at: string;
|
||||||
@@ -402,15 +245,10 @@ export interface WebhookDelivery {
|
|||||||
body_size: number;
|
body_size: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listProjectWebhookDeliveries(projectId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
|
// ── Outgoing-webhook signing & test (settings tier only) ───────────
|
||||||
return get<WebhookDelivery[]>(`/api/projects/${projectId}/webhook/deliveries`, signal);
|
// Per-project, per-stage, per-site tiers were dropped with the legacy
|
||||||
}
|
// endpoints. Per-workload signing is exposed via the workload webhook
|
||||||
|
// flow below.
|
||||||
export function listStaticSiteWebhookDeliveries(siteId: string, signal?: AbortSignal): Promise<WebhookDelivery[]> {
|
|
||||||
return get<WebhookDelivery[]>(`/api/sites/${siteId}/webhook/deliveries`, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Outgoing-webhook signing & test ────────────────────────────────
|
|
||||||
|
|
||||||
export interface NotificationSecretResponse {
|
export interface NotificationSecretResponse {
|
||||||
secret: string;
|
secret: string;
|
||||||
@@ -419,7 +257,7 @@ export interface NotificationSecretResponse {
|
|||||||
|
|
||||||
export interface NotificationTestResult {
|
export interface NotificationTestResult {
|
||||||
url: string;
|
url: string;
|
||||||
tier: 'settings' | 'project' | 'stage' | 'site';
|
tier: 'settings' | 'project' | 'stage' | 'site' | 'workload' | 'trigger';
|
||||||
status_code: number;
|
status_code: number;
|
||||||
latency_ms: number;
|
latency_ms: number;
|
||||||
signature_sent: boolean;
|
signature_sent: boolean;
|
||||||
@@ -428,7 +266,6 @@ export interface NotificationTestResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings (global) tier.
|
|
||||||
export function getSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
export function getSettingsNotificationSecret(): Promise<NotificationSecretResponse> {
|
||||||
return get<NotificationSecretResponse>('/api/settings/notification-secret');
|
return get<NotificationSecretResponse>('/api/settings/notification-secret');
|
||||||
}
|
}
|
||||||
@@ -442,66 +279,14 @@ export function testSettingsNotification(): Promise<NotificationTestResult> {
|
|||||||
return post<NotificationTestResult>('/api/settings/notification-test');
|
return post<NotificationTestResult>('/api/settings/notification-test');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Project tier.
|
|
||||||
export function getProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return get<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret`);
|
|
||||||
}
|
|
||||||
export function regenerateProjectNotificationSecret(projectId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/regenerate`);
|
|
||||||
}
|
|
||||||
export function disableProjectNotificationSigning(projectId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/notification-secret/disable`);
|
|
||||||
}
|
|
||||||
export function testProjectNotification(projectId: string): Promise<NotificationTestResult> {
|
|
||||||
return post<NotificationTestResult>(`/api/projects/${projectId}/notification-test`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stage tier.
|
|
||||||
export function getStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return get<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret`);
|
|
||||||
}
|
|
||||||
export function regenerateStageNotificationSecret(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/regenerate`);
|
|
||||||
}
|
|
||||||
export function disableStageNotificationSigning(projectId: string, stageId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return post<NotificationSecretResponse>(`/api/projects/${projectId}/stages/${stageId}/notification-secret/disable`);
|
|
||||||
}
|
|
||||||
export function testStageNotification(projectId: string, stageId: string): Promise<NotificationTestResult> {
|
|
||||||
return post<NotificationTestResult>(`/api/projects/${projectId}/stages/${stageId}/notification-test`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static-site tier.
|
|
||||||
export function getStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return get<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret`);
|
|
||||||
}
|
|
||||||
export function regenerateStaticSiteNotificationSecret(siteId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/regenerate`);
|
|
||||||
}
|
|
||||||
export function disableStaticSiteNotificationSigning(siteId: string): Promise<NotificationSecretResponse> {
|
|
||||||
return post<NotificationSecretResponse>(`/api/sites/${siteId}/notification-secret/disable`);
|
|
||||||
}
|
|
||||||
export function testStaticSiteNotification(siteId: string): Promise<NotificationTestResult> {
|
|
||||||
return post<NotificationTestResult>(`/api/sites/${siteId}/notification-test`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Proxy Routes ───────────────────────────────────────────────────
|
// ── Proxy Routes ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function listProxyRoutes(): Promise<ProxyRoute[]> {
|
export function listProxyRoutes(signal?: AbortSignal): Promise<ProxyRoute[]> {
|
||||||
return get<ProxyRoute[]>('/api/proxies');
|
return get<ProxyRoute[]>('/api/proxies', signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Docker Management ──────────────────────────────────────────────
|
// ── Docker Management ──────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchContainerLogs(
|
|
||||||
projectId: string, stageId: string, instanceId: string, tail = 200
|
|
||||||
): Promise<string[]> {
|
|
||||||
return get<string[]>(`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/logs?tail=${tail}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listProjectImages(projectId: string, signal?: AbortSignal): Promise<LocalImage[]> {
|
|
||||||
return get<LocalImage[]>(`/api/projects/${projectId}/images`, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getUnusedImageStats(signal?: AbortSignal): Promise<{
|
export function getUnusedImageStats(signal?: AbortSignal): Promise<{
|
||||||
total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean;
|
total_size_mb: number; count: number; threshold_mb: number; exceeded: boolean;
|
||||||
}> {
|
}> {
|
||||||
@@ -524,6 +309,15 @@ export function listNpmAccessLists(): Promise<NpmAccessList[]> {
|
|||||||
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
return get<NpmAccessList[]>('/api/settings/npm-access-lists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Volume scopes (metadata only) ───────────────────────────────────
|
||||||
|
// Per-project volume CRUD endpoints died with the legacy routes; the
|
||||||
|
// workload volume endpoints cover the new path. Scope metadata stays
|
||||||
|
// because the volume editor for workloads still needs it.
|
||||||
|
|
||||||
|
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
|
||||||
|
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
|
||||||
|
}
|
||||||
|
|
||||||
// ── DNS ────────────────────────────────────────────────────────────
|
// ── DNS ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> {
|
export function testDnsConnection(provider: string, token: string, zoneId: string): Promise<{ success: boolean; error?: string }> {
|
||||||
@@ -635,107 +429,48 @@ export function exportConfigUrl(): string {
|
|||||||
return '/api/config/export';
|
return '/api/config/export';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Stage Env Overrides ──────────────────────────────────────────────
|
// ── Workload volume browse / download / upload ─────────────────────
|
||||||
|
// The browse/download/upload helpers now target /api/workloads/{id}
|
||||||
export function listStageEnv(projectId: string, stageId: string): Promise<StageEnv[]> {
|
// instead of the deleted project-scoped path. Source/path/scope params
|
||||||
return get<StageEnv[]>(`/api/projects/${projectId}/stages/${stageId}/env`);
|
// retain the same query keys for compatibility with the volume editor.
|
||||||
}
|
|
||||||
|
|
||||||
export function createStageEnv(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
data: { key: string; value: string; encrypted?: boolean }
|
|
||||||
): Promise<StageEnv> {
|
|
||||||
return post<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateStageEnv(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
envId: string,
|
|
||||||
data: { key?: string; value?: string; encrypted?: boolean }
|
|
||||||
): Promise<StageEnv> {
|
|
||||||
return put<StageEnv>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteStageEnv(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
envId: string
|
|
||||||
): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(`/api/projects/${projectId}/stages/${stageId}/env/${envId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Volumes ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function listVolumes(projectId: string): Promise<Volume[]> {
|
|
||||||
return get<Volume[]>(`/api/projects/${projectId}/volumes`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createVolume(
|
|
||||||
projectId: string,
|
|
||||||
data: { source: string; target: string; scope: string; name?: string; mode?: string }
|
|
||||||
): Promise<Volume> {
|
|
||||||
return post<Volume>(`/api/projects/${projectId}/volumes`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateVolume(
|
|
||||||
projectId: string,
|
|
||||||
volId: string,
|
|
||||||
data: { source?: string; target?: string; scope?: string; name?: string; mode?: string }
|
|
||||||
): Promise<Volume> {
|
|
||||||
return put<Volume>(`/api/projects/${projectId}/volumes/${volId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listVolumeScopes(): Promise<VolumeScopeInfo[]> {
|
|
||||||
return get<VolumeScopeInfo[]>('/api/volumes/scopes');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteVolume(
|
|
||||||
projectId: string,
|
|
||||||
volId: string
|
|
||||||
): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(`/api/projects/${projectId}/volumes/${volId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function browseVolume(
|
export function browseVolume(
|
||||||
projectId: string,
|
workloadId: string,
|
||||||
volId: string,
|
volId: string,
|
||||||
params?: { path?: string; stage?: string; tag?: string }
|
params?: { path?: string; reference?: string }
|
||||||
): Promise<BrowseResult> {
|
): Promise<BrowseResult> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.path) query.set('path', params.path);
|
if (params?.path) query.set('path', params.path);
|
||||||
if (params?.stage) query.set('stage', params.stage);
|
if (params?.reference) query.set('reference', params.reference);
|
||||||
if (params?.tag) query.set('tag', params.tag);
|
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return get<BrowseResult>(`/api/projects/${projectId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`);
|
return get<BrowseResult>(
|
||||||
|
`/api/workloads/${workloadId}/volumes/${volId}/browse${qs ? `?${qs}` : ''}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function volumeDownloadUrl(
|
export function volumeDownloadUrl(
|
||||||
projectId: string,
|
workloadId: string,
|
||||||
volId: string,
|
volId: string,
|
||||||
params?: { path?: string; stage?: string; tag?: string }
|
params?: { path?: string; reference?: string }
|
||||||
): string {
|
): string {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.path) query.set('path', params.path);
|
if (params?.path) query.set('path', params.path);
|
||||||
if (params?.stage) query.set('stage', params.stage);
|
if (params?.reference) query.set('reference', params.reference);
|
||||||
if (params?.tag) query.set('tag', params.tag);
|
|
||||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('auth_token') : null;
|
||||||
if (token) query.set('token', token);
|
if (token) query.set('token', token);
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
return `/api/projects/${projectId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
|
return `/api/workloads/${workloadId}/volumes/${volId}/download${qs ? `?${qs}` : ''}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function uploadToVolume(
|
export async function uploadToVolume(
|
||||||
projectId: string,
|
workloadId: string,
|
||||||
volId: string,
|
volId: string,
|
||||||
files: FileList,
|
files: FileList,
|
||||||
params?: { path?: string; stage?: string; tag?: string }
|
params?: { path?: string; reference?: string }
|
||||||
): Promise<{ uploaded: string[]; count: number }> {
|
): Promise<{ uploaded: string[]; count: number }> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
if (params?.path) query.set('path', params.path);
|
if (params?.path) query.set('path', params.path);
|
||||||
if (params?.stage) query.set('stage', params.stage);
|
if (params?.reference) query.set('reference', params.reference);
|
||||||
if (params?.tag) query.set('tag', params.tag);
|
|
||||||
const qs = query.toString();
|
const qs = query.toString();
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -747,11 +482,14 @@ export async function uploadToVolume(
|
|||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
|
||||||
const res = await fetch(`/api/projects/${projectId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`, {
|
const res = await fetch(
|
||||||
method: 'POST',
|
`/api/workloads/${workloadId}/volumes/${volId}/upload${qs ? `?${qs}` : ''}`,
|
||||||
headers,
|
{
|
||||||
body: formData,
|
method: 'POST',
|
||||||
});
|
headers,
|
||||||
|
body: formData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const envelope = await res.json();
|
const envelope = await res.json();
|
||||||
if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed');
|
if (!envelope.success) throw new Error(envelope.error ?? 'Upload failed');
|
||||||
@@ -779,8 +517,8 @@ export function fetchEventLog(params?: {
|
|||||||
return get<EventLogEntry[]>(`/api/events/log${qs ? `?${qs}` : ''}`);
|
return get<EventLogEntry[]>(`/api/events/log${qs ? `?${qs}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchEventLogStats(): Promise<EventLogStats> {
|
export function fetchEventLogStats(signal?: AbortSignal): Promise<EventLogStats> {
|
||||||
return get<EventLogStats>('/api/events/log/stats');
|
return get<EventLogStats>('/api/events/log/stats', signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteEvent(id: number): Promise<{ status: string }> {
|
export function deleteEvent(id: number): Promise<{ status: string }> {
|
||||||
@@ -805,32 +543,7 @@ export function bulkCleanupStaleContainers(): Promise<{ deleted: number }> {
|
|||||||
return post<{ deleted: number }>('/api/containers/stale/cleanup');
|
return post<{ deleted: number }>('/api/containers/stale/cleanup');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Container Stats ────────────────────────────────────────────────
|
// ── System Stats ───────────────────────────────────────────────────
|
||||||
|
|
||||||
export function fetchContainerStats(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
instanceId: string,
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<ContainerStats> {
|
|
||||||
return get<ContainerStats>(
|
|
||||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats`,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchInstanceStatsHistory(
|
|
||||||
projectId: string,
|
|
||||||
stageId: string,
|
|
||||||
instanceId: string,
|
|
||||||
window = '2h',
|
|
||||||
signal?: AbortSignal
|
|
||||||
): Promise<ContainerStatsSample[]> {
|
|
||||||
return get<ContainerStatsSample[]>(
|
|
||||||
`/api/projects/${projectId}/stages/${stageId}/instances/${instanceId}/stats/history?window=${encodeURIComponent(window)}`,
|
|
||||||
signal
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchSystemStats(signal?: AbortSignal): Promise<SystemStats> {
|
export function fetchSystemStats(signal?: AbortSignal): Promise<SystemStats> {
|
||||||
return get<SystemStats>('/api/system/stats', signal);
|
return get<SystemStats>('/api/system/stats', signal);
|
||||||
@@ -851,212 +564,32 @@ export function fetchTopContainers(
|
|||||||
return get<TopContainerSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
|
return get<TopContainerSample[]>(`/api/system/stats/top?by=${by}&limit=${limit}`, signal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchStaticSiteStats(id: string, signal?: AbortSignal): Promise<ContainerStats> {
|
// ── Per-container stats (workload-scoped) ──────────────────────────
|
||||||
return get<ContainerStats>(`/api/sites/${id}/stats`, signal);
|
// Project / stage / instance + static-site stats endpoints died with
|
||||||
}
|
// the legacy routes. Use the workload-scoped endpoint for any per-
|
||||||
|
// container CPU/memory drill-down.
|
||||||
|
|
||||||
export function fetchStaticSiteStatsHistory(
|
export function fetchWorkloadContainerStats(
|
||||||
id: string,
|
workloadId: string,
|
||||||
window = '2h',
|
containerRowId: string,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
): Promise<ContainerStatsSample[]> {
|
): Promise<ContainerStats> {
|
||||||
return get<ContainerStatsSample[]>(
|
return get<ContainerStats>(
|
||||||
`/api/sites/${id}/stats/history?window=${encodeURIComponent(window)}`,
|
`/api/workloads/${workloadId}/containers/${containerRowId}/stats`,
|
||||||
signal
|
signal
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStaticSiteLogs(id: string, tail = 200): Promise<string[]> {
|
export function fetchWorkloadContainerStatsHistory(
|
||||||
const result = await get<string[] | null>(`/api/sites/${id}/logs?tail=${tail}`);
|
workloadId: string,
|
||||||
return result ?? [];
|
containerRowId: string,
|
||||||
}
|
window = '2h',
|
||||||
|
signal?: AbortSignal
|
||||||
// ── Static Sites ──────────────────────────────────────────────────────
|
): Promise<ContainerStatsSample[]> {
|
||||||
|
return get<ContainerStatsSample[]>(
|
||||||
import type { StaticSite, StaticSiteSecret, FolderEntry, GitProvider, RepoInfo } from './types';
|
`/api/workloads/${workloadId}/containers/${containerRowId}/stats/history?window=${encodeURIComponent(window)}`,
|
||||||
|
signal
|
||||||
export function listStaticSites(signal?: AbortSignal): Promise<StaticSite[]> {
|
);
|
||||||
return get<StaticSite[]>('/api/sites', signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStaticSite(id: string): Promise<StaticSite> {
|
|
||||||
return get<StaticSite>(`/api/sites/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStaticSite(data: Partial<StaticSite>): Promise<StaticSite> {
|
|
||||||
return post<StaticSite>('/api/sites', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateStaticSite(id: string, data: Partial<StaticSite>): Promise<StaticSite> {
|
|
||||||
return put<StaticSite>(`/api/sites/${id}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteStaticSite(id: string): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(`/api/sites/${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deployStaticSite(id: string): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(`/api/sites/${id}/deploy`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function stopStaticSite(id: string): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(`/api/sites/${id}/stop`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startStaticSite(id: string): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>(`/api/sites/${id}/start`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listStaticSiteRepos(data: {
|
|
||||||
provider?: string;
|
|
||||||
gitea_url: string;
|
|
||||||
access_token?: string;
|
|
||||||
query?: string;
|
|
||||||
}): Promise<RepoInfo[]> {
|
|
||||||
return post<RepoInfo[]>('/api/sites/repos', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function detectStaticSiteProvider(url: string): Promise<{ provider: GitProvider }> {
|
|
||||||
return post<{ provider: GitProvider }>('/api/sites/detect-provider', { url });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function testStaticSiteConnection(data: {
|
|
||||||
provider?: string;
|
|
||||||
gitea_url: string;
|
|
||||||
access_token?: string;
|
|
||||||
repo_owner: string;
|
|
||||||
repo_name: string;
|
|
||||||
}): Promise<{ status: string }> {
|
|
||||||
return post<{ status: string }>('/api/sites/test-connection', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listStaticSiteBranches(data: {
|
|
||||||
provider?: string;
|
|
||||||
gitea_url: string;
|
|
||||||
access_token?: string;
|
|
||||||
repo_owner: string;
|
|
||||||
repo_name: string;
|
|
||||||
}): Promise<string[]> {
|
|
||||||
return post<string[]>('/api/sites/branches', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listStaticSiteTree(data: {
|
|
||||||
provider?: string;
|
|
||||||
gitea_url: string;
|
|
||||||
access_token?: string;
|
|
||||||
repo_owner: string;
|
|
||||||
repo_name: string;
|
|
||||||
branch: string;
|
|
||||||
}): Promise<FolderEntry[]> {
|
|
||||||
return post<FolderEntry[]>('/api/sites/tree', data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listStaticSiteSecrets(siteId: string): Promise<StaticSiteSecret[]> {
|
|
||||||
return get<StaticSiteSecret[]>(`/api/sites/${siteId}/secrets`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStaticSiteSecret(
|
|
||||||
siteId: string,
|
|
||||||
data: { key: string; value: string; encrypted?: boolean }
|
|
||||||
): Promise<StaticSiteSecret> {
|
|
||||||
return post<StaticSiteSecret>(`/api/sites/${siteId}/secrets`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateStaticSiteSecret(
|
|
||||||
siteId: string,
|
|
||||||
secretId: string,
|
|
||||||
data: { key?: string; value?: string; encrypted?: boolean }
|
|
||||||
): Promise<StaticSiteSecret> {
|
|
||||||
return put<StaticSiteSecret>(`/api/sites/${siteId}/secrets/${secretId}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deleteStaticSiteSecret(
|
|
||||||
siteId: string,
|
|
||||||
secretId: string
|
|
||||||
): Promise<{ deleted: string }> {
|
|
||||||
return del<{ deleted: string }>(`/api/sites/${siteId}/secrets/${secretId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStaticSiteStorage(
|
|
||||||
siteId: string
|
|
||||||
): Promise<import('./types').StaticSiteStorageUsage> {
|
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Workloads ───────────────────────────────────────────────────────
|
// ── Workloads ───────────────────────────────────────────────────────
|
||||||
@@ -1126,20 +659,10 @@ export function deleteWorkloadEnv(id: string, envID: string): Promise<{ deleted:
|
|||||||
return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`);
|
return del<{ deleted: string }>(`/api/workloads/${id}/env/${envID}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkloadWebhook {
|
// Workload-level webhook URL accessors were removed in the hard legacy
|
||||||
webhook_url: string;
|
// cutover: inbound webhooks are now first-class Triggers. To wire a
|
||||||
webhook_secret: string;
|
// workload to inbound deploys, create or bind a Trigger via the
|
||||||
has_signing_secret: boolean;
|
// /triggers UI (which mints a /api/webhook/triggers/{secret} URL).
|
||||||
webhook_require_signature: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getWorkloadWebhook(id: string, signal?: AbortSignal): Promise<WorkloadWebhook> {
|
|
||||||
return get<WorkloadWebhook>(`/api/workloads/${id}/webhook`, signal);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function regenerateWorkloadWebhook(id: string): Promise<WorkloadWebhook> {
|
|
||||||
return post<WorkloadWebhook>(`/api/workloads/${id}/webhook/regenerate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchWorkloadContainerLogs(
|
export function fetchWorkloadContainerLogs(
|
||||||
workloadId: string,
|
workloadId: string,
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
<!--
|
<!--
|
||||||
Container log viewer with tail line limit and auto-scroll.
|
Container log viewer with tail line limit and auto-scroll.
|
||||||
|
|
||||||
Works for both project instances and static sites — pass a `source`
|
Workload-scoped only after the hard cutover. The legacy `instance` and
|
||||||
discriminated union to point at the right endpoint.
|
`site` source variants targeted /api/projects/.../logs and
|
||||||
|
/api/sites/.../logs respectively — both gone with their routes.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy } from 'svelte';
|
import { onDestroy } from 'svelte';
|
||||||
import {
|
import { fetchWorkloadContainerLogs } from '$lib/api';
|
||||||
fetchContainerLogs,
|
|
||||||
fetchStaticSiteLogs,
|
|
||||||
fetchWorkloadContainerLogs
|
|
||||||
} from '$lib/api';
|
|
||||||
import { getAuthToken } from '$lib/auth';
|
import { getAuthToken } from '$lib/auth';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { IconLoader, IconX } from '$lib/components/icons';
|
import { IconLoader, IconX } from '$lib/components/icons';
|
||||||
|
|
||||||
export type LogSource =
|
export type LogSource = {
|
||||||
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
|
kind: 'workload';
|
||||||
| { kind: 'site'; siteId: string }
|
workloadId: string;
|
||||||
| { kind: 'workload'; workloadId: string; containerRowId: string };
|
containerRowId: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source: LogSource;
|
source: LogSource;
|
||||||
@@ -60,23 +58,11 @@
|
|||||||
|
|
||||||
function buildFollowUrl(token: string | null): string {
|
function buildFollowUrl(token: string | null): string {
|
||||||
const tokenParam = token ? `&token=${token}` : '';
|
const tokenParam = token ? `&token=${token}` : '';
|
||||||
if (source.kind === 'instance') {
|
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
|
||||||
return `/api/projects/${source.projectId}/stages/${source.stageId}/instances/${source.instanceId}/logs?follow=true&tail=0${tokenParam}`;
|
|
||||||
}
|
|
||||||
if (source.kind === 'workload') {
|
|
||||||
return `/api/workloads/${source.workloadId}/containers/${source.containerRowId}/logs?follow=true&tail=0${tokenParam}`;
|
|
||||||
}
|
|
||||||
return `/api/sites/${source.siteId}/logs?follow=true&tail=0${tokenParam}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchLogs(tail: number): Promise<string[]> {
|
async function fetchLogs(tail: number): Promise<string[]> {
|
||||||
if (source.kind === 'instance') {
|
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
|
||||||
return fetchContainerLogs(source.projectId, source.stageId, source.instanceId, tail);
|
|
||||||
}
|
|
||||||
if (source.kind === 'workload') {
|
|
||||||
return fetchWorkloadContainerLogs(source.workloadId, source.containerRowId, tail);
|
|
||||||
}
|
|
||||||
return fetchStaticSiteLogs(source.siteId, tail);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLogs() {
|
async function loadLogs() {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<!--
|
<!--
|
||||||
Compact CPU/memory stats bars with an optional expandable history
|
Compact CPU/memory stats bars with an optional expandable history
|
||||||
chart. Works for both project instances and static sites via the
|
chart. Workload-scoped only after the hard cutover — pass the
|
||||||
`source` discriminated union.
|
workload + container-row identifiers; the legacy project-instance and
|
||||||
|
static-site variants targeted dead endpoints.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
|
import type { ContainerStats, ContainerStatsSample } from '$lib/types';
|
||||||
@@ -12,9 +13,11 @@
|
|||||||
import Sparkline from './Sparkline.svelte';
|
import Sparkline from './Sparkline.svelte';
|
||||||
import type { EChartsOption } from 'echarts';
|
import type { EChartsOption } from 'echarts';
|
||||||
|
|
||||||
export type StatsSource =
|
export type StatsSource = {
|
||||||
| { kind: 'instance'; projectId: string; stageId: string; instanceId: string }
|
kind: 'workload';
|
||||||
| { kind: 'site'; siteId: string };
|
workloadId: string;
|
||||||
|
containerRowId: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
source: StatsSource;
|
source: StatsSource;
|
||||||
@@ -34,23 +37,16 @@
|
|||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
|
|
||||||
async function fetchStats(signal: AbortSignal): Promise<ContainerStats> {
|
async function fetchStats(signal: AbortSignal): Promise<ContainerStats> {
|
||||||
if (source.kind === 'instance') {
|
return api.fetchWorkloadContainerStats(source.workloadId, source.containerRowId, signal);
|
||||||
return api.fetchContainerStats(source.projectId, source.stageId, source.instanceId, signal);
|
|
||||||
}
|
|
||||||
return api.fetchStaticSiteStats(source.siteId, signal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchHistory(signal: AbortSignal, win: string = historyWindow): Promise<ContainerStatsSample[]> {
|
async function fetchHistory(signal: AbortSignal, win: string = historyWindow): Promise<ContainerStatsSample[]> {
|
||||||
if (source.kind === 'instance') {
|
return api.fetchWorkloadContainerStatsHistory(
|
||||||
return api.fetchInstanceStatsHistory(
|
source.workloadId,
|
||||||
source.projectId,
|
source.containerRowId,
|
||||||
source.stageId,
|
win,
|
||||||
source.instanceId,
|
signal
|
||||||
win,
|
);
|
||||||
signal
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return api.fetchStaticSiteStatsHistory(source.siteId, win, signal);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
<!--
|
|
||||||
Task 5: Instance card with inline status badges, icon action buttons, improved layout.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Instance } from '$lib/types';
|
|
||||||
import StatusBadge from './StatusBadge.svelte';
|
|
||||||
import ContainerStats from './ContainerStats.svelte';
|
|
||||||
import ContainerLogs from './ContainerLogs.svelte';
|
|
||||||
import ConfirmDialog from './ConfirmDialog.svelte';
|
|
||||||
import { IconPlay, IconStop, IconRestart, IconTrash, IconExternalLink, IconEvents } from '$lib/components/icons';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
instance: Instance;
|
|
||||||
projectId: string;
|
|
||||||
stageId: string;
|
|
||||||
domain?: string;
|
|
||||||
onchange?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { instance, projectId, stageId, domain = '', onchange }: Props = $props();
|
|
||||||
|
|
||||||
let loading = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
let confirmAction = $state<'stop' | 'restart' | 'remove' | null>(null);
|
|
||||||
let showLogs = $state(false);
|
|
||||||
|
|
||||||
const subdomainUrl = $derived(
|
|
||||||
instance.subdomain && domain
|
|
||||||
? `https://${instance.subdomain}.${domain}`
|
|
||||||
: instance.subdomain ? `https://${instance.subdomain}` : ''
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeSinceCreated = $derived($fmt.relative(instance.created_at));
|
|
||||||
|
|
||||||
async function handleAction(action: 'stop' | 'start' | 'restart' | 'remove') {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
confirmAction = null;
|
|
||||||
try {
|
|
||||||
switch (action) {
|
|
||||||
case 'stop':
|
|
||||||
await api.stopInstance(projectId, stageId, instance.id);
|
|
||||||
break;
|
|
||||||
case 'start':
|
|
||||||
await api.startInstance(projectId, stageId, instance.id);
|
|
||||||
break;
|
|
||||||
case 'restart':
|
|
||||||
await api.restartInstance(projectId, stageId, instance.id);
|
|
||||||
break;
|
|
||||||
case 'remove':
|
|
||||||
await api.removeInstance(projectId, stageId, instance.id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
onchange?.();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('instance.actionFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestConfirm(action: 'stop' | 'restart' | 'remove') {
|
|
||||||
confirmAction = action;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-all duration-200 hover:shadow-[var(--shadow-md)]">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="truncate font-mono text-sm font-medium text-[var(--text-primary)]">
|
|
||||||
{instance.image_tag}
|
|
||||||
</span>
|
|
||||||
<StatusBadge status={instance.state} size="sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if subdomainUrl}
|
|
||||||
<a
|
|
||||||
href={subdomainUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="mt-1.5 inline-flex items-center gap-1 text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
{instance.subdomain}
|
|
||||||
<IconExternalLink size={12} />
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-1.5 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
|
||||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{instance.port}</span>
|
|
||||||
<span>{timeSinceCreated}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action buttons -->
|
|
||||||
<div class="ml-3 flex items-center gap-1">
|
|
||||||
{#if instance.state === 'running'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-amber-50 hover:text-amber-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
|
||||||
title={$t('common.stop')}
|
|
||||||
disabled={loading}
|
|
||||||
onclick={() => requestConfirm('stop')}
|
|
||||||
>
|
|
||||||
<IconStop size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-blue-50 hover:text-blue-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
|
||||||
title={$t('common.restart')}
|
|
||||||
disabled={loading}
|
|
||||||
onclick={() => requestConfirm('restart')}
|
|
||||||
>
|
|
||||||
<IconRestart size={16} />
|
|
||||||
</button>
|
|
||||||
{:else if instance.state === 'stopped'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-emerald-50 hover:text-emerald-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
|
||||||
title={$t('common.start')}
|
|
||||||
disabled={loading}
|
|
||||||
onclick={() => handleAction('start')}
|
|
||||||
>
|
|
||||||
<IconPlay size={16} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300 transition-all duration-150"
|
|
||||||
title={$t('logs.title')}
|
|
||||||
onclick={() => { showLogs = !showLogs; }}
|
|
||||||
>
|
|
||||||
<IconEvents size={16} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 disabled:opacity-50 transition-all duration-150 active:animate-press"
|
|
||||||
title={$t('common.remove')}
|
|
||||||
disabled={loading}
|
|
||||||
onclick={() => requestConfirm('remove')}
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if instance.state === 'running'}
|
|
||||||
<ContainerStats source={{ kind: 'instance', projectId, stageId: stageId, instanceId: instance.id }} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showLogs}
|
|
||||||
<div class="mt-2">
|
|
||||||
<ContainerLogs
|
|
||||||
source={{ kind: 'instance', projectId, stageId: stageId, instanceId: instance.id }}
|
|
||||||
onclose={() => { showLogs = false; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{error}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmAction !== null}
|
|
||||||
title={confirmAction ? $t(`confirm.${confirmAction}Instance`) : ''}
|
|
||||||
message={confirmAction ? $t(`instance.${confirmAction}Confirm`) : ''}
|
|
||||||
confirmLabel={confirmAction ? $t(`confirm.${confirmAction}Action`) : ''}
|
|
||||||
confirmVariant={confirmAction === 'remove' ? 'danger' : 'primary'}
|
|
||||||
onconfirm={() => { if (confirmAction) handleAction(confirmAction); }}
|
|
||||||
oncancel={() => { confirmAction = null; }}
|
|
||||||
/>
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
<!--
|
|
||||||
Task 4: Redesigned project card with status indicators, instance count badges, hover effects.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { Project, Instance } from '$lib/types';
|
|
||||||
import StatusBadge from './StatusBadge.svelte';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { IconContainer, IconBox } from '$lib/components/icons';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
project: Project;
|
|
||||||
instances?: Instance[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { project, instances = [] }: Props = $props();
|
|
||||||
|
|
||||||
const runningCount = $derived(instances.filter((i) => i.state === 'running').length);
|
|
||||||
const stoppedCount = $derived(instances.filter((i) => i.state === 'stopped').length);
|
|
||||||
const failedCount = $derived(instances.filter((i) => i.state === 'failed').length);
|
|
||||||
const totalCount = $derived(instances.length);
|
|
||||||
|
|
||||||
const overallStatus = $derived.by<'failed' | 'running' | 'stopped'>(() => {
|
|
||||||
if (failedCount > 0) return 'failed';
|
|
||||||
if (runningCount > 0) return 'running';
|
|
||||||
if (stoppedCount > 0) return 'stopped';
|
|
||||||
return 'stopped';
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/projects/{project.id}"
|
|
||||||
class="group block rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-all duration-200 hover:border-[var(--color-brand-300)] hover:shadow-[var(--shadow-md)] hover:-translate-y-0.5"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-brand-50)] text-[var(--color-brand-600)] transition-colors group-hover:bg-[var(--color-brand-100)]">
|
|
||||||
<IconBox size={16} />
|
|
||||||
</div>
|
|
||||||
<h3 class="truncate text-base font-semibold text-[var(--text-primary)]">{project.name}</h3>
|
|
||||||
</div>
|
|
||||||
<p class="mt-2 truncate font-mono text-xs text-[var(--text-tertiary)]">{project.image}</p>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={overallStatus} size="sm" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Instance count badges -->
|
|
||||||
<div class="mt-4 flex items-center gap-3 text-sm">
|
|
||||||
{#if totalCount > 0}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
|
|
||||||
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>
|
|
||||||
<span class="text-xs font-medium">{runningCount}</span>
|
|
||||||
</span>
|
|
||||||
{#if stoppedCount > 0}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
|
|
||||||
<span class="h-2 w-2 rounded-full bg-gray-400"></span>
|
|
||||||
<span class="text-xs font-medium">{stoppedCount}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if failedCount > 0}
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-[var(--text-secondary)]">
|
|
||||||
<span class="h-2 w-2 rounded-full bg-red-500"></span>
|
|
||||||
<span class="text-xs font-medium">{failedCount}</span>
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
<span class="ml-auto rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-tertiary)]">
|
|
||||||
{totalCount} {totalCount === 1 ? $t('common.instance') : $t('common.instances')}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Meta info -->
|
|
||||||
<div class="mt-3 flex items-center gap-3 text-xs text-[var(--text-tertiary)]">
|
|
||||||
{#if project.port}
|
|
||||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono">:{project.port}</span>
|
|
||||||
{/if}
|
|
||||||
{#if project.healthcheck}
|
|
||||||
<span class="truncate">{project.healthcheck}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
<!--
|
|
||||||
Card displaying a single stale container with cleanup action.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import type { StaleContainer } from '$lib/types';
|
|
||||||
import { IconClock, IconTag, IconTrash } from '$lib/components/icons';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
container: StaleContainer;
|
|
||||||
cleaning?: boolean;
|
|
||||||
oncleanup: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { container, cleaning = false, oncleanup }: Props = $props();
|
|
||||||
|
|
||||||
const badgeClass = $derived(
|
|
||||||
container.days_stale >= 14
|
|
||||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
|
||||||
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayName = $derived(
|
|
||||||
container.role
|
|
||||||
? `${container.workload_name}-${container.role}-${container.container.image_tag}`
|
|
||||||
: `${container.workload_name}-${container.container.image_tag}`
|
|
||||||
);
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
|
||||||
<!-- Header row -->
|
|
||||||
<div class="flex items-start justify-between gap-3">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h3 class="truncate text-sm font-semibold text-[var(--text-primary)]" title={displayName}>
|
|
||||||
{displayName}
|
|
||||||
</h3>
|
|
||||||
<div class="mt-1.5 flex flex-wrap items-center gap-2">
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-md bg-[var(--color-brand-50)] px-2 py-0.5 text-xs font-medium text-[var(--color-brand-600)]">
|
|
||||||
{container.workload_name}
|
|
||||||
</span>
|
|
||||||
{#if container.role}
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-medium text-[var(--text-secondary)]">
|
|
||||||
{container.role}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Days stale badge -->
|
|
||||||
<span class="inline-flex flex-shrink-0 items-center gap-1 rounded-full px-2.5 py-1 text-xs font-semibold {badgeClass}">
|
|
||||||
<IconClock size={12} />
|
|
||||||
{container.days_stale} {$t('stale.daysStale')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-[var(--text-secondary)]">
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<IconTag size={12} />
|
|
||||||
{container.container.image_tag}
|
|
||||||
</span>
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<IconClock size={12} />
|
|
||||||
{$t('stale.lastAlive')}: {$fmt.shortDate(container.container.last_seen_at)}
|
|
||||||
</span>
|
|
||||||
<span class="rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 font-mono text-[10px]">
|
|
||||||
{container.container.state}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cleanup button -->
|
|
||||||
<div class="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={cleaning}
|
|
||||||
onclick={() => oncleanup(container.container.id)}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--color-danger)] px-3 py-1.5 text-xs font-medium text-[var(--color-danger)] transition-colors hover:bg-[var(--color-danger-light)] disabled:opacity-50 active:animate-press"
|
|
||||||
>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
{$t('stale.cleanup')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { InstanceStatus, DeployStatus } from '$lib/types';
|
// The badge accepts any status string — known container / deploy /
|
||||||
|
// site states render with a colored variant; unknown values fall
|
||||||
type Status = InstanceStatus | DeployStatus | string;
|
// through to the neutral pill. Typed as plain string after the
|
||||||
|
// legacy InstanceStatus / DeployStatus unions were retired.
|
||||||
|
type Status = string;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: Status;
|
status: Status;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
<!--
|
<!--
|
||||||
Dashboard summary card: container counts and recent errors.
|
Dashboard summary card: container counts and recent errors.
|
||||||
|
|
||||||
|
Workload-first: pulls running/total counts straight from the global
|
||||||
|
containers index instead of fanning out project → stage → instance
|
||||||
|
queries the way the legacy implementation did.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Instance, EventLogStats } from '$lib/types';
|
import type { EventLogStats } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { IconServer, IconAlert } from '$lib/components/icons';
|
import { IconServer, IconAlert } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
@@ -13,37 +17,24 @@
|
|||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
let cancelled = false;
|
const ac = new AbortController();
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [projects, eventStats] = await Promise.all([
|
const [containers, eventStats] = await Promise.all([
|
||||||
api.listProjects(),
|
api.listContainers({}, ac.signal).catch(() => []),
|
||||||
api.fetchEventLogStats().catch(() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats)
|
api.fetchEventLogStats(ac.signal).catch(
|
||||||
|
() => ({ info: 0, warn: 0, error: 0, total: 0 }) as EventLogStats
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Gather all instances across projects/stages.
|
if (ac.signal.aborted) return;
|
||||||
const allInstances: Instance[] = [];
|
runningCount = containers.filter((c) => c.state === 'running').length;
|
||||||
for (const project of projects) {
|
stoppedCount = containers.filter((c) => c.state !== 'running').length;
|
||||||
try {
|
recentErrors = eventStats.error;
|
||||||
const detail = await api.getProject(project.id);
|
loading = false;
|
||||||
for (const stage of detail.stages ?? []) {
|
|
||||||
const instances = await api.listInstances(project.id, stage.id);
|
|
||||||
allInstances.push(...instances);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip projects that fail to load.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
runningCount = allInstances.filter((i) => i.state === 'running').length;
|
|
||||||
stoppedCount = allInstances.filter((i) => i.state !== 'running').length;
|
|
||||||
recentErrors = eventStats.error;
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) {
|
if (!ac.signal.aborted) {
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -51,9 +42,7 @@
|
|||||||
|
|
||||||
load();
|
load();
|
||||||
|
|
||||||
return () => {
|
return () => ac.abort();
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -62,7 +51,7 @@
|
|||||||
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
<h3 class="mb-4 text-sm font-semibold text-[var(--text-primary)]">{$t('systemHealth.title')}</h3>
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
<!-- Containers -->
|
<!-- Containers -->
|
||||||
<a href="/projects" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
<a href="/containers" class="flex items-center gap-3 rounded-lg p-3 transition-colors hover:bg-[var(--surface-card-hover)]">
|
||||||
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
|
<div class="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-50 dark:bg-emerald-950/30 text-emerald-600">
|
||||||
<IconServer size={18} />
|
<IconServer size={18} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,7 +176,7 @@
|
|||||||
>
|
>
|
||||||
<span class="truncate font-mono">{item.tag}</span>
|
<span class="truncate font-mono">{item.tag}</span>
|
||||||
<span class="ml-2 shrink-0 rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
|
<span class="ml-2 shrink-0 rounded bg-[var(--surface-card-hover)] px-1.5 py-0.5 text-[10px] text-[var(--text-tertiary)]">
|
||||||
{item.source === 'registry' ? $t('projectDetail.registryTag') : $t('projectDetail.localTag')}
|
{item.source === 'registry' ? $t('tagPicker.registry') : $t('tagPicker.local')}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<!--
|
<!--
|
||||||
WebhookDeliveryLog
|
WebhookDeliveryLog
|
||||||
|
|
||||||
Recent inbound webhook activity panel. Used on the project + site detail
|
Recent inbound webhook activity panel. Mounted on the Trigger detail
|
||||||
pages so users can debug "why didn't my deploy fire?" without grepping
|
page (the project + site detail pages it once served were dropped in
|
||||||
daemon logs. Polls the audit table every 30s while the panel is mounted.
|
the hard legacy cutover). Polls the audit table every 30s so users can
|
||||||
|
debug "why didn't my deploy fire?" without grepping daemon logs.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
|||||||
+30
-576
@@ -18,38 +18,27 @@
|
|||||||
"eventTriggers": "Triggers",
|
"eventTriggers": "Triggers",
|
||||||
"logScanRules": "Log Rules",
|
"logScanRules": "Log Rules",
|
||||||
"triggers": "Triggers",
|
"triggers": "Triggers",
|
||||||
"projects": "Projects",
|
|
||||||
"deploy": "Deploy",
|
|
||||||
"proxies": "Proxies",
|
"proxies": "Proxies",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Log out",
|
"logout": "Log out",
|
||||||
"dns": "DNS Records",
|
"dns": "DNS Records",
|
||||||
"sites": "Sites",
|
|
||||||
"stacks": "Stacks",
|
|
||||||
"containers": "Containers"
|
"containers": "Containers"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"quickDeploy": "Quick Deploy",
|
"newApp": "New app",
|
||||||
"totalProjects": "Total Projects",
|
"totalWorkloads": "Total Workloads",
|
||||||
"runningInstances": "Running Instances",
|
"runningContainers": "Running Containers",
|
||||||
"failedInstances": "Failed Instances",
|
"failedContainers": "Failed Containers",
|
||||||
"projects": "Projects",
|
"recentWorkloads": "Recent Workloads",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"noProjects": "No projects yet.",
|
"noWorkloads": "No workloads yet.",
|
||||||
"addFirst": "Add your first project",
|
"noWorkloadsDesc": "Create an app and forge your first workload to get started.",
|
||||||
"loadFailed": "Failed to load dashboard",
|
"loadFailed": "Failed to load dashboard",
|
||||||
"staleContainers": "Stale Containers",
|
"staleContainers": "Stale Containers",
|
||||||
"unusedImagesWarning": "Unused Docker images are taking up disk space",
|
"unusedImagesWarning": "Unused Docker images are taking up disk space",
|
||||||
"unusedImages": "unused images",
|
"unusedImages": "unused images",
|
||||||
"staticSites": "Static Sites",
|
|
||||||
"totalSites": "Total Sites",
|
|
||||||
"deployedSites": "deployed",
|
|
||||||
"failedSites": "failed",
|
|
||||||
"noSites": "No static sites yet.",
|
|
||||||
"addFirstSite": "Deploy your first site",
|
|
||||||
"viewAllSites": "View all sites",
|
|
||||||
"systemHealth": "System health",
|
"systemHealth": "System health",
|
||||||
"daemons": "Daemons",
|
"daemons": "Daemons",
|
||||||
"systemResources": "System resources",
|
"systemResources": "System resources",
|
||||||
@@ -92,240 +81,9 @@
|
|||||||
"retentionLabel": "Stats retention (hours)",
|
"retentionLabel": "Stats retention (hours)",
|
||||||
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 0–24h."
|
"retentionHelp": "How long resource samples are kept. 0 disables collection. Range: 0–24h."
|
||||||
},
|
},
|
||||||
"projects": {
|
"tagPicker": {
|
||||||
"title": "Projects",
|
|
||||||
"addProject": "Add Project",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"newProject": "New Project",
|
|
||||||
"name": "Name",
|
|
||||||
"image": "Image",
|
|
||||||
"port": "Port",
|
|
||||||
"registry": "Registry",
|
"registry": "Registry",
|
||||||
"created": "Created",
|
"local": "Local"
|
||||||
"view": "View",
|
|
||||||
"noProjects": "No projects configured yet.",
|
|
||||||
"getStarted": "Click \"Add Project\" to get started.",
|
|
||||||
"createProject": "Create Project",
|
|
||||||
"creating": "Creating...",
|
|
||||||
"healthcheck": "Healthcheck Path",
|
|
||||||
"nameRequired": "Name and image are required.",
|
|
||||||
"loadFailed": "Failed to load projects",
|
|
||||||
"createFailed": "Failed to create project",
|
|
||||||
"browseImages": "Browse Images",
|
|
||||||
"selectImage": "Select an image",
|
|
||||||
"noImages": "No images found",
|
|
||||||
"loadingImages": "Loading images...",
|
|
||||||
"imageLoadFailed": "Failed to load images",
|
|
||||||
"alreadyAdded": "Already added",
|
|
||||||
"portHelpText": "Auto-detected from EXPOSE if empty",
|
|
||||||
"healthcheckHelpText": "Auto-detected from image if empty",
|
|
||||||
"searchPlaceholder": "Search projects by name, image, or registry...",
|
|
||||||
"noMatchingProjects": "No projects match your search."
|
|
||||||
},
|
|
||||||
"projectDetail": {
|
|
||||||
"webhookTitle": "Project webhook",
|
|
||||||
"webhookDesc": "POST an image reference to this URL from your CI pipeline to trigger a deploy. Stage routing uses each stage's tag pattern.",
|
|
||||||
"outgoingWebhookTitle": "Outgoing webhook (project)",
|
|
||||||
"outgoingWebhookDesc": "Where Tinyforge posts deploy events for this project. Stages can override; if none set, inherits from global settings.",
|
|
||||||
"outgoingFallbackGlobal": "the global integrations setting",
|
|
||||||
"notificationUrlLabel": "Outgoing webhook URL",
|
|
||||||
"notificationUrlHelp": "Leave empty to inherit from global settings. Stages can override per-stage.",
|
|
||||||
"stageNotificationUrlLabel": "Outgoing webhook URL (this stage)",
|
|
||||||
"stageNotificationUrlHelp": "Leave empty to inherit from the project, then global settings.",
|
|
||||||
"stageOutgoingTitle": "Outgoing webhook (stage)",
|
|
||||||
"stageOutgoingDesc": "Where Tinyforge posts deploy events for this stage. Most-specific tier wins.",
|
|
||||||
"stageFallbackLabel": "the project or global settings",
|
|
||||||
"deleteProject": "Delete Project",
|
|
||||||
"envVars": "Environment Variables",
|
|
||||||
"volumes": "Volume Mounts",
|
|
||||||
"stages": "Stages",
|
|
||||||
"noStages": "No stages configured for this project.",
|
|
||||||
"pattern": "Pattern",
|
|
||||||
"autoDeploy": "auto-deploy",
|
|
||||||
"requiresConfirm": "requires confirm",
|
|
||||||
"instances": "instances",
|
|
||||||
"deployNewVersion": "Deploy new version",
|
|
||||||
"selectTag": "Select tag to deploy",
|
|
||||||
"loadingTags": "Loading tags...",
|
|
||||||
"chooseTag": "Choose a tag...",
|
|
||||||
"enterTag": "Enter image tag (e.g., dev-abc123)",
|
|
||||||
"registryTag": "Registry",
|
|
||||||
"localTag": "Local",
|
|
||||||
"alsoLocal": "Also available locally",
|
|
||||||
"searchTags": "Search tags...",
|
|
||||||
"deployTag": "Tag",
|
|
||||||
"deploy": "Deploy",
|
|
||||||
"deploying": "Deploying...",
|
|
||||||
"recentDeploys": "Recent Deploys",
|
|
||||||
"noDeployHistory": "No deploy history for this project.",
|
|
||||||
"tag": "Tag",
|
|
||||||
"status": "Status",
|
|
||||||
"started": "Started",
|
|
||||||
"finished": "Finished",
|
|
||||||
"error": "Error",
|
|
||||||
"noInstancesRunning": "No instances running",
|
|
||||||
"deleteConfirmTitle": "Delete Project",
|
|
||||||
"deleteConfirmMessage": "This will permanently delete the project '{name}' and all its stages, instances, and deploy history. This cannot be undone.",
|
|
||||||
"loadFailed": "Failed to load project",
|
|
||||||
"deleteFailed": "Failed to delete project",
|
|
||||||
"deployFailed": "Deploy failed",
|
|
||||||
"nameLabel": "Name *",
|
|
||||||
"imageLabel": "Image *",
|
|
||||||
"portLabel": "Port",
|
|
||||||
"healthcheckLabel": "Healthcheck Path",
|
|
||||||
"saving": "Saving...",
|
|
||||||
"addStage": "Add Stage",
|
|
||||||
"tagPattern": "Tag Pattern",
|
|
||||||
"tagPatternHelp": "Glob pattern (e.g., dev-*, v*)",
|
|
||||||
"maxInstances": "Max Instances",
|
|
||||||
"autoDeployLabel": "Auto Deploy",
|
|
||||||
"enableProxy": "Enable Proxy",
|
|
||||||
"accessListId": "NPM Access List ID",
|
|
||||||
"accessListIdHelp": "Override the global access list for this project. Clear to inherit from NPM settings.",
|
|
||||||
"localImages": "Local Docker Images",
|
|
||||||
"imageTag": "Tag",
|
|
||||||
"imageId": "Image ID",
|
|
||||||
"imageSize": "Size",
|
|
||||||
"imageCreated": "Created",
|
|
||||||
"cpuLimit": "CPU Limit (cores)",
|
|
||||||
"cpuLimitHelp": "e.g., 0.5, 1, 2. Leave 0 for unlimited",
|
|
||||||
"memoryLimit": "Memory Limit (MB)",
|
|
||||||
"memoryLimitHelp": "e.g., 256, 512, 1024. Leave 0 for unlimited",
|
|
||||||
"npmProxy": "NPM Proxy",
|
|
||||||
"creating": "Creating...",
|
|
||||||
"createStage": "Create Stage",
|
|
||||||
"noProxy": "No Proxy",
|
|
||||||
"deleteStage": "Delete stage",
|
|
||||||
"deleteStageConfirm": "Delete stage \"{name}\"?",
|
|
||||||
"stageCreated": "Stage \"{name}\" created",
|
|
||||||
"stageUpdated": "Stage updated",
|
|
||||||
"stageUpdateFailed": "Failed to update stage",
|
|
||||||
"stageDeleted": "Stage \"{name}\" deleted",
|
|
||||||
"projectUpdated": "Project updated",
|
|
||||||
"updateFailed": "Failed to update project",
|
|
||||||
"stageCreateFailed": "Failed to create stage",
|
|
||||||
"stageDeleteFailed": "Failed to delete stage"
|
|
||||||
},
|
|
||||||
"envEditor": {
|
|
||||||
"title": "Environment Variables",
|
|
||||||
"description": "Manage per-stage environment variable overrides. Stage-level values override project-level defaults.",
|
|
||||||
"stage": "Stage",
|
|
||||||
"projectDefaults": "Project-Level Defaults",
|
|
||||||
"noProjectEnv": "No project-level environment variables defined yet.",
|
|
||||||
"stageOverrides": "Stage Overrides",
|
|
||||||
"key": "Key",
|
|
||||||
"value": "Value",
|
|
||||||
"secret": "Secret",
|
|
||||||
"source": "Source",
|
|
||||||
"actions": "Actions",
|
|
||||||
"overridden": "overridden",
|
|
||||||
"inherited": "inherited",
|
|
||||||
"overridesProject": "overrides project",
|
|
||||||
"stageOnly": "stage only",
|
|
||||||
"edit": "Edit",
|
|
||||||
"change": "Change",
|
|
||||||
"delete": "Delete",
|
|
||||||
"save": "Save",
|
|
||||||
"add": "Add",
|
|
||||||
"adding": "Adding...",
|
|
||||||
"noStages": "No stages configured. Add stages to the project first.",
|
|
||||||
"loadFailed": "Failed to load project",
|
|
||||||
"envAdded": "Environment variable added",
|
|
||||||
"envUpdated": "Environment variable updated",
|
|
||||||
"envDeleted": "Environment variable deleted",
|
|
||||||
"addFailed": "Failed to add env var",
|
|
||||||
"updateFailed": "Failed to update env var",
|
|
||||||
"deleteFailed": "Failed to delete env var",
|
|
||||||
"loadEnvFailed": "Failed to load env vars",
|
|
||||||
"leaveEmptyToKeep": "Leave empty to keep current",
|
|
||||||
"deleteTitle": "Delete Environment Variable",
|
|
||||||
"deleteMessage": "Are you sure you want to delete this environment variable? This action cannot be undone."
|
|
||||||
},
|
|
||||||
"volumeEditor": {
|
|
||||||
"title": "Volume Mounts",
|
|
||||||
"description": "Configure volume mounts for containers. Choose a scope to control how volumes are shared between deploys.",
|
|
||||||
"sourceHost": "Source (Host)",
|
|
||||||
"targetContainer": "Target (Container)",
|
|
||||||
"scope": "Scope",
|
|
||||||
"nameColumn": "Name",
|
|
||||||
"namePlaceholder": "e.g. shared-db",
|
|
||||||
"requiresName": "requires name",
|
|
||||||
"noHostPath": "no host path",
|
|
||||||
"tmpfs": "tmpfs (in-memory)",
|
|
||||||
"actions": "Actions",
|
|
||||||
"edit": "Edit",
|
|
||||||
"delete": "Delete",
|
|
||||||
"save": "Save",
|
|
||||||
"add": "Add",
|
|
||||||
"adding": "Adding...",
|
|
||||||
"scopeGuide": "Volume Scopes",
|
|
||||||
"noVolumes": "No volumes configured yet. Add one above.",
|
|
||||||
"volumeAdded": "Volume added",
|
|
||||||
"volumeUpdated": "Volume updated",
|
|
||||||
"volumeDeleted": "Volume deleted",
|
|
||||||
"loadFailed": "Failed to load volumes",
|
|
||||||
"addFailed": "Failed to add volume",
|
|
||||||
"updateFailed": "Failed to update volume",
|
|
||||||
"deleteFailed": "Failed to delete volume"
|
|
||||||
},
|
|
||||||
"volumeBrowser": {
|
|
||||||
"title": "Volume Browser",
|
|
||||||
"loadFailed": "Failed to load directory",
|
|
||||||
"empty": "This directory is empty.",
|
|
||||||
"name": "Name",
|
|
||||||
"size": "Size",
|
|
||||||
"modified": "Modified",
|
|
||||||
"downloadAll": "Download volume as ZIP",
|
|
||||||
"downloadFolder": "Download folder as ZIP",
|
|
||||||
"upload": "Upload files",
|
|
||||||
"uploaded": "Uploaded",
|
|
||||||
"files": "file(s)",
|
|
||||||
"uploadFailed": "Failed to upload files",
|
|
||||||
"browse": "Browse",
|
|
||||||
"download": "Download"
|
|
||||||
},
|
|
||||||
"quickDeploy": {
|
|
||||||
"title": "Quick Deploy",
|
|
||||||
"description": "Deploy a container image with zero configuration. Paste an image URL, review the defaults, and deploy.",
|
|
||||||
"step1": "1. Enter Image URL",
|
|
||||||
"imageUrl": "Image URL",
|
|
||||||
"imageUrlHelp": "Full image URL including tag (e.g., git.example.com/user/app:dev-abc123)",
|
|
||||||
"inspect": "Inspect",
|
|
||||||
"inspecting": "Inspecting...",
|
|
||||||
"step2": "2. Review Configuration",
|
|
||||||
"reviewDesc": "These defaults were detected from the image. Adjust as needed before deploying.",
|
|
||||||
"projectName": "Project Name",
|
|
||||||
"port": "Port",
|
|
||||||
"portHelp": "Container port to expose (1-65535)",
|
|
||||||
"healthCheckPath": "Health Check Path",
|
|
||||||
"healthCheckHelp": "Optional HTTP path for health verification",
|
|
||||||
"stage": "Stage",
|
|
||||||
"development": "Development",
|
|
||||||
"release": "Release",
|
|
||||||
"production": "Production",
|
|
||||||
"stageHelp": "Deployment stage for this image",
|
|
||||||
"subdomainOverride": "Subdomain Override",
|
|
||||||
"subdomainHelp": "Leave empty to use the default subdomain pattern",
|
|
||||||
"envVars": "Environment Variables",
|
|
||||||
"envVarsHelp": "One per line, KEY=VALUE format",
|
|
||||||
"step3": "3. Deploy",
|
|
||||||
"deployDesc": "A new project will be created and the container will be deployed immediately.",
|
|
||||||
"deployBtn": "Deploy",
|
|
||||||
"inspectedSuccess": "Image inspected successfully",
|
|
||||||
"deployedSuccess": "Deployed {name} successfully!",
|
|
||||||
"inspectFailed": "Failed to inspect image",
|
|
||||||
"deployFailed": "Deployment failed",
|
|
||||||
"browseImages": "Browse",
|
|
||||||
"selectImage": "Select an image from a registry",
|
|
||||||
"noImages": "No images found",
|
|
||||||
"loadingImages": "Loading...",
|
|
||||||
"imageLoadFailed": "Failed to load images",
|
|
||||||
"autoDeployLabel": "Deploy immediately",
|
|
||||||
"lowercaseHint": "Lowercase with hyphens",
|
|
||||||
"imageAlreadyExists": "Image already deployed",
|
|
||||||
"conflictDescription": "A project using this image already exists. You can open the existing project to deploy a new version, or create a separate project.",
|
|
||||||
"openProject": "Open project \u2192",
|
|
||||||
"createNewAnyway": "Create New Project"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -489,7 +247,7 @@
|
|||||||
"testing": "Testing...",
|
"testing": "Testing...",
|
||||||
"testSuccess": "NPM connection successful",
|
"testSuccess": "NPM connection successful",
|
||||||
"testFailed": "NPM connection failed",
|
"testFailed": "NPM connection failed",
|
||||||
"saveFailedConnection": "Cannot save \u2014 connection test failed",
|
"saveFailedConnection": "Cannot save — connection test failed",
|
||||||
"remoteMode": "Remote NPM",
|
"remoteMode": "Remote NPM",
|
||||||
"remoteModeHelp": "Enable when NPM runs on a different machine than Docker. Forwards to Server IP with published host ports.",
|
"remoteModeHelp": "Enable when NPM runs on a different machine than Docker. Forwards to Server IP with published host ports.",
|
||||||
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
|
"remoteModeWarning": "Requires Server IP in General settings. Ports are auto-mapped to random host ports.",
|
||||||
@@ -613,117 +371,28 @@
|
|||||||
"networkError": "Network error"
|
"networkError": "Network error"
|
||||||
},
|
},
|
||||||
"proxies": {
|
"proxies": {
|
||||||
"title": "Proxy Manager",
|
"title": "Proxy Routes",
|
||||||
"create": "Create Proxy",
|
"description": "Active proxy routes from deployed containers and static sites.",
|
||||||
"standalone": "Standalone Proxies",
|
|
||||||
"managed": "Managed Proxies",
|
|
||||||
"noProxies": "No proxies found",
|
|
||||||
"noProxiesDesc": "Create a standalone proxy or deploy a project with proxy enabled.",
|
|
||||||
"filter": {
|
|
||||||
"search": "Search by domain or destination...",
|
|
||||||
"health": "Health",
|
|
||||||
"type": "Type",
|
|
||||||
"all": "All",
|
|
||||||
"clear": "Clear filters"
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
"healthy": "Healthy",
|
|
||||||
"unhealthy": "Unhealthy",
|
|
||||||
"unknown": "Unknown"
|
|
||||||
},
|
|
||||||
"lastChecked": "Last checked"
|
|
||||||
},
|
|
||||||
"sites": {
|
|
||||||
"webhookTitle": "Site webhook",
|
|
||||||
"webhookDesc": "Point your Git provider's push webhook at this URL. Tinyforge will re-sync the site on matching refs (branch for push trigger, tag pattern for tag trigger). Send an empty body for an unconditional sync.",
|
|
||||||
"outgoingUrlTitle": "Outgoing webhook URL (this site)",
|
|
||||||
"outgoingUrlDesc": "Where Tinyforge posts site_sync_success / site_sync_failure events for this site. Empty falls through to global settings.",
|
|
||||||
"outgoingWebhookTitle": "Outgoing webhook (site)",
|
|
||||||
"outgoingWebhookDesc": "HMAC signing secret and test sender for the resolved outgoing URL.",
|
|
||||||
"outgoingFallbackGlobal": "the global integrations setting",
|
|
||||||
"title": "Static Sites",
|
|
||||||
"addSite": "New Site",
|
|
||||||
"newSite": "New Static Site",
|
|
||||||
"createSite": "Create Site",
|
|
||||||
"noSites": "No static sites",
|
|
||||||
"noSitesDesc": "Deploy static content from a Git repository folder.",
|
|
||||||
"searchPlaceholder": "Search sites by name, domain, or repo...",
|
|
||||||
"noMatching": "No sites match your search.",
|
|
||||||
"name": "Name",
|
|
||||||
"domain": "Domain",
|
"domain": "Domain",
|
||||||
"mode": "Mode",
|
"project": "Project / Site",
|
||||||
|
"stage": "Stage / Mode",
|
||||||
|
"tag": "Tag",
|
||||||
|
"port": "Port",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"lastSync": "Last Sync",
|
"source": "Source",
|
||||||
"deploy": "Deploy",
|
"sourceContainer": "Container",
|
||||||
"stop": "Stop",
|
"sourceStatic": "Static Site",
|
||||||
"start": "Start",
|
"sourceDeno": "Deno Site",
|
||||||
"openSite": "Open Site",
|
"filterAll": "All",
|
||||||
"confirmDelete": "Delete Site",
|
"filterContainers": "Containers",
|
||||||
"confirmDeleteMsg": "This will permanently delete the site and remove its container",
|
"filterSites": "Sites",
|
||||||
"confirmDeleteSecret": "Delete Secret",
|
"noRoutes": "No proxy routes",
|
||||||
"confirmDeleteSecretMsg": "Are you sure you want to delete secret",
|
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled or publish a static site.",
|
||||||
"siteInfo": "Site Information",
|
"searchPlaceholder": "Search by domain, project, or tag...",
|
||||||
"folder": "Folder",
|
"noMatch": "No routes match your search.",
|
||||||
"syncTrigger": "Sync Trigger",
|
"loadFailed": "Failed to load proxy routes",
|
||||||
"commitSha": "Commit SHA",
|
"route": "route",
|
||||||
"secrets": "Secrets",
|
"routes": "routes"
|
||||||
"addSecret": "Add Secret",
|
|
||||||
"noSecrets": "No secrets configured. Add secrets if your site needs server-side API keys.",
|
|
||||||
"secretKey": "Key",
|
|
||||||
"secretValue": "Value",
|
|
||||||
"encryptSecret": "Encrypt value",
|
|
||||||
"saveSecret": "Add Secret",
|
|
||||||
"step1Title": "1. Repository",
|
|
||||||
"step2Title": "2. Select Branch",
|
|
||||||
"step3Title": "3. Select Folder",
|
|
||||||
"step4Title": "4. Configuration",
|
|
||||||
"step5Title": "5. Review & Create",
|
|
||||||
"fullRepoUrl": "Repository URL",
|
|
||||||
"fullRepoUrlHelp": "Paste a full URL to auto-fill the fields below (e.g., https://git.example.com/owner/repo)",
|
|
||||||
"serverUrl": "Server URL",
|
|
||||||
"repoUrl": "Git Server URL",
|
|
||||||
"repoUrlHelp": "Paste a full repo URL or enter the server base URL (Gitea, Forgejo, Gogs)",
|
|
||||||
"repoOwner": "Owner",
|
|
||||||
"repoName": "Repository",
|
|
||||||
"accessToken": "Access Token",
|
|
||||||
"accessTokenPlaceholder": "Optional — for private repos",
|
|
||||||
"accessTokenHelp": "Personal access token with repo read permissions. Leave empty for public repos.",
|
|
||||||
"noToken": "None (public repo)",
|
|
||||||
"testConnection": "Test Connection",
|
|
||||||
"connectionSuccess": "Repository is accessible",
|
|
||||||
"loadingBranches": "Loading branches...",
|
|
||||||
"selectBranch": "Select a branch",
|
|
||||||
"chooseBranch": "Choose a branch...",
|
|
||||||
"branch": "Branch",
|
|
||||||
"loadingTree": "Loading repository tree...",
|
|
||||||
"selectFolder": "Select the folder containing your site files",
|
|
||||||
"selectedFolder": "Selected folder",
|
|
||||||
"siteName": "Site Name",
|
|
||||||
"domainHelp": "Public domain for the site. Proxy will be configured automatically.",
|
|
||||||
"modeStaticDesc": "HTML, CSS, JS, images served via Nginx",
|
|
||||||
"modeDenoDesc": "Static files + server-side API from api/ folder",
|
|
||||||
"triggerManual": "Manual",
|
|
||||||
"triggerPush": "On Push",
|
|
||||||
"triggerTag": "On Tag",
|
|
||||||
"tagPattern": "Tag Pattern",
|
|
||||||
"tagPatternHelp": "Glob pattern for matching tags (e.g., v*, pages-*)",
|
|
||||||
"renderMarkdown": "Render Markdown files to HTML",
|
|
||||||
"provider": "Git Provider",
|
|
||||||
"detectedProvider": "Auto-detected",
|
|
||||||
"browseRepos": "Browse repositories",
|
|
||||||
"selectRepo": "Select a repository",
|
|
||||||
"storage": "Persistent Storage",
|
|
||||||
"enableStorage": "Enable persistent storage",
|
|
||||||
"storageHelp": "Mounts a Docker volume at /app/data for your Deno backend to read and write files that persist across deployments.",
|
|
||||||
"storageLimitMB": "Storage Limit (MB)",
|
|
||||||
"storageLimitHelp": "Maximum storage size in megabytes. 0 = unlimited.",
|
|
||||||
"storageVolume": "Volume",
|
|
||||||
"dataPath": "Data Path",
|
|
||||||
"storageMountPath": "Mount Path",
|
|
||||||
"storageLimit": "Limit",
|
|
||||||
"storageUsed": "Used",
|
|
||||||
"storageOfLimit": "of limit used",
|
|
||||||
"unlimited": "Unlimited"
|
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -775,24 +444,9 @@
|
|||||||
"lastSeen": "Last seen"
|
"lastSeen": "Last seen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instance": {
|
|
||||||
"stopConfirm": "This will stop the running container. The instance can be started again later.",
|
|
||||||
"restartConfirm": "This will restart the container, causing brief downtime.",
|
|
||||||
"removeConfirm": "This will permanently remove the container and its proxy configuration. This cannot be undone.",
|
|
||||||
"actionFailed": "Action failed"
|
|
||||||
},
|
|
||||||
"empty": {
|
"empty": {
|
||||||
"noProjects": "No projects yet",
|
|
||||||
"noProjectsDesc": "Get started by creating your first project or use Quick Deploy.",
|
|
||||||
"createProject": "Create Project",
|
|
||||||
"noInstances": "No instances",
|
|
||||||
"noInstancesDesc": "Deploy a new version to see instances here.",
|
|
||||||
"noDeploys": "No deploy history",
|
|
||||||
"noDeploysDesc": "Deploy history will appear here after your first deployment.",
|
|
||||||
"noRegistries": "No registries",
|
"noRegistries": "No registries",
|
||||||
"noRegistriesDesc": "Add a container registry to enable image detection.",
|
"noRegistriesDesc": "Add a container registry to enable image detection.",
|
||||||
"noVolumes": "No volumes",
|
|
||||||
"noVolumesDesc": "Configure volume mounts for persistent data.",
|
|
||||||
"noUsers": "No users",
|
"noUsers": "No users",
|
||||||
"noUsersDesc": "Add local users to manage access."
|
"noUsersDesc": "Add local users to manage access."
|
||||||
},
|
},
|
||||||
@@ -808,15 +462,6 @@
|
|||||||
"requiredWhenUpdating": "{field} is required when updating credentials",
|
"requiredWhenUpdating": "{field} is required when updating credentials",
|
||||||
"requiredForNew": "{field} is required for new registries"
|
"requiredForNew": "{field} is required for new registries"
|
||||||
},
|
},
|
||||||
"confirm": {
|
|
||||||
"stopInstance": "Stop Instance",
|
|
||||||
"startInstance": "Start Instance",
|
|
||||||
"restartInstance": "Restart Instance",
|
|
||||||
"removeInstance": "Remove Instance",
|
|
||||||
"stopAction": "Stop",
|
|
||||||
"restartAction": "Restart",
|
|
||||||
"removeAction": "Remove"
|
|
||||||
},
|
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": "Light",
|
"light": "Light",
|
||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
@@ -842,75 +487,6 @@
|
|||||||
"cleanupFailed": "Cleanup failed",
|
"cleanupFailed": "Cleanup failed",
|
||||||
"loadFailed": "Failed to load stale containers"
|
"loadFailed": "Failed to load stale containers"
|
||||||
},
|
},
|
||||||
"proxies": {
|
|
||||||
"title": "Proxies",
|
|
||||||
"create": "Create Proxy",
|
|
||||||
"noProxies": "No proxies configured yet.",
|
|
||||||
"noProxiesDesc": "Create a standalone proxy or deploy a project to see proxies here.",
|
|
||||||
"standalone": "Standalone Proxies",
|
|
||||||
"managed": "Managed",
|
|
||||||
"lastChecked": "Last checked",
|
|
||||||
"health": {
|
|
||||||
"healthy": "Healthy",
|
|
||||||
"unhealthy": "Unhealthy",
|
|
||||||
"unknown": "Unknown"
|
|
||||||
},
|
|
||||||
"filter": {
|
|
||||||
"search": "Search proxies...",
|
|
||||||
"health": "Health",
|
|
||||||
"type": "Type",
|
|
||||||
"all": "All",
|
|
||||||
"clear": "Clear filters"
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"title": "Create Proxy",
|
|
||||||
"editTitle": "Edit Proxy",
|
|
||||||
"destination": "Destination URL / IP",
|
|
||||||
"port": "Port",
|
|
||||||
"domain": "Domain",
|
|
||||||
"domainHelp": "The public domain for this proxy.",
|
|
||||||
"validate": "Validate",
|
|
||||||
"validating": "Validating...",
|
|
||||||
"create": "Create Proxy",
|
|
||||||
"save": "Save Changes",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"delete": "Delete",
|
|
||||||
"deleteConfirm": "Delete this proxy? This cannot be undone."
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"title": "Destination Validation",
|
|
||||||
"syntax": "URL syntax",
|
|
||||||
"dns": "DNS resolution",
|
|
||||||
"tcp": "TCP connection",
|
|
||||||
"http": "HTTP response",
|
|
||||||
"checking": "Checking...",
|
|
||||||
"skipped": "Skipped"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxies": {
|
|
||||||
"title": "Proxy Routes",
|
|
||||||
"description": "Active proxy routes from deployed containers and static sites.",
|
|
||||||
"domain": "Domain",
|
|
||||||
"project": "Project / Site",
|
|
||||||
"stage": "Stage / Mode",
|
|
||||||
"tag": "Tag",
|
|
||||||
"port": "Port",
|
|
||||||
"status": "Status",
|
|
||||||
"source": "Source",
|
|
||||||
"sourceContainer": "Container",
|
|
||||||
"sourceStatic": "Static Site",
|
|
||||||
"sourceDeno": "Deno Site",
|
|
||||||
"filterAll": "All",
|
|
||||||
"filterContainers": "Containers",
|
|
||||||
"filterSites": "Sites",
|
|
||||||
"noRoutes": "No proxy routes",
|
|
||||||
"noRoutesDesc": "Proxy routes are created automatically when you deploy a container with proxy enabled or publish a static site.",
|
|
||||||
"searchPlaceholder": "Search by domain, project, or tag...",
|
|
||||||
"noMatch": "No routes match your search.",
|
|
||||||
"loadFailed": "Failed to load proxy routes",
|
|
||||||
"route": "route",
|
|
||||||
"routes": "routes"
|
|
||||||
},
|
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Container Logs",
|
"title": "Container Logs",
|
||||||
"lines": "lines",
|
"lines": "lines",
|
||||||
@@ -1049,128 +625,6 @@
|
|||||||
"en": "English",
|
"en": "English",
|
||||||
"ru": "Russian"
|
"ru": "Russian"
|
||||||
},
|
},
|
||||||
"stacks": {
|
|
||||||
"eyebrow": "THE FORGE",
|
|
||||||
"title": "Stacks",
|
|
||||||
"lede": "Compose blueprints, forged as <em>atomic units</em>. Spin up services, iterate on revisions, roll back without breaking a sweat.",
|
|
||||||
"newStack": "New stack",
|
|
||||||
"refresh": "Refresh",
|
|
||||||
"total": "Total",
|
|
||||||
"running": "Running",
|
|
||||||
"deploying": "Forging",
|
|
||||||
"failed": "Failed",
|
|
||||||
"stopped": "Cold",
|
|
||||||
"empty": {
|
|
||||||
"title": "The anvil is cold.",
|
|
||||||
"desc": "Upload a docker-compose.yml to forge your first stack."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"noDescription": "No description",
|
|
||||||
"updated": "Updated",
|
|
||||||
"start": "Start",
|
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
|
||||||
"open": "Open"
|
|
||||||
},
|
|
||||||
"new": {
|
|
||||||
"eyebrow": "NEW BLUEPRINT",
|
|
||||||
"title": "Forge a new stack.",
|
|
||||||
"lede": "Upload or paste a <code>docker-compose.yml</code>. All services in the blueprint deploy as a single atomic unit.",
|
|
||||||
"back": "Stacks",
|
|
||||||
"name": "Name",
|
|
||||||
"namePlaceholder": "my-app-stack",
|
|
||||||
"nameHint": "Lowercase, hyphenated. Used as the compose project name.",
|
|
||||||
"description": "Description",
|
|
||||||
"descriptionPlaceholder": "What does this stack do?",
|
|
||||||
"composeYaml": "Compose YAML",
|
|
||||||
"required": "required",
|
|
||||||
"optional": "optional",
|
|
||||||
"loadSample": "Load sample",
|
|
||||||
"uploadFile": "Upload file",
|
|
||||||
"dropHere": "Drop a docker-compose.yml here",
|
|
||||||
"dropSub": "or click to browse · or use <strong>Load sample</strong> above",
|
|
||||||
"lines": "{n} lines",
|
|
||||||
"bytes": "{n} bytes",
|
|
||||||
"clear": "Clear",
|
|
||||||
"deployImmediate": "Deploy immediately",
|
|
||||||
"deployHint": "Strike while the iron's hot. If unchecked, the stack is saved cold.",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"forging": "Forging…",
|
|
||||||
"forgeAndDeploy": "Forge & deploy",
|
|
||||||
"saveBlueprint": "Save blueprint",
|
|
||||||
"errorRequired": "Name and compose YAML are required.",
|
|
||||||
"errorCreate": "Failed to create stack"
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"manifest": "MANIFEST",
|
|
||||||
"loading": "Loading blueprint…",
|
|
||||||
"composeProject": "COMPOSE PROJECT",
|
|
||||||
"noDescription": "No description",
|
|
||||||
"refresh": "Refresh",
|
|
||||||
"start": "Start",
|
|
||||||
"stop": "Stop",
|
|
||||||
"delete": "Delete",
|
|
||||||
"fault": "FAULT",
|
|
||||||
"err": "ERR",
|
|
||||||
"stats": {
|
|
||||||
"services": "Services",
|
|
||||||
"servicesSub": "in blueprint",
|
|
||||||
"running": "Running",
|
|
||||||
"runningSub": "active containers",
|
|
||||||
"revisions": "Revisions",
|
|
||||||
"revisionsSub": "in history",
|
|
||||||
"current": "Current",
|
|
||||||
"currentSub": "deployed"
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"title": "Services",
|
|
||||||
"count": "{n} on the floor",
|
|
||||||
"empty": "— no containers running —"
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"blueprint": "Blueprint",
|
|
||||||
"revisions": "Revisions",
|
|
||||||
"logs": "Logs"
|
|
||||||
},
|
|
||||||
"yaml": {
|
|
||||||
"currentRevision": "Current revision",
|
|
||||||
"edit": "Edit & redeploy",
|
|
||||||
"cancel": "Cancel",
|
|
||||||
"forging": "Forging…",
|
|
||||||
"deployNew": "Deploy new revision"
|
|
||||||
},
|
|
||||||
"revisions": {
|
|
||||||
"current": "CURRENT",
|
|
||||||
"by": "by",
|
|
||||||
"rollback": "← Rollback to this revision",
|
|
||||||
"rollbackTitle": "Rollback to revision?",
|
|
||||||
"rollbackMessage": "Create a new revision from rev {n} and redeploy the stack.",
|
|
||||||
"rollbackConfirm": "Rollback"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"service": "Service:",
|
|
||||||
"allServices": "All services",
|
|
||||||
"fetching": "Fetching…",
|
|
||||||
"fetch": "Fetch logs",
|
|
||||||
"empty": "— no logs loaded. tap fetch. —"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"title": "Delete stack?",
|
|
||||||
"messageBase": "This runs 'docker compose down' and removes \"{name}\".",
|
|
||||||
"messageVolumes": " Named volumes will also be removed.",
|
|
||||||
"confirm": "Delete"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"load": "Failed to load stack",
|
|
||||||
"stop": "Stop failed",
|
|
||||||
"start": "Start failed",
|
|
||||||
"update": "Update failed",
|
|
||||||
"rollback": "Rollback failed",
|
|
||||||
"delete": "Delete failed",
|
|
||||||
"fetchLogs": "Failed to load logs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"eyebrow": "The Forge // Chronograph",
|
"eyebrow": "The Forge // Chronograph",
|
||||||
"title": "Display timezone",
|
"title": "Display timezone",
|
||||||
|
|||||||
+29
-575
@@ -18,38 +18,27 @@
|
|||||||
"eventTriggers": "Триггеры",
|
"eventTriggers": "Триггеры",
|
||||||
"logScanRules": "Лог-правила",
|
"logScanRules": "Лог-правила",
|
||||||
"triggers": "Триггеры",
|
"triggers": "Триггеры",
|
||||||
"projects": "Проекты",
|
|
||||||
"deploy": "Деплой",
|
|
||||||
"proxies": "Прокси",
|
"proxies": "Прокси",
|
||||||
"events": "События",
|
"events": "События",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"dns": "DNS-записи",
|
"dns": "DNS-записи",
|
||||||
"sites": "Сайты",
|
|
||||||
"stacks": "Стеки",
|
|
||||||
"containers": "Контейнеры"
|
"containers": "Контейнеры"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"title": "Панель управления",
|
"title": "Панель управления",
|
||||||
"quickDeploy": "Быстрый деплой",
|
"newApp": "Новое приложение",
|
||||||
"totalProjects": "Всего проектов",
|
"totalWorkloads": "Всего нагрузок",
|
||||||
"runningInstances": "Запущенных экземпляров",
|
"runningContainers": "Запущенных контейнеров",
|
||||||
"failedInstances": "Сбойных экземпляров",
|
"failedContainers": "Сбойных контейнеров",
|
||||||
"projects": "Проекты",
|
"recentWorkloads": "Недавние нагрузки",
|
||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
"noProjects": "Проектов пока нет.",
|
"noWorkloads": "Нагрузок пока нет.",
|
||||||
"addFirst": "Добавьте первый проект",
|
"noWorkloadsDesc": "Создайте приложение и выкуйте первую нагрузку, чтобы начать.",
|
||||||
"loadFailed": "Не удалось загрузить панель",
|
"loadFailed": "Не удалось загрузить панель",
|
||||||
"staleContainers": "Устаревшие контейнеры",
|
"staleContainers": "Устаревшие контейнеры",
|
||||||
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
|
"unusedImagesWarning": "Неиспользуемые Docker-образы занимают дисковое пространство",
|
||||||
"unusedImages": "неиспользуемых образов",
|
"unusedImages": "неиспользуемых образов",
|
||||||
"staticSites": "Статические сайты",
|
|
||||||
"totalSites": "Всего сайтов",
|
|
||||||
"deployedSites": "развёрнуто",
|
|
||||||
"failedSites": "с ошибкой",
|
|
||||||
"noSites": "Статических сайтов пока нет.",
|
|
||||||
"addFirstSite": "Разверните первый сайт",
|
|
||||||
"viewAllSites": "Все сайты",
|
|
||||||
"systemHealth": "Состояние системы",
|
"systemHealth": "Состояние системы",
|
||||||
"daemons": "Демоны",
|
"daemons": "Демоны",
|
||||||
"systemResources": "Системные ресурсы",
|
"systemResources": "Системные ресурсы",
|
||||||
@@ -92,240 +81,9 @@
|
|||||||
"retentionLabel": "Хранение статистики (часы)",
|
"retentionLabel": "Хранение статистики (часы)",
|
||||||
"retentionHelp": "Как долго хранятся замеры ресурсов. 0 отключает сбор. Диапазон: 0–24ч."
|
"retentionHelp": "Как долго хранятся замеры ресурсов. 0 отключает сбор. Диапазон: 0–24ч."
|
||||||
},
|
},
|
||||||
"projects": {
|
"tagPicker": {
|
||||||
"title": "Проекты",
|
|
||||||
"addProject": "Добавить проект",
|
|
||||||
"cancel": "Отмена",
|
|
||||||
"newProject": "Новый проект",
|
|
||||||
"name": "Название",
|
|
||||||
"image": "Образ",
|
|
||||||
"port": "Порт",
|
|
||||||
"registry": "Реестр",
|
"registry": "Реестр",
|
||||||
"created": "Создан",
|
"local": "Локальный"
|
||||||
"view": "Открыть",
|
|
||||||
"noProjects": "Проекты ещё не настроены.",
|
|
||||||
"getStarted": "Нажмите «Добавить проект» для начала.",
|
|
||||||
"createProject": "Создать проект",
|
|
||||||
"creating": "Создание...",
|
|
||||||
"healthcheck": "Путь проверки здоровья",
|
|
||||||
"nameRequired": "Название и образ обязательны.",
|
|
||||||
"loadFailed": "Не удалось загрузить проекты",
|
|
||||||
"createFailed": "Не удалось создать проект",
|
|
||||||
"browseImages": "Обзор образов",
|
|
||||||
"selectImage": "Выберите образ",
|
|
||||||
"noImages": "Образы не найдены",
|
|
||||||
"loadingImages": "Загрузка образов...",
|
|
||||||
"imageLoadFailed": "Не удалось загрузить образы",
|
|
||||||
"alreadyAdded": "Уже добавлен",
|
|
||||||
"portHelpText": "Автоопределение из EXPOSE, если пусто",
|
|
||||||
"healthcheckHelpText": "Автоопределение из образа, если пусто",
|
|
||||||
"searchPlaceholder": "Поиск по имени, образу или реестру...",
|
|
||||||
"noMatchingProjects": "Проекты не найдены."
|
|
||||||
},
|
|
||||||
"projectDetail": {
|
|
||||||
"webhookTitle": "Webhook проекта",
|
|
||||||
"webhookDesc": "Отправьте POST с image-ссылкой на этот URL из CI — и Tinyforge запустит деплой. Стейдж выбирается по tag_pattern.",
|
|
||||||
"outgoingWebhookTitle": "Исходящий webhook (проект)",
|
|
||||||
"outgoingWebhookDesc": "Куда Tinyforge отправляет события деплоя для этого проекта. Стейджи могут переопределить; если нигде не задано — используется глобальная настройка.",
|
|
||||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
|
||||||
"notificationUrlLabel": "URL исходящего webhook",
|
|
||||||
"notificationUrlHelp": "Оставьте пустым для наследования из глобальных настроек. Стейджи могут переопределить.",
|
|
||||||
"stageNotificationUrlLabel": "URL исходящего webhook (этот стейдж)",
|
|
||||||
"stageNotificationUrlHelp": "Оставьте пустым для наследования от проекта, затем — из глобальных настроек.",
|
|
||||||
"stageOutgoingTitle": "Исходящий webhook (стейдж)",
|
|
||||||
"stageOutgoingDesc": "Куда Tinyforge отправляет события деплоя этого стейджа. Побеждает самый конкретный уровень.",
|
|
||||||
"stageFallbackLabel": "проектной или глобальной настройки",
|
|
||||||
"deleteProject": "Удалить проект",
|
|
||||||
"envVars": "Переменные окружения",
|
|
||||||
"volumes": "Тома",
|
|
||||||
"stages": "Стадии",
|
|
||||||
"noStages": "Для этого проекта не настроены стадии.",
|
|
||||||
"pattern": "Шаблон",
|
|
||||||
"autoDeploy": "авто-деплой",
|
|
||||||
"requiresConfirm": "нужно подтверждение",
|
|
||||||
"instances": "экземпляров",
|
|
||||||
"deployNewVersion": "Развернуть новую версию",
|
|
||||||
"selectTag": "Выберите тег для деплоя",
|
|
||||||
"loadingTags": "Загрузка тегов...",
|
|
||||||
"chooseTag": "Выберите тег...",
|
|
||||||
"enterTag": "Введите тег образа (напр., dev-abc123)",
|
|
||||||
"registryTag": "Реестр",
|
|
||||||
"localTag": "Локальный",
|
|
||||||
"alsoLocal": "Также доступен локально",
|
|
||||||
"searchTags": "Поиск тегов...",
|
|
||||||
"deployTag": "Тег",
|
|
||||||
"deploy": "Развернуть",
|
|
||||||
"deploying": "Развёртывание...",
|
|
||||||
"recentDeploys": "Последние деплои",
|
|
||||||
"noDeployHistory": "Нет истории деплоев для этого проекта.",
|
|
||||||
"tag": "Тег",
|
|
||||||
"status": "Статус",
|
|
||||||
"started": "Начат",
|
|
||||||
"finished": "Завершён",
|
|
||||||
"error": "Ошибка",
|
|
||||||
"noInstancesRunning": "Нет запущенных экземпляров",
|
|
||||||
"deleteConfirmTitle": "Удалить проект",
|
|
||||||
"deleteConfirmMessage": "Это безвозвратно удалит проект '{name}' и все его стадии, экземпляры и историю деплоев.",
|
|
||||||
"loadFailed": "Не удалось загрузить проект",
|
|
||||||
"deleteFailed": "Не удалось удалить проект",
|
|
||||||
"deployFailed": "Деплой не удался",
|
|
||||||
"nameLabel": "Название *",
|
|
||||||
"imageLabel": "Образ *",
|
|
||||||
"portLabel": "Порт",
|
|
||||||
"healthcheckLabel": "Путь проверки",
|
|
||||||
"saving": "Сохранение...",
|
|
||||||
"addStage": "Добавить стадию",
|
|
||||||
"tagPattern": "Шаблон тега",
|
|
||||||
"tagPatternHelp": "Glob-шаблон (напр., dev-*, v*)",
|
|
||||||
"maxInstances": "Макс. экземпляров",
|
|
||||||
"autoDeployLabel": "Авто-деплой",
|
|
||||||
"enableProxy": "Включить прокси",
|
|
||||||
"accessListId": "ID списка доступа NPM",
|
|
||||||
"accessListIdHelp": "Переопределить глобальный список доступа для этого проекта. Очистите, чтобы наследовать из настроек NPM.",
|
|
||||||
"localImages": "Локальные Docker-образы",
|
|
||||||
"imageTag": "Тег",
|
|
||||||
"imageId": "ID образа",
|
|
||||||
"imageSize": "Размер",
|
|
||||||
"imageCreated": "Создан",
|
|
||||||
"cpuLimit": "Лимит CPU (ядра)",
|
|
||||||
"cpuLimitHelp": "напр., 0.5, 1, 2. Оставьте 0 для без ограничений",
|
|
||||||
"memoryLimit": "Лимит памяти (МБ)",
|
|
||||||
"memoryLimitHelp": "напр., 256, 512, 1024. Оставьте 0 для без ограничений",
|
|
||||||
"npmProxy": "NPM прокси",
|
|
||||||
"creating": "Создание...",
|
|
||||||
"createStage": "Создать стадию",
|
|
||||||
"noProxy": "Без прокси",
|
|
||||||
"deleteStage": "Удалить стадию",
|
|
||||||
"deleteStageConfirm": "Удалить стадию \"{name}\"?",
|
|
||||||
"stageCreated": "Стадия \"{name}\" создана",
|
|
||||||
"stageUpdated": "Стадия обновлена",
|
|
||||||
"stageUpdateFailed": "Не удалось обновить стадию",
|
|
||||||
"stageDeleted": "Стадия \"{name}\" удалена",
|
|
||||||
"projectUpdated": "Проект обновлён",
|
|
||||||
"updateFailed": "Не удалось обновить проект",
|
|
||||||
"stageCreateFailed": "Не удалось создать стадию",
|
|
||||||
"stageDeleteFailed": "Не удалось удалить стадию"
|
|
||||||
},
|
|
||||||
"envEditor": {
|
|
||||||
"title": "Переменные окружения",
|
|
||||||
"description": "Управление переопределениями переменных окружения на уровне стадий. Значения стадий переопределяют значения проекта.",
|
|
||||||
"stage": "Стадия",
|
|
||||||
"projectDefaults": "Значения проекта по умолчанию",
|
|
||||||
"noProjectEnv": "Переменные окружения на уровне проекта ещё не определены.",
|
|
||||||
"stageOverrides": "Переопределения стадии",
|
|
||||||
"key": "Ключ",
|
|
||||||
"value": "Значение",
|
|
||||||
"secret": "Секрет",
|
|
||||||
"source": "Источник",
|
|
||||||
"actions": "Действия",
|
|
||||||
"overridden": "переопределено",
|
|
||||||
"inherited": "наследуется",
|
|
||||||
"overridesProject": "переопределяет проект",
|
|
||||||
"stageOnly": "только стадия",
|
|
||||||
"edit": "Изменить",
|
|
||||||
"change": "Изменить",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"save": "Сохранить",
|
|
||||||
"add": "Добавить",
|
|
||||||
"adding": "Добавление...",
|
|
||||||
"noStages": "Стадии не настроены. Сначала добавьте стадии к проекту.",
|
|
||||||
"loadFailed": "Не удалось загрузить проект",
|
|
||||||
"envAdded": "Переменная окружения добавлена",
|
|
||||||
"envUpdated": "Переменная окружения обновлена",
|
|
||||||
"envDeleted": "Переменная окружения удалена",
|
|
||||||
"addFailed": "Не удалось добавить переменную",
|
|
||||||
"updateFailed": "Не удалось обновить переменную",
|
|
||||||
"deleteFailed": "Не удалось удалить переменную",
|
|
||||||
"loadEnvFailed": "Не удалось загрузить переменные",
|
|
||||||
"leaveEmptyToKeep": "Оставьте пустым, чтобы сохранить текущее",
|
|
||||||
"deleteTitle": "Удалить переменную окружения",
|
|
||||||
"deleteMessage": "Вы уверены, что хотите удалить эту переменную окружения? Это действие нельзя отменить."
|
|
||||||
},
|
|
||||||
"volumeEditor": {
|
|
||||||
"title": "Тома",
|
|
||||||
"description": "Настройка монтирования томов для контейнеров. Выберите область видимости для управления общим доступом между развёртываниями.",
|
|
||||||
"sourceHost": "Источник (хост)",
|
|
||||||
"targetContainer": "Цель (контейнер)",
|
|
||||||
"scope": "Область",
|
|
||||||
"nameColumn": "Имя",
|
|
||||||
"namePlaceholder": "напр. shared-db",
|
|
||||||
"requiresName": "требуется имя",
|
|
||||||
"noHostPath": "нет пути на хосте",
|
|
||||||
"tmpfs": "tmpfs (в памяти)",
|
|
||||||
"actions": "Действия",
|
|
||||||
"edit": "Изменить",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"save": "Сохранить",
|
|
||||||
"add": "Добавить",
|
|
||||||
"adding": "Добавление...",
|
|
||||||
"scopeGuide": "Области видимости томов",
|
|
||||||
"noVolumes": "Тома ещё не настроены. Добавьте один выше.",
|
|
||||||
"volumeAdded": "Том добавлен",
|
|
||||||
"volumeUpdated": "Том обновлён",
|
|
||||||
"volumeDeleted": "Том удалён",
|
|
||||||
"loadFailed": "Не удалось загрузить тома",
|
|
||||||
"addFailed": "Не удалось добавить том",
|
|
||||||
"updateFailed": "Не удалось обновить том",
|
|
||||||
"deleteFailed": "Не удалось удалить том"
|
|
||||||
},
|
|
||||||
"volumeBrowser": {
|
|
||||||
"title": "Обзор тома",
|
|
||||||
"loadFailed": "Не удалось загрузить каталог",
|
|
||||||
"empty": "Этот каталог пуст.",
|
|
||||||
"name": "Имя",
|
|
||||||
"size": "Размер",
|
|
||||||
"modified": "Изменён",
|
|
||||||
"downloadAll": "Скачать том как ZIP",
|
|
||||||
"downloadFolder": "Скачать папку как ZIP",
|
|
||||||
"upload": "Загрузить файлы",
|
|
||||||
"uploaded": "Загружено",
|
|
||||||
"files": "файл(ов)",
|
|
||||||
"uploadFailed": "Не удалось загрузить файлы",
|
|
||||||
"browse": "Обзор",
|
|
||||||
"download": "Скачать"
|
|
||||||
},
|
|
||||||
"quickDeploy": {
|
|
||||||
"title": "Быстрый деплой",
|
|
||||||
"description": "Разверните образ контейнера без настройки. Вставьте URL образа, проверьте параметры и разверните.",
|
|
||||||
"step1": "1. Введите URL образа",
|
|
||||||
"imageUrl": "URL образа",
|
|
||||||
"imageUrlHelp": "Полный URL образа с тегом (напр., git.example.com/user/app:dev-abc123)",
|
|
||||||
"inspect": "Проверить",
|
|
||||||
"inspecting": "Проверка...",
|
|
||||||
"step2": "2. Проверка конфигурации",
|
|
||||||
"reviewDesc": "Эти параметры были обнаружены из образа. Измените при необходимости перед деплоем.",
|
|
||||||
"projectName": "Имя проекта",
|
|
||||||
"port": "Порт",
|
|
||||||
"portHelp": "Порт контейнера (1-65535)",
|
|
||||||
"healthCheckPath": "Путь проверки здоровья",
|
|
||||||
"healthCheckHelp": "Необязательный HTTP-путь для проверки работоспособности",
|
|
||||||
"stage": "Стадия",
|
|
||||||
"development": "Разработка",
|
|
||||||
"release": "Релиз",
|
|
||||||
"production": "Продакшн",
|
|
||||||
"stageHelp": "Стадия развёртывания для этого образа",
|
|
||||||
"subdomainOverride": "Переопределение поддомена",
|
|
||||||
"subdomainHelp": "Оставьте пустым для использования шаблона по умолчанию",
|
|
||||||
"envVars": "Переменные окружения",
|
|
||||||
"envVarsHelp": "По одной на строку, формат KEY=VALUE",
|
|
||||||
"step3": "3. Развёртывание",
|
|
||||||
"deployDesc": "Будет создан новый проект и контейнер будет развёрнут немедленно.",
|
|
||||||
"deployBtn": "Развернуть",
|
|
||||||
"inspectedSuccess": "Образ успешно проверен",
|
|
||||||
"deployedSuccess": "{name} успешно развёрнут!",
|
|
||||||
"inspectFailed": "Не удалось проверить образ",
|
|
||||||
"deployFailed": "Развёртывание не удалось",
|
|
||||||
"browseImages": "Обзор",
|
|
||||||
"selectImage": "Выберите образ из реестра",
|
|
||||||
"noImages": "Образы не найдены",
|
|
||||||
"loadingImages": "Загрузка...",
|
|
||||||
"imageLoadFailed": "Не удалось загрузить образы",
|
|
||||||
"autoDeployLabel": "Развернуть сразу",
|
|
||||||
"lowercaseHint": "Строчные буквы и дефисы",
|
|
||||||
"imageAlreadyExists": "Образ уже развёрнут",
|
|
||||||
"conflictDescription": "Проект с этим образом уже существует. Откройте существующий проект для развёртывания новой версии или создайте отдельный проект.",
|
|
||||||
"openProject": "Открыть проект \u2192",
|
|
||||||
"createNewAnyway": "Создать новый проект"
|
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
@@ -613,117 +371,28 @@
|
|||||||
"networkError": "Ошибка сети"
|
"networkError": "Ошибка сети"
|
||||||
},
|
},
|
||||||
"proxies": {
|
"proxies": {
|
||||||
"title": "Менеджер прокси",
|
"title": "Прокси-маршруты",
|
||||||
"create": "Создать прокси",
|
"description": "Активные прокси-маршруты от контейнеров и статических сайтов.",
|
||||||
"standalone": "Автономные прокси",
|
|
||||||
"managed": "Управляемые прокси",
|
|
||||||
"noProxies": "Прокси не найдены",
|
|
||||||
"noProxiesDesc": "Создайте автономный прокси или разверните проект с включённым прокси.",
|
|
||||||
"filter": {
|
|
||||||
"search": "Поиск по домену или назначению...",
|
|
||||||
"health": "Здоровье",
|
|
||||||
"type": "Тип",
|
|
||||||
"all": "Все",
|
|
||||||
"clear": "Сбросить фильтры"
|
|
||||||
},
|
|
||||||
"health": {
|
|
||||||
"healthy": "Здоров",
|
|
||||||
"unhealthy": "Нездоров",
|
|
||||||
"unknown": "Неизвестно"
|
|
||||||
},
|
|
||||||
"lastChecked": "Последняя проверка"
|
|
||||||
},
|
|
||||||
"sites": {
|
|
||||||
"webhookTitle": "Webhook сайта",
|
|
||||||
"webhookDesc": "Укажите этот URL в push-вебхуке Git-провайдера. Tinyforge пересинхронизирует сайт при подходящей ref-ссылке (ветка для push, шаблон тега для tag). Пустое тело запускает синхронизацию безусловно.",
|
|
||||||
"outgoingUrlTitle": "URL исходящего webhook (этот сайт)",
|
|
||||||
"outgoingUrlDesc": "Куда Tinyforge отправляет события site_sync_success / site_sync_failure. Пусто — наследовать из глобальных настроек.",
|
|
||||||
"outgoingWebhookTitle": "Исходящий webhook (сайт)",
|
|
||||||
"outgoingWebhookDesc": "HMAC-секрет и тестовая отправка для разрешённого исходящего URL.",
|
|
||||||
"outgoingFallbackGlobal": "глобальной настройки интеграций",
|
|
||||||
"title": "Статические сайты",
|
|
||||||
"addSite": "Новый сайт",
|
|
||||||
"newSite": "Новый статический сайт",
|
|
||||||
"createSite": "Создать сайт",
|
|
||||||
"noSites": "Нет статических сайтов",
|
|
||||||
"noSitesDesc": "Разверните статический контент из папки Git-репозитория.",
|
|
||||||
"searchPlaceholder": "Поиск по имени, домену или репозиторию...",
|
|
||||||
"noMatching": "Нет сайтов, соответствующих поиску.",
|
|
||||||
"name": "Имя",
|
|
||||||
"domain": "Домен",
|
"domain": "Домен",
|
||||||
"mode": "Режим",
|
"project": "Проект / Сайт",
|
||||||
|
"stage": "Этап / Режим",
|
||||||
|
"tag": "Тег",
|
||||||
|
"port": "Порт",
|
||||||
"status": "Статус",
|
"status": "Статус",
|
||||||
"lastSync": "Последняя синхр.",
|
"source": "Источник",
|
||||||
"deploy": "Развернуть",
|
"sourceContainer": "Контейнер",
|
||||||
"stop": "Остановить",
|
"sourceStatic": "Статический сайт",
|
||||||
"start": "Запустить",
|
"sourceDeno": "Deno-сайт",
|
||||||
"openSite": "Открыть сайт",
|
"filterAll": "Все",
|
||||||
"confirmDelete": "Удалить сайт",
|
"filterContainers": "Контейнеры",
|
||||||
"confirmDeleteMsg": "Это удалит сайт и его контейнер",
|
"filterSites": "Сайты",
|
||||||
"confirmDeleteSecret": "Удалить секрет",
|
"noRoutes": "Нет прокси-маршрутов",
|
||||||
"confirmDeleteSecretMsg": "Вы уверены, что хотите удалить секрет",
|
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с прокси или публикации статического сайта.",
|
||||||
"siteInfo": "Информация о сайте",
|
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
|
||||||
"folder": "Папка",
|
"noMatch": "Нет маршрутов, соответствующих поиску.",
|
||||||
"syncTrigger": "Триггер синхр.",
|
"loadFailed": "Не удалось загрузить прокси-маршруты",
|
||||||
"commitSha": "Коммит SHA",
|
"route": "маршрут",
|
||||||
"secrets": "Секреты",
|
"routes": "маршрутов"
|
||||||
"addSecret": "Добавить секрет",
|
|
||||||
"noSecrets": "Секреты не настроены. Добавьте их, если сайту нужны серверные API-ключи.",
|
|
||||||
"secretKey": "Ключ",
|
|
||||||
"secretValue": "Значение",
|
|
||||||
"encryptSecret": "Шифровать значение",
|
|
||||||
"saveSecret": "Добавить секрет",
|
|
||||||
"step1Title": "1. Репозиторий",
|
|
||||||
"step2Title": "2. Выбор ветки",
|
|
||||||
"step3Title": "3. Выбор папки",
|
|
||||||
"step4Title": "4. Настройки",
|
|
||||||
"step5Title": "5. Проверка и создание",
|
|
||||||
"fullRepoUrl": "URL репозитория",
|
|
||||||
"fullRepoUrlHelp": "Вставьте полный URL для автозаполнения полей ниже (напр., https://git.example.com/owner/repo)",
|
|
||||||
"serverUrl": "URL сервера",
|
|
||||||
"repoUrl": "URL Git-сервера",
|
|
||||||
"repoUrlHelp": "Вставьте полный URL репозитория или базовый URL сервера (Gitea, Forgejo, Gogs)",
|
|
||||||
"repoOwner": "Владелец",
|
|
||||||
"repoName": "Репозиторий",
|
|
||||||
"accessToken": "Токен доступа",
|
|
||||||
"accessTokenPlaceholder": "Необязательно — для приватных репозиториев",
|
|
||||||
"accessTokenHelp": "Персональный токен с правами на чтение репозитория. Оставьте пустым для публичных.",
|
|
||||||
"noToken": "Нет (публичный репо)",
|
|
||||||
"testConnection": "Проверить соединение",
|
|
||||||
"connectionSuccess": "Репозиторий доступен",
|
|
||||||
"loadingBranches": "Загрузка веток...",
|
|
||||||
"selectBranch": "Выберите ветку",
|
|
||||||
"chooseBranch": "Выберите ветку...",
|
|
||||||
"branch": "Ветка",
|
|
||||||
"loadingTree": "Загрузка дерева репозитория...",
|
|
||||||
"selectFolder": "Выберите папку с файлами сайта",
|
|
||||||
"selectedFolder": "Выбранная папка",
|
|
||||||
"siteName": "Имя сайта",
|
|
||||||
"domainHelp": "Публичный домен сайта. Прокси будет настроен автоматически.",
|
|
||||||
"modeStaticDesc": "HTML, CSS, JS, изображения через Nginx",
|
|
||||||
"modeDenoDesc": "Статические файлы + серверный API из папки api/",
|
|
||||||
"triggerManual": "Вручную",
|
|
||||||
"triggerPush": "При пуше",
|
|
||||||
"triggerTag": "По тегу",
|
|
||||||
"tagPattern": "Паттерн тега",
|
|
||||||
"tagPatternHelp": "Glob-паттерн для тегов (напр., v*, pages-*)",
|
|
||||||
"renderMarkdown": "Рендерить Markdown-файлы в HTML",
|
|
||||||
"provider": "Git-провайдер",
|
|
||||||
"detectedProvider": "Автоопределён",
|
|
||||||
"browseRepos": "Обзор репозиториев",
|
|
||||||
"selectRepo": "Выберите репозиторий",
|
|
||||||
"storage": "Хранилище данных",
|
|
||||||
"enableStorage": "Включить хранилище данных",
|
|
||||||
"storageHelp": "Подключает Docker-том в /app/data, чтобы Deno-бэкенд мог читать и записывать файлы, сохраняющиеся между деплоями.",
|
|
||||||
"storageLimitMB": "Лимит хранилища (МБ)",
|
|
||||||
"storageLimitHelp": "Максимальный размер хранилища в мегабайтах. 0 = без ограничений.",
|
|
||||||
"storageVolume": "Том",
|
|
||||||
"dataPath": "Путь к данным",
|
|
||||||
"storageMountPath": "Путь монтирования",
|
|
||||||
"storageLimit": "Лимит",
|
|
||||||
"storageUsed": "Использовано",
|
|
||||||
"storageOfLimit": "от лимита использовано",
|
|
||||||
"unlimited": "Без ограничений"
|
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
@@ -775,24 +444,9 @@
|
|||||||
"lastSeen": "Замечен"
|
"lastSeen": "Замечен"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"instance": {
|
|
||||||
"stopConfirm": "Контейнер будет остановлен. Экземпляр можно будет запустить снова позже.",
|
|
||||||
"restartConfirm": "Контейнер будет перезапущен с кратковременным простоем.",
|
|
||||||
"removeConfirm": "Контейнер и его прокси-конфигурация будут безвозвратно удалены.",
|
|
||||||
"actionFailed": "Действие не удалось"
|
|
||||||
},
|
|
||||||
"empty": {
|
"empty": {
|
||||||
"noProjects": "Проектов пока нет",
|
|
||||||
"noProjectsDesc": "Начните с создания первого проекта или используйте быстрый деплой.",
|
|
||||||
"createProject": "Создать проект",
|
|
||||||
"noInstances": "Нет экземпляров",
|
|
||||||
"noInstancesDesc": "Разверните новую версию, чтобы увидеть экземпляры здесь.",
|
|
||||||
"noDeploys": "Нет истории деплоев",
|
|
||||||
"noDeploysDesc": "История деплоев появится здесь после первого развёртывания.",
|
|
||||||
"noRegistries": "Нет реестров",
|
"noRegistries": "Нет реестров",
|
||||||
"noRegistriesDesc": "Добавьте реестр контейнеров для обнаружения образов.",
|
"noRegistriesDesc": "Добавьте реестр контейнеров для обнаружения образов.",
|
||||||
"noVolumes": "Нет томов",
|
|
||||||
"noVolumesDesc": "Настройте монтирование томов для постоянных данных.",
|
|
||||||
"noUsers": "Нет пользователей",
|
"noUsers": "Нет пользователей",
|
||||||
"noUsersDesc": "Добавьте локальных пользователей для управления доступом."
|
"noUsersDesc": "Добавьте локальных пользователей для управления доступом."
|
||||||
},
|
},
|
||||||
@@ -808,15 +462,6 @@
|
|||||||
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
|
"requiredWhenUpdating": "Поле {field} обязательно при обновлении учётных данных",
|
||||||
"requiredForNew": "Поле {field} обязательно для новых реестров"
|
"requiredForNew": "Поле {field} обязательно для новых реестров"
|
||||||
},
|
},
|
||||||
"confirm": {
|
|
||||||
"stopInstance": "Остановить экземпляр",
|
|
||||||
"startInstance": "Запустить экземпляр",
|
|
||||||
"restartInstance": "Перезапустить экземпляр",
|
|
||||||
"removeInstance": "Удалить экземпляр",
|
|
||||||
"stopAction": "Остановить",
|
|
||||||
"restartAction": "Перезапустить",
|
|
||||||
"removeAction": "Удалить"
|
|
||||||
},
|
|
||||||
"theme": {
|
"theme": {
|
||||||
"light": "Светлая",
|
"light": "Светлая",
|
||||||
"dark": "Тёмная",
|
"dark": "Тёмная",
|
||||||
@@ -842,75 +487,6 @@
|
|||||||
"cleanupFailed": "Не удалось очистить",
|
"cleanupFailed": "Не удалось очистить",
|
||||||
"loadFailed": "Не удалось загрузить устаревшие контейнеры"
|
"loadFailed": "Не удалось загрузить устаревшие контейнеры"
|
||||||
},
|
},
|
||||||
"proxies": {
|
|
||||||
"title": "Прокси",
|
|
||||||
"create": "Создать прокси",
|
|
||||||
"noProxies": "Прокси ещё не настроены.",
|
|
||||||
"noProxiesDesc": "Создайте автономный прокси или разверните проект, чтобы увидеть прокси здесь.",
|
|
||||||
"standalone": "Автономные прокси",
|
|
||||||
"managed": "Управляемые",
|
|
||||||
"lastChecked": "Последняя проверка",
|
|
||||||
"health": {
|
|
||||||
"healthy": "Работает",
|
|
||||||
"unhealthy": "Недоступен",
|
|
||||||
"unknown": "Неизвестно"
|
|
||||||
},
|
|
||||||
"filter": {
|
|
||||||
"search": "Поиск прокси...",
|
|
||||||
"health": "Здоровье",
|
|
||||||
"type": "Тип",
|
|
||||||
"all": "Все",
|
|
||||||
"clear": "Сбросить фильтры"
|
|
||||||
},
|
|
||||||
"form": {
|
|
||||||
"title": "Создать прокси",
|
|
||||||
"editTitle": "Редактировать прокси",
|
|
||||||
"destination": "URL / IP назначения",
|
|
||||||
"port": "Порт",
|
|
||||||
"domain": "Домен",
|
|
||||||
"domainHelp": "Публичный домен для этого прокси.",
|
|
||||||
"validate": "Проверить",
|
|
||||||
"validating": "Проверка...",
|
|
||||||
"create": "Создать прокси",
|
|
||||||
"save": "Сохранить изменения",
|
|
||||||
"cancel": "Отмена",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"deleteConfirm": "Удалить этот прокси? Это действие необратимо."
|
|
||||||
},
|
|
||||||
"validation": {
|
|
||||||
"title": "Проверка назначения",
|
|
||||||
"syntax": "Синтаксис URL",
|
|
||||||
"dns": "DNS разрешение",
|
|
||||||
"tcp": "TCP подключение",
|
|
||||||
"http": "HTTP ответ",
|
|
||||||
"checking": "Проверка...",
|
|
||||||
"skipped": "Пропущено"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"proxies": {
|
|
||||||
"title": "Прокси-маршруты",
|
|
||||||
"description": "Активные прокси-маршруты от контейнеров и статических сайтов.",
|
|
||||||
"domain": "Домен",
|
|
||||||
"project": "Проект / Сайт",
|
|
||||||
"stage": "Этап / Режим",
|
|
||||||
"tag": "Тег",
|
|
||||||
"port": "Порт",
|
|
||||||
"status": "Статус",
|
|
||||||
"source": "Источник",
|
|
||||||
"sourceContainer": "Контейнер",
|
|
||||||
"sourceStatic": "Статический сайт",
|
|
||||||
"sourceDeno": "Deno-сайт",
|
|
||||||
"filterAll": "Все",
|
|
||||||
"filterContainers": "Контейнеры",
|
|
||||||
"filterSites": "Сайты",
|
|
||||||
"noRoutes": "Нет прокси-маршрутов",
|
|
||||||
"noRoutesDesc": "Прокси-маршруты создаются автоматически при развёртывании контейнера с прокси или публикации статического сайта.",
|
|
||||||
"searchPlaceholder": "Поиск по домену, проекту или тегу...",
|
|
||||||
"noMatch": "Нет маршрутов, соответствующих поиску.",
|
|
||||||
"loadFailed": "Не удалось загрузить прокси-маршруты",
|
|
||||||
"route": "маршрут",
|
|
||||||
"routes": "маршрутов"
|
|
||||||
},
|
|
||||||
"logs": {
|
"logs": {
|
||||||
"title": "Логи контейнера",
|
"title": "Логи контейнера",
|
||||||
"lines": "строк",
|
"lines": "строк",
|
||||||
@@ -1049,128 +625,6 @@
|
|||||||
"en": "Английский",
|
"en": "Английский",
|
||||||
"ru": "Русский"
|
"ru": "Русский"
|
||||||
},
|
},
|
||||||
"stacks": {
|
|
||||||
"eyebrow": "КУЗНИЦА",
|
|
||||||
"title": "Стеки",
|
|
||||||
"lede": "Compose-чертежи, выкованные как <em>атомарные единицы</em>. Запускайте сервисы, меняйте ревизии и откатывайтесь без нервов.",
|
|
||||||
"newStack": "Новый стек",
|
|
||||||
"refresh": "Обновить",
|
|
||||||
"total": "Всего",
|
|
||||||
"running": "Работают",
|
|
||||||
"deploying": "Куются",
|
|
||||||
"failed": "Сбой",
|
|
||||||
"stopped": "Холодные",
|
|
||||||
"empty": {
|
|
||||||
"title": "Наковальня остыла.",
|
|
||||||
"desc": "Загрузите docker-compose.yml, чтобы выковать первый стек."
|
|
||||||
},
|
|
||||||
"card": {
|
|
||||||
"noDescription": "Без описания",
|
|
||||||
"updated": "Обновлён",
|
|
||||||
"start": "Запустить",
|
|
||||||
"stop": "Остановить",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"open": "Открыть"
|
|
||||||
},
|
|
||||||
"new": {
|
|
||||||
"eyebrow": "НОВЫЙ ЧЕРТЁЖ",
|
|
||||||
"title": "Выковать новый стек.",
|
|
||||||
"lede": "Загрузите или вставьте <code>docker-compose.yml</code>. Все сервисы чертежа разворачиваются как одна атомарная единица.",
|
|
||||||
"back": "Стеки",
|
|
||||||
"name": "Имя",
|
|
||||||
"namePlaceholder": "мой-стек",
|
|
||||||
"nameHint": "Строчные буквы, через дефис. Используется как имя compose-проекта.",
|
|
||||||
"description": "Описание",
|
|
||||||
"descriptionPlaceholder": "Что делает этот стек?",
|
|
||||||
"composeYaml": "Compose YAML",
|
|
||||||
"required": "обязательно",
|
|
||||||
"optional": "необязательно",
|
|
||||||
"loadSample": "Загрузить пример",
|
|
||||||
"uploadFile": "Загрузить файл",
|
|
||||||
"dropHere": "Перетащите сюда docker-compose.yml",
|
|
||||||
"dropSub": "или нажмите для выбора · или используйте <strong>Загрузить пример</strong> выше",
|
|
||||||
"lines": "{n} строк",
|
|
||||||
"bytes": "{n} байт",
|
|
||||||
"clear": "Очистить",
|
|
||||||
"deployImmediate": "Развернуть сразу",
|
|
||||||
"deployHint": "Куй железо, пока горячо. Без галочки стек сохраняется холодным.",
|
|
||||||
"cancel": "Отмена",
|
|
||||||
"forging": "Куём…",
|
|
||||||
"forgeAndDeploy": "Выковать и развернуть",
|
|
||||||
"saveBlueprint": "Сохранить чертёж",
|
|
||||||
"errorRequired": "Имя и compose YAML обязательны.",
|
|
||||||
"errorCreate": "Не удалось создать стек"
|
|
||||||
},
|
|
||||||
"detail": {
|
|
||||||
"manifest": "МАНИФЕСТ",
|
|
||||||
"loading": "Загрузка чертежа…",
|
|
||||||
"composeProject": "COMPOSE-ПРОЕКТ",
|
|
||||||
"noDescription": "Без описания",
|
|
||||||
"refresh": "Обновить",
|
|
||||||
"start": "Запустить",
|
|
||||||
"stop": "Остановить",
|
|
||||||
"delete": "Удалить",
|
|
||||||
"fault": "СБОЙ",
|
|
||||||
"err": "ОШБ",
|
|
||||||
"stats": {
|
|
||||||
"services": "Сервисы",
|
|
||||||
"servicesSub": "в чертеже",
|
|
||||||
"running": "Работают",
|
|
||||||
"runningSub": "активных контейнеров",
|
|
||||||
"revisions": "Ревизии",
|
|
||||||
"revisionsSub": "в истории",
|
|
||||||
"current": "Текущая",
|
|
||||||
"currentSub": "развёрнута"
|
|
||||||
},
|
|
||||||
"services": {
|
|
||||||
"title": "Сервисы",
|
|
||||||
"count": "{n} в работе",
|
|
||||||
"empty": "— нет запущенных контейнеров —"
|
|
||||||
},
|
|
||||||
"tabs": {
|
|
||||||
"blueprint": "Чертёж",
|
|
||||||
"revisions": "Ревизии",
|
|
||||||
"logs": "Логи"
|
|
||||||
},
|
|
||||||
"yaml": {
|
|
||||||
"currentRevision": "Текущая ревизия",
|
|
||||||
"edit": "Править и развернуть",
|
|
||||||
"cancel": "Отмена",
|
|
||||||
"forging": "Куём…",
|
|
||||||
"deployNew": "Развернуть новую ревизию"
|
|
||||||
},
|
|
||||||
"revisions": {
|
|
||||||
"current": "ТЕКУЩАЯ",
|
|
||||||
"by": "автор",
|
|
||||||
"rollback": "← Откатиться к этой ревизии",
|
|
||||||
"rollbackTitle": "Откатить ревизию?",
|
|
||||||
"rollbackMessage": "Создать новую ревизию из rev {n} и развернуть стек заново.",
|
|
||||||
"rollbackConfirm": "Откатить"
|
|
||||||
},
|
|
||||||
"logs": {
|
|
||||||
"service": "Сервис:",
|
|
||||||
"allServices": "Все сервисы",
|
|
||||||
"fetching": "Загрузка…",
|
|
||||||
"fetch": "Получить логи",
|
|
||||||
"empty": "— логи не загружены. нажмите получить. —"
|
|
||||||
},
|
|
||||||
"delete": {
|
|
||||||
"title": "Удалить стек?",
|
|
||||||
"messageBase": "Будет выполнен 'docker compose down' и удалён \"{name}\".",
|
|
||||||
"messageVolumes": " Именованные тома также будут удалены.",
|
|
||||||
"confirm": "Удалить"
|
|
||||||
},
|
|
||||||
"errors": {
|
|
||||||
"load": "Не удалось загрузить стек",
|
|
||||||
"stop": "Остановка не удалась",
|
|
||||||
"start": "Запуск не удался",
|
|
||||||
"update": "Обновление не удалось",
|
|
||||||
"rollback": "Откат не удался",
|
|
||||||
"delete": "Удаление не удалось",
|
|
||||||
"fetchLogs": "Не удалось загрузить логи"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"timezone": {
|
"timezone": {
|
||||||
"eyebrow": "The Forge // Хронограф",
|
"eyebrow": "The Forge // Хронограф",
|
||||||
"title": "Часовой пояс отображения",
|
"title": "Часовой пояс отображения",
|
||||||
|
|||||||
+3
-26
@@ -156,32 +156,9 @@ export function connectSSE(url: string, options: SSEOptions): SSEConnection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Convenience Factories ──────────────────────────────────────────
|
// ── Convenience Factories ──────────────────────────────────────────
|
||||||
|
// connectDeployLogs (legacy /api/deploys/{id}/logs SSE) was retired
|
||||||
/**
|
// with the hard cutover; deploy progress now surfaces through the
|
||||||
* Connect to deploy log SSE stream for a specific deploy.
|
// global /api/events stream below.
|
||||||
* Streams existing logs first, then real-time updates.
|
|
||||||
*/
|
|
||||||
export function connectDeployLogs(
|
|
||||||
deployId: string,
|
|
||||||
callbacks: {
|
|
||||||
onLog: (log: DeployLogPayload) => void;
|
|
||||||
onStatus?: (status: DeployStatusPayload) => void;
|
|
||||||
onOpen?: () => void;
|
|
||||||
onError?: (attempt: number) => void;
|
|
||||||
}
|
|
||||||
): SSEConnection {
|
|
||||||
return connectSSE(`/api/deploys/${deployId}/logs`, {
|
|
||||||
onEvent(event) {
|
|
||||||
if (event.type === 'deploy_log') {
|
|
||||||
callbacks.onLog(event.payload as DeployLogPayload);
|
|
||||||
} else if (event.type === 'deploy_status') {
|
|
||||||
callbacks.onStatus?.(event.payload as DeployStatusPayload);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onOpen: callbacks.onOpen,
|
|
||||||
onError: callbacks.onError
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to the global events SSE stream.
|
* Connect to the global events SSE stream.
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
* so the UI can dim the badge if desired. The poller is intentionally
|
* so the UI can dim the badge if desired. The poller is intentionally
|
||||||
* forgiving: if the user is unauthenticated or the backend isn't ready,
|
* forgiving: if the user is unauthenticated or the backend isn't ready,
|
||||||
* it silently retries on the next tick.
|
* it silently retries on the next tick.
|
||||||
|
*
|
||||||
|
* Workload-first: legacy project / site / stack counts were retired with
|
||||||
|
* the hard cutover. `workloads` is now the single "things you have"
|
||||||
|
* counter, supplementing `apps` (the grouping primitive) and
|
||||||
|
* `containers` (the running instances).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { writable, type Readable } from 'svelte/store';
|
import { writable, type Readable } from 'svelte/store';
|
||||||
@@ -13,9 +18,8 @@ import * as api from '$lib/api';
|
|||||||
import { isAuthenticated } from '$lib/auth';
|
import { isAuthenticated } from '$lib/auth';
|
||||||
|
|
||||||
export interface NavCounts {
|
export interface NavCounts {
|
||||||
projects: number | null;
|
apps: number | null;
|
||||||
sites: number | null;
|
workloads: number | null;
|
||||||
stacks: number | null;
|
|
||||||
proxies: number | null;
|
proxies: number | null;
|
||||||
containers: number | null;
|
containers: number | null;
|
||||||
/** Error-severity events only; dashboard surfaces total separately. */
|
/** Error-severity events only; dashboard surfaces total separately. */
|
||||||
@@ -23,9 +27,8 @@ export interface NavCounts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY: NavCounts = {
|
const EMPTY: NavCounts = {
|
||||||
projects: null,
|
apps: null,
|
||||||
sites: null,
|
workloads: null,
|
||||||
stacks: null,
|
|
||||||
proxies: null,
|
proxies: null,
|
||||||
containers: null,
|
containers: null,
|
||||||
eventsErrors: null
|
eventsErrors: null
|
||||||
@@ -36,31 +39,31 @@ const store = writable<NavCounts>(EMPTY);
|
|||||||
export const navCounts: Readable<NavCounts> = { subscribe: store.subscribe };
|
export const navCounts: Readable<NavCounts> = { subscribe: store.subscribe };
|
||||||
|
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let inFlight = false;
|
let inFlight: AbortController | null = null;
|
||||||
|
|
||||||
async function refreshOnce(): Promise<void> {
|
async function refreshOnce(): Promise<void> {
|
||||||
if (inFlight || !isAuthenticated()) return;
|
if (inFlight || !isAuthenticated()) return;
|
||||||
inFlight = true;
|
const ac = new AbortController();
|
||||||
|
inFlight = ac;
|
||||||
try {
|
try {
|
||||||
const [projects, sites, stacks, proxies, containers, eventStats] = await Promise.allSettled([
|
const [apps, workloads, proxies, containers, eventStats] = await Promise.allSettled([
|
||||||
api.listProjects(),
|
api.listApps(ac.signal),
|
||||||
api.listStaticSites(),
|
api.listWorkloads(undefined, ac.signal),
|
||||||
api.listStacks(),
|
api.listProxyRoutes(ac.signal),
|
||||||
api.listProxyRoutes(),
|
api.listContainers({}, ac.signal),
|
||||||
api.listContainers({}),
|
api.fetchEventLogStats(ac.signal)
|
||||||
api.fetchEventLogStats()
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
store.update((prev) => ({
|
store.update((prev) => ({
|
||||||
projects: projects.status === 'fulfilled' ? projects.value.length : prev.projects,
|
apps: apps.status === 'fulfilled' ? apps.value.length : prev.apps,
|
||||||
sites: sites.status === 'fulfilled' ? sites.value.length : prev.sites,
|
workloads: workloads.status === 'fulfilled' ? workloads.value.length : prev.workloads,
|
||||||
stacks: stacks.status === 'fulfilled' ? stacks.value.length : prev.stacks,
|
|
||||||
proxies: proxies.status === 'fulfilled' ? proxies.value.length : prev.proxies,
|
proxies: proxies.status === 'fulfilled' ? proxies.value.length : prev.proxies,
|
||||||
containers: containers.status === 'fulfilled' ? containers.value.length : prev.containers,
|
containers: containers.status === 'fulfilled' ? containers.value.length : prev.containers,
|
||||||
eventsErrors: eventStats.status === 'fulfilled' ? eventStats.value.error : prev.eventsErrors
|
eventsErrors: eventStats.status === 'fulfilled' ? eventStats.value.error : prev.eventsErrors
|
||||||
}));
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
inFlight = false;
|
if (inFlight === ac) inFlight = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +82,10 @@ export function stopNavCountsPolling(): void {
|
|||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (inFlight) {
|
||||||
|
inFlight.abort();
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trigger an out-of-band refresh (e.g. after a mutation). */
|
/** Trigger an out-of-band refresh (e.g. after a mutation). */
|
||||||
|
|||||||
+21
-216
@@ -1,83 +1,13 @@
|
|||||||
// Types matching the Go backend store models (internal/store/models.go).
|
// Types matching the Go backend store models (internal/store/models.go).
|
||||||
|
//
|
||||||
export interface Project {
|
// Workload-first refactor: the canonical primitive is a Workload (with
|
||||||
id: string;
|
// a Container row per instance). Legacy Project / Stage / Stack /
|
||||||
name: string;
|
// StaticSite / Deploy / Instance / Volume types were retired with the
|
||||||
registry: string;
|
// hard cutover — the WebUI talks to /api/workloads, /api/containers,
|
||||||
image: string;
|
// /api/triggers, and friends only. The few "envelope" types still
|
||||||
port: number;
|
// useful across the surviving endpoints (ApiEnvelope, BrowseResult,
|
||||||
healthcheck: string;
|
// FileEntry, registries, settings, DNS, backups, NPM, system stats,
|
||||||
env: string;
|
// container stats, etc.) live below.
|
||||||
volumes: string;
|
|
||||||
npm_access_list_id: number;
|
|
||||||
notification_url: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Stage {
|
|
||||||
id: string;
|
|
||||||
project_id: string;
|
|
||||||
name: string;
|
|
||||||
tag_pattern: string;
|
|
||||||
auto_deploy: boolean;
|
|
||||||
max_instances: number;
|
|
||||||
confirm: boolean;
|
|
||||||
enable_proxy: boolean;
|
|
||||||
promote_from: string;
|
|
||||||
subdomain: string;
|
|
||||||
notification_url: string;
|
|
||||||
cpu_limit: number;
|
|
||||||
memory_limit: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instance is a back-compat alias: project deploys used to live in a
|
|
||||||
* dedicated `instances` table, but after the workload refactor the canonical
|
|
||||||
* row is a Container. New code should use Container directly. The fields the
|
|
||||||
* deployer always populates for project containers (workload_id, role,
|
|
||||||
* stage_id, container_id, etc.) are required on Container; the alias is a
|
|
||||||
* straight rename, not a relaxation of the type contract.
|
|
||||||
*/
|
|
||||||
export type Instance = Container;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Use {@link ContainerState} for new code. Kept around for older
|
|
||||||
* components that still narrow on the legacy four-state union.
|
|
||||||
*/
|
|
||||||
export type InstanceStatus = 'running' | 'stopped' | 'failed' | 'removing';
|
|
||||||
|
|
||||||
export interface Deploy {
|
|
||||||
id: string;
|
|
||||||
project_id: string;
|
|
||||||
stage_id: string;
|
|
||||||
instance_id: string;
|
|
||||||
image_tag: string;
|
|
||||||
status: DeployStatus;
|
|
||||||
started_at: string;
|
|
||||||
finished_at: string;
|
|
||||||
error: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DeployStatus =
|
|
||||||
| 'pending'
|
|
||||||
| 'pulling'
|
|
||||||
| 'starting'
|
|
||||||
| 'configuring_proxy'
|
|
||||||
| 'health_checking'
|
|
||||||
| 'success'
|
|
||||||
| 'failed'
|
|
||||||
| 'rolled_back';
|
|
||||||
|
|
||||||
export interface DeployLog {
|
|
||||||
id: number;
|
|
||||||
deploy_id: string;
|
|
||||||
message: string;
|
|
||||||
level: 'info' | 'warn' | 'error';
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Registry {
|
export interface Registry {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -181,12 +111,6 @@ export interface ApiEnvelope<T> {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Response shape for GET /api/projects/:id */
|
|
||||||
export interface ProjectDetail {
|
|
||||||
project: Project;
|
|
||||||
stages: Stage[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response shape for POST /api/deploy/inspect */
|
/** Response shape for POST /api/deploy/inspect */
|
||||||
export interface InspectResult {
|
export interface InspectResult {
|
||||||
image: string;
|
image: string;
|
||||||
@@ -194,17 +118,6 @@ export interface InspectResult {
|
|||||||
healthcheck: string;
|
healthcheck: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stage environment variable override. */
|
|
||||||
export interface StageEnv {
|
|
||||||
id: string;
|
|
||||||
stage_id: string;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
encrypted: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Item for the EntityPicker command-palette component. */
|
/** Item for the EntityPicker command-palette component. */
|
||||||
export interface EntityPickerItem {
|
export interface EntityPickerItem {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -217,20 +130,14 @@ export interface EntityPickerItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Volume scope determines the sharing level. */
|
/** Volume scope determines the sharing level. */
|
||||||
export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral' | 'absolute';
|
export type VolumeScope =
|
||||||
|
| 'instance'
|
||||||
/** Volume mount configuration for a project. */
|
| 'stage'
|
||||||
export interface Volume {
|
| 'project'
|
||||||
id: string;
|
| 'project_named'
|
||||||
project_id: string;
|
| 'named'
|
||||||
source: string;
|
| 'ephemeral'
|
||||||
target: string;
|
| 'absolute';
|
||||||
mode?: string;
|
|
||||||
scope: VolumeScope;
|
|
||||||
name: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scope metadata returned by GET /api/volumes/scopes. */
|
/** Scope metadata returned by GET /api/volumes/scopes. */
|
||||||
export interface VolumeScopeInfo {
|
export interface VolumeScopeInfo {
|
||||||
@@ -353,9 +260,7 @@ export interface EventLogStats {
|
|||||||
*
|
*
|
||||||
* Shape matches the Go side after the workload refactor:
|
* Shape matches the Go side after the workload refactor:
|
||||||
* the embedded Container row is the canonical state and the workload/role
|
* the embedded Container row is the canonical state and the workload/role
|
||||||
* fields decorate it for display. The legacy `instance` / `project_name` /
|
* fields decorate it for display.
|
||||||
* `stage_name` aliases are exposed as optional getters via the StaleContainerCard
|
|
||||||
* adapter so we don't have to update every consumer at once.
|
|
||||||
*/
|
*/
|
||||||
export interface StaleContainer {
|
export interface StaleContainer {
|
||||||
container: Container;
|
container: Container;
|
||||||
@@ -365,106 +270,6 @@ export interface StaleContainer {
|
|||||||
days_stale: number;
|
days_stale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A static site deployed from a Git repository folder. */
|
|
||||||
export interface StaticSite {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
provider: GitProvider;
|
|
||||||
gitea_url: string;
|
|
||||||
repo_owner: string;
|
|
||||||
repo_name: string;
|
|
||||||
branch: string;
|
|
||||||
folder_path: string;
|
|
||||||
access_token: string;
|
|
||||||
domain: string;
|
|
||||||
mode: 'static' | 'deno';
|
|
||||||
render_markdown: boolean;
|
|
||||||
sync_trigger: 'push' | 'tag' | 'manual';
|
|
||||||
tag_pattern: string;
|
|
||||||
container_id: string;
|
|
||||||
proxy_route_id: string;
|
|
||||||
status: StaticSiteStatus;
|
|
||||||
last_sync_at: string;
|
|
||||||
last_commit_sha: string;
|
|
||||||
error: string;
|
|
||||||
storage_enabled: boolean;
|
|
||||||
storage_limit_mb: number;
|
|
||||||
notification_url: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StaticSiteStorageUsage {
|
|
||||||
enabled: boolean;
|
|
||||||
used_bytes: number;
|
|
||||||
limit_mb: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
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. */
|
|
||||||
export interface StaticSiteSecret {
|
|
||||||
id: string;
|
|
||||||
site_id: string;
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
encrypted: boolean;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A repository from the Git provider's API. */
|
|
||||||
export interface RepoInfo {
|
|
||||||
owner: string;
|
|
||||||
name: string;
|
|
||||||
full_name: string;
|
|
||||||
description: string;
|
|
||||||
private: boolean;
|
|
||||||
html_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A folder entry from the Gitea repo tree. */
|
|
||||||
export interface FolderEntry {
|
|
||||||
path: string;
|
|
||||||
is_dir: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Container CPU, memory, network, and block I/O stats from the Docker stats API. */
|
/** Container CPU, memory, network, and block I/O stats from the Docker stats API. */
|
||||||
export interface ContainerStats {
|
export interface ContainerStats {
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
@@ -539,8 +344,9 @@ export interface SystemStatsSample {
|
|||||||
export type WorkloadKind = 'project' | 'stack' | 'site' | 'plugin' | (string & {});
|
export type WorkloadKind = 'project' | 'stack' | 'site' | 'plugin' | (string & {});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Workload is the unifying primitive over Project / Stack / StaticSite,
|
* Workload is the unifying primitive after the legacy Project / Stack /
|
||||||
* plus plugin-native rows whose source/trigger kinds are populated.
|
* StaticSite types were retired. Plugin-native rows whose source/trigger
|
||||||
|
* kinds are populated are the only rows the WebUI creates now.
|
||||||
*/
|
*/
|
||||||
export interface Workload {
|
export interface Workload {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -647,4 +453,3 @@ export interface App {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import Toast from '$lib/components/Toast.svelte';
|
import Toast from '$lib/components/Toast.svelte';
|
||||||
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
import ThemeToggle from '$lib/components/ThemeToggle.svelte';
|
||||||
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
import LocaleSwitcher from '$lib/components/LocaleSwitcher.svelte';
|
||||||
import { IconDashboard, IconProjects, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconGlobe, IconBox, IconContainer } from '$lib/components/icons';
|
import { IconDashboard, IconDeploy, IconEvents, IconWifi, IconSettings, IconMenu, IconX, IconLogout, IconBox, IconContainer } from '$lib/components/icons';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
import { resolvedTheme, applyTheme } from '$lib/stores/theme';
|
||||||
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
import { exchangeOidcToken, setAuthToken, clearAuth, isAuthenticated } from '$lib/auth';
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
|
|
||||||
const { children }: Props = $props();
|
const { children }: Props = $props();
|
||||||
|
|
||||||
type NavCountKey = 'projects' | 'sites' | 'stacks' | 'proxies' | 'containers' | 'eventsErrors';
|
type NavCountKey = 'apps' | 'workloads' | 'proxies' | 'containers' | 'eventsErrors';
|
||||||
|
|
||||||
const navItems: ReadonlyArray<{
|
const navItems: ReadonlyArray<{
|
||||||
href: string;
|
href: string;
|
||||||
@@ -36,12 +36,8 @@
|
|||||||
labelOverride?: string;
|
labelOverride?: string;
|
||||||
}> = [
|
}> = [
|
||||||
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
{ href: '/', labelKey: 'nav.dashboard', icon: 'dashboard' },
|
||||||
{ href: '/apps', labelKey: 'nav.apps', icon: 'box' },
|
{ href: '/apps', labelKey: 'nav.apps', icon: 'box', countKey: 'apps' },
|
||||||
{ href: '/projects', labelKey: 'nav.projects', icon: 'projects', countKey: 'projects' },
|
|
||||||
{ href: '/sites', labelKey: 'nav.sites', icon: 'globe', countKey: 'sites' },
|
|
||||||
{ href: '/stacks', labelKey: 'nav.stacks', icon: 'stacks', countKey: 'stacks' },
|
|
||||||
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
|
{ href: '/containers', labelKey: 'nav.containers', icon: 'containers', countKey: 'containers' },
|
||||||
{ href: '/deploy', labelKey: 'nav.deploy', icon: 'deploy' },
|
|
||||||
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
{ href: '/proxies', labelKey: 'nav.proxies', icon: 'proxies', countKey: 'proxies' },
|
||||||
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
{ href: '/events', labelKey: 'nav.events', icon: 'events', countKey: 'eventsErrors', alert: true },
|
||||||
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
|
{ href: '/triggers', labelKey: 'nav.triggers', icon: 'deploy' },
|
||||||
@@ -76,7 +72,7 @@
|
|||||||
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
|
const clockTitle = $derived(`${$effectiveTimezone.replace(/_/g, ' ')} · ${clockOffset}`);
|
||||||
|
|
||||||
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
|
// Keyboard quick-nav: "g" then a letter jumps to a section (vim-style).
|
||||||
// g+d → dashboard, g+p → projects, g+s → sites, g+k → stacks, g+x → deploy,
|
// g+d → dashboard, g+a → apps, g+n → containers, g+t → triggers,
|
||||||
// g+r → proxies, g+e → events, g+c → settings
|
// g+r → proxies, g+e → events, g+c → settings
|
||||||
let gPressedAt = 0;
|
let gPressedAt = 0;
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
@@ -91,8 +87,8 @@
|
|||||||
}
|
}
|
||||||
if (Date.now() - gPressedAt > 1200) return;
|
if (Date.now() - gPressedAt > 1200) return;
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
d: '/', p: '/projects', s: '/sites', k: '/stacks',
|
d: '/', a: '/apps', n: '/containers', t: '/triggers',
|
||||||
x: '/deploy', r: '/proxies', e: '/events', c: '/settings'
|
r: '/proxies', e: '/events', c: '/settings'
|
||||||
};
|
};
|
||||||
const dest = map[e.key.toLowerCase()];
|
const dest = map[e.key.toLowerCase()];
|
||||||
if (dest) {
|
if (dest) {
|
||||||
@@ -282,14 +278,8 @@
|
|||||||
>
|
>
|
||||||
{#if item.icon === 'dashboard'}
|
{#if item.icon === 'dashboard'}
|
||||||
<IconDashboard size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconDashboard 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 === 'projects'}
|
|
||||||
<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 === 'box'}
|
{:else if item.icon === 'box'}
|
||||||
<IconBox size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<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 === '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 === 'containers'}
|
{:else if item.icon === 'containers'}
|
||||||
<IconContainer size={18} class="{active ? 'text-[var(--color-brand-600)]' : 'text-[var(--text-tertiary)] group-hover:text-[var(--text-secondary)]'} transition-colors duration-150" />
|
<IconContainer 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'}
|
{:else if item.icon === 'deploy'}
|
||||||
@@ -349,7 +339,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
<p class="forge-nav-hint" title="Press 'g' then a letter to jump between sections">
|
||||||
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>p</kbd><kbd>s</kbd><kbd>k</kbd>
|
<kbd>g</kbd><span class="arr">→</span><kbd>d</kbd><kbd>a</kbd><kbd>n</kbd><kbd>t</kbd>
|
||||||
<span class="hint-label">quick-nav</span>
|
<span class="hint-label">quick-nav</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+92
-161
@@ -1,7 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Project, Instance, StaleContainer, StaticSite } from '$lib/types';
|
// Workload-first dashboard. Replaces the legacy project / site
|
||||||
|
// summaries with a single workload count grouped by source_kind and
|
||||||
|
// a running-container tally pulled from /api/containers.
|
||||||
|
//
|
||||||
|
// We no longer fan out N+1 fetches per project to gather instance
|
||||||
|
// status — the global containers index already carries the workload
|
||||||
|
// reference and state.
|
||||||
|
import type { ContainerView, StaleContainer, Workload } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import ProjectCard from '$lib/components/ProjectCard.svelte';
|
|
||||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
import SystemHealthCard from '$lib/components/SystemHealthCard.svelte';
|
||||||
@@ -9,17 +15,17 @@
|
|||||||
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
|
import SystemResourcesCard from '$lib/components/SystemResourcesCard.svelte';
|
||||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import { IconDeploy, IconAlert } from '$lib/components/icons';
|
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
||||||
|
import { IconBox, IconAlert } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
import { fmt } from '$lib/format/datetime';
|
import { fmt } from '$lib/format/datetime';
|
||||||
|
|
||||||
let projects = $state<Project[]>([]);
|
let workloads = $state<Workload[]>([]);
|
||||||
let instancesByProject = $state<Record<string, Instance[]>>({});
|
let containers = $state<ContainerView[]>([]);
|
||||||
let staleContainers = $state<StaleContainer[]>([]);
|
let staleContainers = $state<StaleContainer[]>([]);
|
||||||
let unusedImagesMB = $state(0);
|
let unusedImagesMB = $state(0);
|
||||||
let unusedImagesCount = $state(0);
|
let unusedImagesCount = $state(0);
|
||||||
let unusedImagesExceeded = $state(false);
|
let unusedImagesExceeded = $state(false);
|
||||||
let sites = $state<StaticSite[]>([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let loadController: AbortController | null = null;
|
let loadController: AbortController | null = null;
|
||||||
@@ -33,35 +39,17 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
projects = await api.listProjects(signal);
|
// Parallelize the cheap top-level reads. Each falls back to an
|
||||||
|
// empty list so a single slow daemon (e.g. Docker stats) does
|
||||||
// Fetch project details sequentially to avoid exhausting
|
// not blank the entire dashboard.
|
||||||
// browser connection pool (HTTP/1.1 allows only 6 per host).
|
const [wls, ctrs, stale] = await Promise.all([
|
||||||
const results: { projectId: string; instances: Instance[] }[] = [];
|
api.listWorkloads(undefined, signal),
|
||||||
for (const p of projects) {
|
api.listContainers({}, signal).catch(() => [] as ContainerView[]),
|
||||||
try {
|
api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[])
|
||||||
const detail = await api.getProject(p.id, signal);
|
]);
|
||||||
const stages = detail.stages ?? [];
|
workloads = wls;
|
||||||
const stageInstances: Instance[][] = [];
|
containers = ctrs;
|
||||||
for (const s of stages) {
|
staleContainers = stale;
|
||||||
stageInstances.push(await api.listInstances(p.id, s.id, signal));
|
|
||||||
}
|
|
||||||
results.push({ projectId: p.id, instances: stageInstances.flat() });
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') throw e;
|
|
||||||
results.push({ projectId: p.id, instances: [] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapped: Record<string, Instance[]> = {};
|
|
||||||
for (const r of results) {
|
|
||||||
mapped[r.projectId] = r.instances;
|
|
||||||
}
|
|
||||||
instancesByProject = mapped;
|
|
||||||
|
|
||||||
staleContainers = await api.fetchStaleContainers(signal).catch(() => [] as StaleContainer[]);
|
|
||||||
|
|
||||||
sites = await api.listStaticSites(signal).catch(() => [] as StaticSite[]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const imgStats = await api.getUnusedImageStats(signal);
|
const imgStats = await api.getUnusedImageStats(signal);
|
||||||
@@ -82,35 +70,32 @@
|
|||||||
return () => { loadController?.abort(); };
|
return () => { loadController?.abort(); };
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalProjects = $derived(projects.length);
|
const totalWorkloads = $derived(workloads.length);
|
||||||
const totalRunning = $derived(
|
const totalRunning = $derived(containers.filter((c) => c.state === 'running').length);
|
||||||
Object.values(instancesByProject)
|
const totalFailed = $derived(containers.filter((c) => c.state === 'failed').length);
|
||||||
.flat()
|
|
||||||
.filter((i) => i.state === 'running').length
|
|
||||||
);
|
|
||||||
const totalFailed = $derived(
|
|
||||||
Object.values(instancesByProject)
|
|
||||||
.flat()
|
|
||||||
.filter((i) => i.state === 'failed').length
|
|
||||||
);
|
|
||||||
const totalStale = $derived(staleContainers.length);
|
const totalStale = $derived(staleContainers.length);
|
||||||
const totalSites = $derived(sites.length);
|
|
||||||
const deployedSites = $derived(sites.filter((s) => s.status === 'deployed').length);
|
|
||||||
const failedSitesCount = $derived(sites.filter((s) => s.status === 'failed').length);
|
|
||||||
|
|
||||||
function siteStatusBadge(status: string): { text: string; cls: string } {
|
// Latest 6 workloads by updated_at desc — enough for an at-a-glance
|
||||||
switch (status) {
|
// recent-activity strip without paging the entire list.
|
||||||
case 'deployed':
|
const recentWorkloads = $derived(
|
||||||
return { text: 'Deployed', cls: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
[...workloads]
|
||||||
case 'syncing':
|
.sort((a, b) => (b.updated_at ?? '').localeCompare(a.updated_at ?? ''))
|
||||||
return { text: 'Syncing', cls: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
.slice(0, 6)
|
||||||
case 'failed':
|
);
|
||||||
return { text: 'Failed', cls: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
|
||||||
case 'stopped':
|
function containerStateFor(workloadID: string): string {
|
||||||
return { text: 'Stopped', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
// Pick the most informative state across this workload's
|
||||||
default:
|
// containers: failed > running > stopped > anything else.
|
||||||
return { text: 'Idle', cls: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
const states = containers.filter((c) => c.workload_id === workloadID).map((c) => c.state);
|
||||||
}
|
if (states.length === 0) return 'idle';
|
||||||
|
if (states.includes('failed')) return 'failed';
|
||||||
|
if (states.includes('running')) return 'running';
|
||||||
|
if (states.includes('stopped')) return 'stopped';
|
||||||
|
return states[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerCountFor(workloadID: string): number {
|
||||||
|
return containers.filter((c) => c.workload_id === workloadID).length;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -121,9 +106,9 @@
|
|||||||
<div class="space-y-6 dashboard">
|
<div class="space-y-6 dashboard">
|
||||||
<!-- Hero -->
|
<!-- Hero -->
|
||||||
{#snippet heroToolbar()}
|
{#snippet heroToolbar()}
|
||||||
<a href="/deploy" class="forge-btn">
|
<a href="/apps/new" class="forge-btn">
|
||||||
<IconDeploy size={14} />
|
<IconBox size={14} />
|
||||||
<span>{$t('dashboard.quickDeploy')}</span>
|
<span>{$t('dashboard.newApp')}</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<ForgeHero
|
<ForgeHero
|
||||||
@@ -137,35 +122,26 @@
|
|||||||
|
|
||||||
<!-- Stats grid -->
|
<!-- Stats grid -->
|
||||||
<div class="forge-stat-grid">
|
<div class="forge-stat-grid">
|
||||||
<div class="forge-stat">
|
<a href="/apps" class="forge-stat stat-link">
|
||||||
<span class="forge-stat-label">{$t('dashboard.totalProjects')}</span>
|
<span class="forge-stat-label">{$t('dashboard.totalWorkloads')}</span>
|
||||||
<span class="forge-stat-value">{String(totalProjects).padStart(2, '0')}</span>
|
<span class="forge-stat-value">{String(totalWorkloads).padStart(2, '0')}</span>
|
||||||
<span class="forge-stat-sub">active</span>
|
<span class="forge-stat-sub">workloads →</span>
|
||||||
</div>
|
</a>
|
||||||
<div class="forge-stat">
|
<a href="/containers" class="forge-stat stat-link">
|
||||||
<span class="forge-stat-label">{$t('dashboard.runningInstances')}</span>
|
<span class="forge-stat-label">{$t('dashboard.runningContainers')}</span>
|
||||||
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
<span class="forge-stat-value accent">{String(totalRunning).padStart(2, '0')}</span>
|
||||||
<span class="forge-stat-sub">instances</span>
|
<span class="forge-stat-sub">running</span>
|
||||||
</div>
|
</a>
|
||||||
<div class="forge-stat">
|
<a href="/containers?state=failed" class="forge-stat stat-link">
|
||||||
<span class="forge-stat-label">{$t('dashboard.failedInstances')}</span>
|
<span class="forge-stat-label">{$t('dashboard.failedContainers')}</span>
|
||||||
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
<span class="forge-stat-value" class:fail={totalFailed > 0}>{String(totalFailed).padStart(2, '0')}</span>
|
||||||
<span class="forge-stat-sub">need attention</span>
|
<span class="forge-stat-sub">need attention</span>
|
||||||
</div>
|
</a>
|
||||||
<a href="/containers/stale" class="forge-stat stat-link">
|
<a href="/containers/stale" class="forge-stat stat-link">
|
||||||
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
<span class="forge-stat-label">{$t('dashboard.staleContainers')}</span>
|
||||||
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
<span class="forge-stat-value" class:warn={totalStale > 0}>{String(totalStale).padStart(2, '0')}</span>
|
||||||
<span class="forge-stat-sub">stale →</span>
|
<span class="forge-stat-sub">stale →</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/sites" class="forge-stat stat-link">
|
|
||||||
<span class="forge-stat-label">{$t('dashboard.totalSites')}</span>
|
|
||||||
<span class="forge-stat-value">{String(totalSites).padStart(2, '0')}</span>
|
|
||||||
<span class="forge-stat-sub">
|
|
||||||
{#if deployedSites > 0}<span class="tag ok">{deployedSites} up</span>{/if}
|
|
||||||
{#if failedSitesCount > 0}<span class="tag bad">{failedSitesCount} fail</span>{/if}
|
|
||||||
{#if deployedSites === 0 && failedSitesCount === 0}static sites →{/if}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unused images warning -->
|
<!-- Unused images warning -->
|
||||||
@@ -201,59 +177,11 @@
|
|||||||
<SystemResourcesCard />
|
<SystemResourcesCard />
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<!-- Static sites summary -->
|
<!-- Recent workloads strip -->
|
||||||
{#if !loading}
|
|
||||||
{#snippet sitesActions()}
|
|
||||||
{#if sites.length > 0}
|
|
||||||
<a href="/sites" class="text-xs font-medium text-[var(--color-brand-600)] hover:underline">
|
|
||||||
{$t('dashboard.viewAllSites')} →
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/snippet}
|
|
||||||
<CollapsibleSection
|
|
||||||
id="dashboard-sites"
|
|
||||||
title={$t('dashboard.staticSites')}
|
|
||||||
badge={sites.length > 0 ? String(sites.length) : ''}
|
|
||||||
actions={sitesActions}
|
|
||||||
>
|
|
||||||
{#if sites.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
title={$t('dashboard.noSites')}
|
|
||||||
description={$t('dashboard.addFirstSite')}
|
|
||||||
actionLabel={$t('sites.title')}
|
|
||||||
actionHref="/sites"
|
|
||||||
icon="projects"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each sites as site (site.id)}
|
|
||||||
{@const badge = siteStatusBadge(site.status)}
|
|
||||||
<a href="/sites/{site.id}" class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]">
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<span class="truncate font-medium text-[var(--text-primary)]">{site.name}</span>
|
|
||||||
<span class="inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {badge.cls}">{badge.text}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
|
||||||
<span class="truncate">{site.repo_owner}/{site.repo_name}</span>
|
|
||||||
{#if site.domain}
|
|
||||||
<span class="truncate text-[var(--color-brand-600)]">{site.domain}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if site.last_sync_at}
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.lastSync')}: {$fmt.dateTime(site.last_sync_at)}</p>
|
|
||||||
{/if}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</CollapsibleSection>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Project cards -->
|
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
id="dashboard-projects"
|
id="dashboard-workloads"
|
||||||
title={$t('dashboard.projects')}
|
title={$t('dashboard.recentWorkloads')}
|
||||||
badge={!loading && projects.length > 0 ? String(projects.length) : ''}
|
badge={!loading && workloads.length > 0 ? String(workloads.length) : ''}
|
||||||
>
|
>
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
@@ -272,18 +200,36 @@
|
|||||||
{$t('dashboard.retry')}
|
{$t('dashboard.retry')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else if projects.length === 0}
|
{:else if workloads.length === 0}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={$t('empty.noProjects')}
|
title={$t('dashboard.noWorkloads')}
|
||||||
description={$t('empty.noProjectsDesc')}
|
description={$t('dashboard.noWorkloadsDesc')}
|
||||||
actionLabel={$t('empty.createProject')}
|
actionLabel={$t('dashboard.newApp')}
|
||||||
actionHref="/projects"
|
actionHref="/apps/new"
|
||||||
icon="projects"
|
icon="projects"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each projects as project (project.id)}
|
{#each recentWorkloads as wl (wl.id)}
|
||||||
<ProjectCard {project} instances={instancesByProject[project.id] ?? []} />
|
{@const state = containerStateFor(wl.id)}
|
||||||
|
{@const count = containerCountFor(wl.id)}
|
||||||
|
<a
|
||||||
|
href={wl.app_id ? `/apps/${wl.app_id}` : '/apps'}
|
||||||
|
class="flex flex-col gap-2 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)] transition-colors hover:bg-[var(--surface-card-hover)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="truncate font-medium text-[var(--text-primary)]">{wl.name}</span>
|
||||||
|
<StatusBadge status={state} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
|
||||||
|
<span class="truncate">{wl.source_kind || wl.kind}</span>
|
||||||
|
<span class="opacity-60">·</span>
|
||||||
|
<span>{count} {count === 1 ? $t('common.instance') : $t('common.instances')}</span>
|
||||||
|
</div>
|
||||||
|
{#if wl.updated_at}
|
||||||
|
<p class="text-xs text-[var(--text-tertiary)]">{$fmt.dateTime(wl.updated_at)}</p>
|
||||||
|
{/if}
|
||||||
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -298,19 +244,4 @@
|
|||||||
transition: background 150ms ease;
|
transition: background 150ms ease;
|
||||||
}
|
}
|
||||||
.stat-link:hover { background: var(--surface-card-hover); }
|
.stat-link:hover { background: var(--surface-card-hover); }
|
||||||
.stat-link .forge-stat-sub .tag {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 0.05rem 0.4rem;
|
|
||||||
margin-right: 0.25rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-family: var(--forge-mono);
|
|
||||||
font-size: 0.62rem;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.stat-link .tag.ok { background: var(--color-success-light); color: var(--color-success-dark); }
|
|
||||||
.stat-link .tag.bad { background: var(--color-danger-light); color: var(--color-danger-dark); }
|
|
||||||
:global([data-theme='dark']) .stat-link .tag.ok { background: color-mix(in srgb, var(--color-success) 16%, transparent); color: #86efac; }
|
|
||||||
:global([data-theme='dark']) .stat-link .tag.bad { background: color-mix(in srgb, var(--color-danger) 16%, transparent); color: #fca5a5; }
|
|
||||||
|
|
||||||
.section { margin-top: 0.5rem; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -328,11 +328,9 @@
|
|||||||
let newEnvValue = $state('');
|
let newEnvValue = $state('');
|
||||||
let newEnvEncrypted = $state(true);
|
let newEnvEncrypted = $state(true);
|
||||||
|
|
||||||
// ── Webhook ────────────────────────────────────────────────
|
// Workload-side webhook UI was removed in the hard legacy cutover —
|
||||||
let webhook = $state<api.WorkloadWebhook | null>(null);
|
// inbound webhooks are now first-class Triggers. Use the bindings
|
||||||
let webhookLoading = $state(false);
|
// panel + the /triggers detail page to manage the webhook URL.
|
||||||
let webhookError = $state('');
|
|
||||||
let regenerating = $state(false);
|
|
||||||
|
|
||||||
// ── Logs viewer ────────────────────────────────────────────
|
// ── Logs viewer ────────────────────────────────────────────
|
||||||
let logContainerRowID = $state<string | null>(null);
|
let logContainerRowID = $state<string | null>(null);
|
||||||
@@ -501,18 +499,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadWebhook() {
|
|
||||||
webhookLoading = true;
|
|
||||||
webhookError = '';
|
|
||||||
try {
|
|
||||||
webhook = await api.getWorkloadWebhook(id);
|
|
||||||
} catch (e) {
|
|
||||||
webhookError = e instanceof Error ? e.message : 'Failed to load webhook';
|
|
||||||
} finally {
|
|
||||||
webhookLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addEnv() {
|
async function addEnv() {
|
||||||
envError = '';
|
envError = '';
|
||||||
const key = newEnvKey.trim();
|
const key = newEnvKey.trim();
|
||||||
@@ -547,18 +533,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function regenerateWebhook() {
|
|
||||||
regenerating = true;
|
|
||||||
webhookError = '';
|
|
||||||
try {
|
|
||||||
webhook = await api.regenerateWorkloadWebhook(id);
|
|
||||||
} catch (e) {
|
|
||||||
webhookError = e instanceof Error ? e.message : 'Failed to rotate secret';
|
|
||||||
} finally {
|
|
||||||
regenerating = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deploy() {
|
async function deploy() {
|
||||||
deploying = true;
|
deploying = true;
|
||||||
lastDeployMsg = '';
|
lastDeployMsg = '';
|
||||||
@@ -2231,57 +2205,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ── Webhook ──────────────────────────────────── -->
|
<!-- Webhook URL panel removed — inbound webhooks live on
|
||||||
<section class="panel">
|
the bound Triggers panel above. The trigger detail page
|
||||||
<header class="panel-head split">
|
(/triggers/{id}) carries the URL + rotate action. -->
|
||||||
<h2 class="panel-title">Webhook<span class="title-accent">.</span></h2>
|
|
||||||
{#if !webhook}
|
|
||||||
<button class="forge-btn-ghost" onclick={loadWebhook} disabled={webhookLoading}>
|
|
||||||
{webhookLoading ? 'Loading…' : 'Reveal URL'}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
{#if webhookError}
|
|
||||||
<div class="alert inline-alert"><span class="alert-tag">ERR</span><span>{webhookError}</span></div>
|
|
||||||
{/if}
|
|
||||||
{#if webhook}
|
|
||||||
<p class="hint">
|
|
||||||
Point your registry or CI here. The URL itself is the credential — treat it as a
|
|
||||||
secret. Rotate any time without disrupting deploys (the next call uses the new URL).
|
|
||||||
</p>
|
|
||||||
<div class="webhook-row">
|
|
||||||
<code class="webhook-url">{webhook.webhook_url}</code>
|
|
||||||
<button
|
|
||||||
class="forge-btn-ghost"
|
|
||||||
onclick={() => copyToClipboard('webhook', webhook!.webhook_url)}
|
|
||||||
aria-label="Copy webhook URL"
|
|
||||||
>
|
|
||||||
{#if copied.webhook}
|
|
||||||
<IconCheck size={13} /><span>Copied</span>
|
|
||||||
{:else}
|
|
||||||
<IconCopy size={13} /><span>Copy</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="webhook-meta">
|
|
||||||
<span class="meta-chip" class:active={webhook.has_signing_secret}>
|
|
||||||
{webhook.has_signing_secret ? 'HMAC SIGNED' : 'UNSIGNED'}
|
|
||||||
</span>
|
|
||||||
<span class="meta-chip" class:active={webhook.webhook_require_signature}>
|
|
||||||
{webhook.webhook_require_signature ? 'SIGNATURE REQUIRED' : 'SIGNATURE OPTIONAL'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="webhook-actions">
|
|
||||||
<button
|
|
||||||
class="forge-btn-ghost danger"
|
|
||||||
onclick={regenerateWebhook}
|
|
||||||
disabled={regenerating}
|
|
||||||
>
|
|
||||||
{regenerating ? 'Rotating…' : 'Rotate secret'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ── Config viewers ───────────────────────────── -->
|
<!-- ── Config viewers ───────────────────────────── -->
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
// client-side so the tab counters reflect the whole population, not the
|
// client-side so the tab counters reflect the whole population, not the
|
||||||
// current narrowed view (otherwise picking "Project" would show All=0).
|
// current narrowed view (otherwise picking "Project" would show All=0).
|
||||||
let allContainers = $state<ContainerView[]>([]);
|
let allContainers = $state<ContainerView[]>([]);
|
||||||
let refIDByWorkload = $state<Record<string, string>>({});
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
@@ -40,15 +39,9 @@
|
|||||||
try {
|
try {
|
||||||
// Race-safety: keep the latest fetch's result and discard stragglers.
|
// Race-safety: keep the latest fetch's result and discard stragglers.
|
||||||
const seq = ++loadSeq;
|
const seq = ++loadSeq;
|
||||||
const [containers, workloads] = await Promise.all([
|
const containers = await api.listContainers({});
|
||||||
api.listContainers({}),
|
|
||||||
api.listWorkloads()
|
|
||||||
]);
|
|
||||||
if (seq !== loadSeq) return;
|
if (seq !== loadSeq) return;
|
||||||
allContainers = containers;
|
allContainers = containers;
|
||||||
const map: Record<string, string> = {};
|
|
||||||
for (const wl of workloads) map[wl.id] = wl.ref_id;
|
|
||||||
refIDByWorkload = map;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e instanceof Error ? e.message : $t('containers.errLoad');
|
error = e instanceof Error ? e.message : $t('containers.errLoad');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -127,18 +120,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function detailHref(c: ContainerView): string | undefined {
|
function detailHref(c: ContainerView): string | undefined {
|
||||||
const refID = refIDByWorkload[c.workload_id];
|
// Legacy project / stack / site detail pages were retired with the
|
||||||
if (!refID) return undefined;
|
// hard cutover. The workload-first equivalent lives under /apps —
|
||||||
switch (c.workload_kind) {
|
// every workload now belongs to an app, so the row deep-links to
|
||||||
case 'project':
|
// the app detail page when one is attached, otherwise stays flat.
|
||||||
return `/projects/${refID}`;
|
return c.app_id ? `/apps/${c.app_id}` : undefined;
|
||||||
case 'stack':
|
|
||||||
return `/stacks/${refID}`;
|
|
||||||
case 'site':
|
|
||||||
return `/sites/${refID}`;
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tabClass(active: boolean): string {
|
function tabClass(active: boolean): string {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { StaleContainer } from '$lib/types';
|
import type { StaleContainer } from '$lib/types';
|
||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import StaleContainerCard from '$lib/components/StaleContainerCard.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
||||||
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
import SkeletonCard from '$lib/components/SkeletonCard.svelte';
|
||||||
@@ -9,6 +8,7 @@
|
|||||||
import { IconTrash, IconLoader } from '$lib/components/icons';
|
import { IconTrash, IconLoader } from '$lib/components/icons';
|
||||||
import { toasts } from '$lib/stores/toast';
|
import { toasts } from '$lib/stores/toast';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
import { fmt } from '$lib/format/datetime';
|
||||||
|
|
||||||
let containers = $state<StaleContainer[]>([]);
|
let containers = $state<StaleContainer[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
@@ -19,15 +19,26 @@
|
|||||||
let cleaningIds = $state<Set<string>>(new Set());
|
let cleaningIds = $state<Set<string>>(new Set());
|
||||||
let bulkCleaning = $state(false);
|
let bulkCleaning = $state(false);
|
||||||
|
|
||||||
|
let loadController: AbortController | null = null;
|
||||||
|
|
||||||
async function loadStale() {
|
async function loadStale() {
|
||||||
|
loadController?.abort();
|
||||||
|
const ac = new AbortController();
|
||||||
|
loadController = ac;
|
||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
containers = await api.fetchStaleContainers();
|
const rows = await api.fetchStaleContainers(ac.signal);
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
|
containers = rows;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (ac.signal.aborted) return;
|
||||||
error = e instanceof Error ? e.message : $t('stale.loadFailed');
|
error = e instanceof Error ? e.message : $t('stale.loadFailed');
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
if (loadController === ac) {
|
||||||
|
loading = false;
|
||||||
|
loadController = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +79,7 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadStale();
|
loadStale();
|
||||||
|
return () => loadController?.abort();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,17 +136,124 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{#each containers as container (container.container.id)}
|
{#each containers as entry (entry.container.id)}
|
||||||
<StaleContainerCard
|
{@const c = entry.container}
|
||||||
{container}
|
{@const cleaning = cleaningIds.has(c.id)}
|
||||||
cleaning={cleaningIds.has(container.container.id)}
|
<article class="stale-card">
|
||||||
oncleanup={requestCleanup}
|
<header class="stale-card-head">
|
||||||
/>
|
<div class="stale-card-title">
|
||||||
|
<span class="stale-workload">{entry.workload_name || c.workload_id || '—'}</span>
|
||||||
|
{#if entry.role}
|
||||||
|
<span class="stale-role">/ {entry.role}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="stale-pill" title={$t('stale.daysStale')}>{entry.days_stale}d</span>
|
||||||
|
</header>
|
||||||
|
<dl class="stale-meta">
|
||||||
|
<div><dt>{$t('common.running')}</dt><dd>{c.state}</dd></div>
|
||||||
|
<div><dt>image</dt><dd class="truncate">{c.image_ref}{c.image_tag ? ':' + c.image_tag : ''}</dd></div>
|
||||||
|
{#if c.last_seen_at}
|
||||||
|
<div><dt>{$t('stale.lastAlive')}</dt><dd>{$fmt.dateTime(c.last_seen_at)}</dd></div>
|
||||||
|
{/if}
|
||||||
|
</dl>
|
||||||
|
<footer class="stale-card-foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="forge-btn-ghost forge-btn-danger"
|
||||||
|
disabled={cleaning}
|
||||||
|
onclick={() => requestCleanup(c.id)}
|
||||||
|
>
|
||||||
|
{#if cleaning}<IconLoader size={14} />{/if}
|
||||||
|
<IconTrash size={14} />
|
||||||
|
<span>{$t('stale.cleanup')}</span>
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stale-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: var(--radius-md, 0.75rem);
|
||||||
|
background: var(--surface-card);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
.stale-card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stale-card-title {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.stale-workload {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.stale-role {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
.stale-pill {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.15rem 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--color-danger-dark);
|
||||||
|
background: var(--color-danger-light);
|
||||||
|
}
|
||||||
|
.stale-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.stale-meta > div {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 5.5rem 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.stale-meta dt {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
.stale-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--forge-mono, 'JetBrains Mono', monospace);
|
||||||
|
}
|
||||||
|
.stale-card-foot {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
border-top: 1px dashed var(--border-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<!-- Single cleanup confirm -->
|
<!-- Single cleanup confirm -->
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmSingleId !== ''}
|
open={confirmSingleId !== ''}
|
||||||
|
|||||||
@@ -1,389 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { inspectImage, quickDeploy, listProjects, listRegistries, listRegistryImages } from '$lib/api';
|
|
||||||
import type { InspectResult, EntityPickerItem, Project } from '$lib/types';
|
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import { toasts } from '$lib/stores/toast';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import { IconSearch, IconDeploy, IconLoader, IconCheck } from '$lib/components/icons';
|
|
||||||
|
|
||||||
let imageUrl = $state('');
|
|
||||||
let inspecting = $state(false);
|
|
||||||
let deploying = $state(false);
|
|
||||||
let inspected = $state(false);
|
|
||||||
let inspectResult: InspectResult | null = $state(null);
|
|
||||||
|
|
||||||
let projectName = $state('');
|
|
||||||
let port = $state('');
|
|
||||||
let stage = $state('dev');
|
|
||||||
let subdomain = $state('');
|
|
||||||
let envVars = $state('');
|
|
||||||
let enableProxy = $state(true);
|
|
||||||
let autoDeploy = $state(false);
|
|
||||||
|
|
||||||
let errors = $state<Record<string, string>>({});
|
|
||||||
|
|
||||||
// Duplicate detection state
|
|
||||||
let conflictProjects = $state<Project[]>([]);
|
|
||||||
let showConflictDialog = $state(false);
|
|
||||||
|
|
||||||
// Image picker state
|
|
||||||
let showImagePicker = $state(false);
|
|
||||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
|
||||||
let imagePickerLoading = $state(false);
|
|
||||||
|
|
||||||
async function handleBrowseImages() {
|
|
||||||
showImagePicker = true;
|
|
||||||
if (imagePickerItems.length > 0) return;
|
|
||||||
|
|
||||||
imagePickerLoading = true;
|
|
||||||
try {
|
|
||||||
const registries = await listRegistries();
|
|
||||||
const items: EntityPickerItem[] = [];
|
|
||||||
for (const reg of registries) {
|
|
||||||
if (!reg.owner) continue;
|
|
||||||
try {
|
|
||||||
const images = await listRegistryImages(reg.id);
|
|
||||||
for (const img of images) {
|
|
||||||
items.push({
|
|
||||||
value: img.full_ref + ':latest',
|
|
||||||
label: img.full_ref,
|
|
||||||
description: reg.name,
|
|
||||||
group: reg.name
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip registries that fail.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imagePickerItems = items;
|
|
||||||
} catch {
|
|
||||||
toasts.error($t('quickDeploy.imageLoadFailed'));
|
|
||||||
} finally {
|
|
||||||
imagePickerLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPickedImage(value: string) {
|
|
||||||
imageUrl = value;
|
|
||||||
showImagePicker = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateImageUrl(url: string): string {
|
|
||||||
if (!url.trim()) return $t('validation.required', { field: 'Image URL' });
|
|
||||||
if (!/^[a-zA-Z0-9._\-/]+:[a-zA-Z0-9._\-]+$/.test(url.trim())) {
|
|
||||||
return $t('validation.invalidUrl');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function validatePort(value: string | number): string {
|
|
||||||
const s = String(value ?? '');
|
|
||||||
if (!s.trim()) return $t('validation.required', { field: 'Port' });
|
|
||||||
const num = parseInt(s, 10);
|
|
||||||
if (isNaN(num) || num < 1 || num > 65535) return $t('validation.invalidPort');
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateProjectName(value: string): string {
|
|
||||||
if (!value.trim()) return $t('validation.required', { field: 'Project name' });
|
|
||||||
if (value.trim().length > 1 && !/^[a-z0-9][a-z0-9\-]*[a-z0-9]$/.test(value.trim())) {
|
|
||||||
return $t('validation.invalidProjectName');
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll(): boolean {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
const nameErr = validateProjectName(projectName);
|
|
||||||
if (nameErr) newErrors.projectName = nameErr;
|
|
||||||
const portErr = validatePort(port);
|
|
||||||
if (portErr) newErrors.port = portErr;
|
|
||||||
errors = newErrors;
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function deriveProjectName(image: string): string {
|
|
||||||
const withoutTag = image.split(':')[0] ?? image;
|
|
||||||
const segments = withoutTag.split('/');
|
|
||||||
return (segments[segments.length - 1] ?? 'unknown').toLowerCase().replace(/[^a-z0-9\-]/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleInspect() {
|
|
||||||
const urlError = validateImageUrl(imageUrl);
|
|
||||||
if (urlError) {
|
|
||||||
errors = { imageUrl: urlError };
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
errors = {};
|
|
||||||
inspecting = true;
|
|
||||||
try {
|
|
||||||
const result = await inspectImage(imageUrl.trim());
|
|
||||||
inspectResult = result;
|
|
||||||
projectName = deriveProjectName(result.image);
|
|
||||||
port = result.port?.toString() ?? '';
|
|
||||||
// Healthcheck auto-detected but not shown — user can configure later on project page.
|
|
||||||
stage = 'dev';
|
|
||||||
subdomain = '';
|
|
||||||
envVars = '';
|
|
||||||
inspected = true;
|
|
||||||
toasts.success($t('quickDeploy.inspectedSuccess'));
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : $t('quickDeploy.inspectFailed');
|
|
||||||
toasts.error(message);
|
|
||||||
} finally {
|
|
||||||
inspecting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeploy(force = false) {
|
|
||||||
if (!validateAll()) return;
|
|
||||||
deploying = true;
|
|
||||||
try {
|
|
||||||
const result = await quickDeploy({ image: imageUrl.trim(), name: projectName.trim(), port: parseInt(port, 10), force, enable_proxy: enableProxy, auto_deploy: autoDeploy });
|
|
||||||
toasts.success($t('quickDeploy.deployedSuccess', { name: projectName }));
|
|
||||||
// Redirect to the new project page.
|
|
||||||
if (result.project?.id) {
|
|
||||||
goto(`/projects/${result.project.id}`);
|
|
||||||
} else {
|
|
||||||
imageUrl = '';
|
|
||||||
inspected = false;
|
|
||||||
inspectResult = null;
|
|
||||||
projectName = '';
|
|
||||||
port = '';
|
|
||||||
stage = 'dev';
|
|
||||||
subdomain = '';
|
|
||||||
envVars = '';
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
// Handle 409 Conflict — existing project with same image.
|
|
||||||
if (err instanceof Error && 'status' in err && (err as any).status === 409) {
|
|
||||||
try {
|
|
||||||
// Find existing projects with the same image.
|
|
||||||
const allProjects = await listProjects();
|
|
||||||
const imageBase = imageUrl.trim().split(':')[0];
|
|
||||||
const matching = allProjects.filter(p => p.image === imageBase || p.image === imageUrl.trim());
|
|
||||||
if (matching.length > 0) {
|
|
||||||
conflictProjects = matching;
|
|
||||||
showConflictDialog = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch { /* fall through */ }
|
|
||||||
toasts.error($t('quickDeploy.imageAlreadyExists'));
|
|
||||||
} else {
|
|
||||||
const message = err instanceof Error ? err.message : $t('quickDeploy.deployFailed');
|
|
||||||
toasts.error(message);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
deploying = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeployToExisting(project: Project) {
|
|
||||||
showConflictDialog = false;
|
|
||||||
conflictProjects = [];
|
|
||||||
goto(`/projects/${project.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleForceNewProject() {
|
|
||||||
showConflictDialog = false;
|
|
||||||
conflictProjects = [];
|
|
||||||
await handleDeploy(true);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('quickDeploy.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="mx-auto max-w-2xl space-y-6">
|
|
||||||
<ForgeHero
|
|
||||||
eyebrowSuffix="DEPLOY"
|
|
||||||
title={$t('quickDeploy.title')}
|
|
||||||
lede={$t('quickDeploy.description')}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Step 1 -->
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
|
||||||
<h2 class="mb-4 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step1')}</h2>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<FormField
|
|
||||||
label={$t('quickDeploy.imageUrl')}
|
|
||||||
name="imageUrl"
|
|
||||||
bind:value={imageUrl}
|
|
||||||
placeholder="registry.example.com/org/app:tag"
|
|
||||||
required
|
|
||||||
error={errors.imageUrl ?? ''}
|
|
||||||
helpText={$t('quickDeploy.imageUrlHelp')}
|
|
||||||
disabled={inspecting}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-start gap-2 pt-[26px]">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleBrowseImages}
|
|
||||||
title={$t('quickDeploy.browseImages')}
|
|
||||||
aria-label={$t('quickDeploy.browseImages')}
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{#if imagePickerLoading}
|
|
||||||
<IconLoader size={16} />
|
|
||||||
{:else}
|
|
||||||
<IconSearch size={16} />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={handleInspect}
|
|
||||||
disabled={inspecting || !imageUrl.trim()}
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-info)] px-4 py-2 text-sm font-medium text-white transition-all duration-150 hover:bg-[var(--color-info-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
|
||||||
>
|
|
||||||
{#if inspecting}
|
|
||||||
<IconLoader size={16} />
|
|
||||||
{$t('quickDeploy.inspecting')}
|
|
||||||
{:else}
|
|
||||||
<IconSearch size={16} />
|
|
||||||
{$t('quickDeploy.inspect')}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EntityPicker
|
|
||||||
bind:open={showImagePicker}
|
|
||||||
items={imagePickerItems}
|
|
||||||
current={imageUrl}
|
|
||||||
title={$t('quickDeploy.selectImage')}
|
|
||||||
placeholder={$t('entityPicker.search')}
|
|
||||||
onselect={selectPickedImage}
|
|
||||||
onclose={() => { showImagePicker = false; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2 -->
|
|
||||||
{#if inspected}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
|
||||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step2')}</h2>
|
|
||||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.reviewDesc')}</p>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<FormField label={$t('quickDeploy.projectName')} name="projectName" bind:value={projectName} placeholder="my-app" required error={errors.projectName ?? ''} helpText={$t('quickDeploy.lowercaseHint')} />
|
|
||||||
<FormField label={$t('quickDeploy.port')} name="port" type="number" bind:value={port} placeholder="3000" required error={errors.port ?? ''} helpText={$t('quickDeploy.portHelp')} />
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<label for="stage" class="text-sm font-medium text-[var(--text-primary)]">{$t('quickDeploy.stage')}</label>
|
|
||||||
<select id="stage" bind:value={stage} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
|
||||||
<option value="dev">{$t('quickDeploy.development')}</option>
|
|
||||||
<option value="rel">{$t('quickDeploy.release')}</option>
|
|
||||||
<option value="prod">{$t('quickDeploy.production')}</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.stageHelp')}</p>
|
|
||||||
</div>
|
|
||||||
<FormField label={$t('quickDeploy.subdomainOverride')} name="subdomain" bind:value={subdomain} placeholder="auto-generated" helpText={$t('quickDeploy.subdomainHelp')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4">
|
|
||||||
<FormField label={$t('quickDeploy.envVars')} name="envVars" type="textarea" bind:value={envVars} placeholder={"KEY=value\nANOTHER_KEY=another_value"} helpText={$t('quickDeploy.envVarsHelp')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex items-center gap-6">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ToggleSwitch bind:checked={enableProxy} label={$t('projectDetail.enableProxy')} />
|
|
||||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.enableProxy')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<ToggleSwitch bind:checked={autoDeploy} label={$t('quickDeploy.autoDeployLabel')} />
|
|
||||||
<span class="text-sm text-[var(--text-secondary)]">{$t('quickDeploy.autoDeployLabel')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3 -->
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)] animate-scale-in">
|
|
||||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('quickDeploy.step3')}</h2>
|
|
||||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('quickDeploy.deployDesc')}</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
onclick={() => handleDeploy()}
|
|
||||||
disabled={deploying}
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg bg-[var(--color-success)] px-6 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-150 hover:bg-[var(--color-success-dark)] disabled:cursor-not-allowed disabled:opacity-50 active:animate-press"
|
|
||||||
>
|
|
||||||
{#if deploying}
|
|
||||||
<IconLoader size={16} />
|
|
||||||
{$t('projectDetail.deploying')}
|
|
||||||
{:else}
|
|
||||||
<IconDeploy size={16} />
|
|
||||||
{$t('quickDeploy.deployBtn')}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => { inspected = false; inspectResult = null; }}
|
|
||||||
disabled={deploying}
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{$t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conflict dialog: image already deployed -->
|
|
||||||
{#if showConflictDialog}
|
|
||||||
<div class="fixed inset-0 z-40 bg-[var(--surface-overlay)] animate-fade-in" role="presentation" onclick={() => { showConflictDialog = false; }}></div>
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-labelledby="conflict-dialog-title"
|
|
||||||
tabindex="-1"
|
|
||||||
onkeydown={(e) => { if (e.key === 'Escape') showConflictDialog = false; }}
|
|
||||||
onclick={(e) => e.stopPropagation()}
|
|
||||||
class="w-full max-w-lg rounded-2xl bg-[var(--surface-card)] p-6 shadow-xl animate-scale-in"
|
|
||||||
>
|
|
||||||
<h3 id="conflict-dialog-title" class="text-lg font-semibold text-[var(--text-primary)]">
|
|
||||||
{$t('quickDeploy.imageAlreadyExists')}
|
|
||||||
</h3>
|
|
||||||
<p class="mt-2 text-sm text-[var(--text-secondary)]">
|
|
||||||
{$t('quickDeploy.conflictDescription')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 space-y-2">
|
|
||||||
{#each conflictProjects as project (project.id)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => handleDeployToExisting(project)}
|
|
||||||
class="flex w-full items-center justify-between rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card-hover)] p-3 text-left hover:border-[var(--color-brand-500)] transition-colors"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span class="text-sm font-medium text-[var(--text-primary)]">{project.name}</span>
|
|
||||||
<span class="ml-2 text-xs text-[var(--text-tertiary)]">{project.image}</span>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">{$t('quickDeploy.openProject')}</span>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
onclick={() => { showConflictDialog = false; }}
|
|
||||||
>
|
|
||||||
{$t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
|
||||||
onclick={handleForceNewProject}
|
|
||||||
>
|
|
||||||
{$t('quickDeploy.createNewAnyway')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Project, EntityPickerItem } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
import { IconPlus, IconSearch, IconLoader } from '$lib/components/icons';
|
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
|
||||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
|
|
||||||
let projects = $state<Project[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let showAddForm = $state(false);
|
|
||||||
let searchQuery = $state('');
|
|
||||||
|
|
||||||
const filteredProjects = $derived(
|
|
||||||
searchQuery.trim()
|
|
||||||
? projects.filter(p => {
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
return p.name.toLowerCase().includes(q)
|
|
||||||
|| p.image.toLowerCase().includes(q)
|
|
||||||
|| (p.registry ?? '').toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
: projects
|
|
||||||
);
|
|
||||||
|
|
||||||
let formName = $state('');
|
|
||||||
let formImage = $state('');
|
|
||||||
let formRegistry = $state('');
|
|
||||||
let formPort = $state('');
|
|
||||||
let formHealthcheck = $state('');
|
|
||||||
let formSubmitting = $state(false);
|
|
||||||
let formError = $state('');
|
|
||||||
|
|
||||||
// Image picker state
|
|
||||||
let showImagePicker = $state(false);
|
|
||||||
let imagePickerItems = $state<EntityPickerItem[]>([]);
|
|
||||||
let imagePickerLoading = $state(false);
|
|
||||||
|
|
||||||
async function handleBrowseImages() {
|
|
||||||
showImagePicker = true;
|
|
||||||
if (imagePickerItems.length > 0) return;
|
|
||||||
|
|
||||||
imagePickerLoading = true;
|
|
||||||
try {
|
|
||||||
const registries = await api.listRegistries();
|
|
||||||
// Collect existing project images to mark as already added.
|
|
||||||
const existingImages = new Set(projects.map(p => p.image.toLowerCase()));
|
|
||||||
const items: EntityPickerItem[] = [];
|
|
||||||
for (const reg of registries) {
|
|
||||||
if (!reg.owner) continue;
|
|
||||||
try {
|
|
||||||
const images = await api.listRegistryImages(reg.id);
|
|
||||||
for (const img of images) {
|
|
||||||
const alreadyAdded = existingImages.has(img.full_ref.toLowerCase());
|
|
||||||
items.push({
|
|
||||||
value: JSON.stringify({ full_ref: img.full_ref, registryName: reg.name }),
|
|
||||||
label: img.full_ref,
|
|
||||||
description: alreadyAdded ? undefined : reg.name,
|
|
||||||
group: reg.name,
|
|
||||||
disabled: alreadyAdded,
|
|
||||||
disabledHint: alreadyAdded ? $t('projects.alreadyAdded') : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Skip registries that fail (e.g., no owner configured).
|
|
||||||
}
|
|
||||||
}
|
|
||||||
imagePickerItems = items;
|
|
||||||
} catch {
|
|
||||||
imagePickerItems = [];
|
|
||||||
} finally {
|
|
||||||
imagePickerLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nameFromImage(imageRef: string): string {
|
|
||||||
// Extract last path segment: "git.example.com/owner/my-app" → "my-app"
|
|
||||||
const parts = imageRef.split('/');
|
|
||||||
return parts[parts.length - 1].toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPickedImage(value: string) {
|
|
||||||
const parsed = JSON.parse(value) as { full_ref: string; registryName: string };
|
|
||||||
formImage = parsed.full_ref;
|
|
||||||
formRegistry = parsed.registryName;
|
|
||||||
// Auto-fill name if empty.
|
|
||||||
if (!formName.trim()) {
|
|
||||||
formName = nameFromImage(parsed.full_ref);
|
|
||||||
}
|
|
||||||
showImagePicker = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProjects() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
projects = await api.listProjects();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('projects.loadFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddProject() {
|
|
||||||
if (!formName.trim() || !formImage.trim()) {
|
|
||||||
formError = $t('projects.nameRequired');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
formSubmitting = true;
|
|
||||||
formError = '';
|
|
||||||
try {
|
|
||||||
await api.createProject({
|
|
||||||
name: formName.trim(),
|
|
||||||
image: formImage.trim(),
|
|
||||||
registry: formRegistry.trim(),
|
|
||||||
port: parseInt(formPort, 10) || 3000,
|
|
||||||
healthcheck: formHealthcheck.trim()
|
|
||||||
});
|
|
||||||
formName = '';
|
|
||||||
formImage = '';
|
|
||||||
formRegistry = '';
|
|
||||||
formPort = '';
|
|
||||||
formHealthcheck = '';
|
|
||||||
showAddForm = false;
|
|
||||||
await loadProjects();
|
|
||||||
} catch (e) {
|
|
||||||
formError = e instanceof Error ? e.message : $t('projects.createFailed');
|
|
||||||
} finally {
|
|
||||||
formSubmitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadProjects();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('projects.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#snippet heroToolbar()}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={showAddForm ? 'forge-btn-ghost' : 'forge-btn'}
|
|
||||||
onclick={() => { showAddForm = !showAddForm; }}
|
|
||||||
>
|
|
||||||
{#if !showAddForm}<IconPlus size={14} />{/if}
|
|
||||||
<span>{showAddForm ? $t('projects.cancel') : $t('projects.addProject')}</span>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
<ForgeHero
|
|
||||||
eyebrowSuffix="PROJECTS"
|
|
||||||
title={$t('projects.title')}
|
|
||||||
size="lg"
|
|
||||||
toolbar={heroToolbar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Add project form -->
|
|
||||||
{#if showAddForm}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projects.newProject')}</h2>
|
|
||||||
|
|
||||||
{#if formError}
|
|
||||||
<div class="mt-3 rounded-lg bg-[var(--color-danger-light)] p-3">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{formError}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label="{$t('projects.name')} *" name="name" bind:value={formName} placeholder="my-web-app" required />
|
|
||||||
<div class="flex items-end gap-2">
|
|
||||||
<div class="flex-1">
|
|
||||||
<FormField label="{$t('projects.image')} *" name="image" bind:value={formImage} placeholder="registry.example.com/org/app" required />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleBrowseImages}
|
|
||||||
title={$t('projects.browseImages')}
|
|
||||||
aria-label={$t('projects.browseImages')}
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors"
|
|
||||||
>
|
|
||||||
{#if imagePickerLoading}
|
|
||||||
<IconLoader size={16} />
|
|
||||||
{:else}
|
|
||||||
<IconSearch size={16} />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<EntityPicker
|
|
||||||
bind:open={showImagePicker}
|
|
||||||
items={imagePickerItems}
|
|
||||||
current={formImage}
|
|
||||||
title={$t('projects.selectImage')}
|
|
||||||
placeholder={$t('entityPicker.search')}
|
|
||||||
onselect={selectPickedImage}
|
|
||||||
onclose={() => { showImagePicker = false; }}
|
|
||||||
/>
|
|
||||||
<FormField label={$t('projects.port')} name="port" type="number" bind:value={formPort} placeholder="3000" helpText={$t('projects.portHelpText')} />
|
|
||||||
<FormField label={$t('projects.healthcheck')} name="healthcheck" bind:value={formHealthcheck} placeholder="/api/health" helpText={$t('projects.healthcheckHelpText')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all duration-150 active:animate-press"
|
|
||||||
disabled={formSubmitting}
|
|
||||||
onclick={handleAddProject}
|
|
||||||
>
|
|
||||||
{formSubmitting ? $t('projects.creating') : $t('projects.createProject')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Projects list -->
|
|
||||||
{#if loading}
|
|
||||||
<SkeletonTable rows={4} cols={5} />
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProjects}>
|
|
||||||
{$t('common.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else if projects.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
title={$t('empty.noProjects')}
|
|
||||||
description={$t('empty.noProjectsDesc')}
|
|
||||||
actionLabel={$t('projects.addProject')}
|
|
||||||
onaction={() => { showAddForm = true; }}
|
|
||||||
icon="projects"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<!-- Search filter -->
|
|
||||||
<div class="relative">
|
|
||||||
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
placeholder={$t('projects.searchPlaceholder')}
|
|
||||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredProjects.length === 0}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('projects.noMatchingProjects')}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.name')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.image')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</th>
|
|
||||||
<th class="px-6 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#each filteredProjects as project (project.id)}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<a href="/projects/{project.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
|
||||||
{project.name}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm text-[var(--text-tertiary)]">
|
|
||||||
{project.image}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
|
||||||
{project.port || '-'}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
|
||||||
{project.registry || '-'}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
|
||||||
{$fmt.date(project.created_at)}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4 text-right text-sm">
|
|
||||||
<a href="/projects/{project.id}" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
|
||||||
{$t('projects.view')}
|
|
||||||
</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1,915 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import type { Project, Stage, Instance, Deploy, LocalImage } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import StatusBadge from '$lib/components/StatusBadge.svelte';
|
|
||||||
import InstanceCard from '$lib/components/InstanceCard.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
|
||||||
import { IconTrash, IconKey, IconHardDrive, IconDeploy, IconClock, IconPlus, IconEdit, IconCheck, IconX } from '$lib/components/icons';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
|
||||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
|
||||||
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
|
|
||||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
|
||||||
import type { EntityPickerItem } from '$lib/types';
|
|
||||||
import { IconShield } from '$lib/components/icons';
|
|
||||||
import { toasts } from '$lib/stores/toast';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
|
|
||||||
let project = $state<Project | null>(null);
|
|
||||||
let stages = $state<Stage[]>([]);
|
|
||||||
let instancesByStage = $state<Record<string, Instance[]>>({});
|
|
||||||
let deploys = $state<Deploy[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
let deployStageId = $state('');
|
|
||||||
let deployTag = $state('');
|
|
||||||
let deployLoading = $state(false);
|
|
||||||
let deployError = $state('');
|
|
||||||
|
|
||||||
|
|
||||||
// Edit stage
|
|
||||||
let editingStageId = $state('');
|
|
||||||
let editStageName = $state('');
|
|
||||||
let editStageTagPattern = $state('');
|
|
||||||
let editStageAutoDeploy = $state(true);
|
|
||||||
let editStageEnableProxy = $state(true);
|
|
||||||
let editStageMaxInstances = $state('1');
|
|
||||||
let editStageCpuLimit = $state('');
|
|
||||||
let editStageMemoryLimit = $state('');
|
|
||||||
let editStageNotificationUrl = $state('');
|
|
||||||
let savingStage = $state(false);
|
|
||||||
|
|
||||||
function startEditStage(stage: Stage) {
|
|
||||||
editingStageId = stage.id;
|
|
||||||
editStageName = stage.name;
|
|
||||||
editStageTagPattern = stage.tag_pattern;
|
|
||||||
editStageAutoDeploy = stage.auto_deploy;
|
|
||||||
editStageEnableProxy = stage.enable_proxy;
|
|
||||||
editStageMaxInstances = String(stage.max_instances);
|
|
||||||
editStageCpuLimit = stage.cpu_limit ? String(stage.cpu_limit) : '';
|
|
||||||
editStageMemoryLimit = stage.memory_limit ? String(stage.memory_limit) : '';
|
|
||||||
editStageNotificationUrl = stage.notification_url ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateStage() {
|
|
||||||
if (!editStageName.trim()) return;
|
|
||||||
savingStage = true;
|
|
||||||
try {
|
|
||||||
await api.updateStage(projectId, editingStageId, {
|
|
||||||
name: editStageName.trim(),
|
|
||||||
tag_pattern: editStageTagPattern.trim() || '*',
|
|
||||||
auto_deploy: editStageAutoDeploy,
|
|
||||||
enable_proxy: editStageEnableProxy,
|
|
||||||
max_instances: parseInt(editStageMaxInstances) || 1,
|
|
||||||
cpu_limit: parseFloat(editStageCpuLimit) || 0,
|
|
||||||
memory_limit: parseInt(editStageMemoryLimit) || 0,
|
|
||||||
notification_url: editStageNotificationUrl.trim(),
|
|
||||||
});
|
|
||||||
toasts.success($t('projectDetail.stageUpdated'));
|
|
||||||
editingStageId = '';
|
|
||||||
await loadProject();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageUpdateFailed'));
|
|
||||||
} finally {
|
|
||||||
savingStage = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add stage form
|
|
||||||
let showAddStage = $state(false);
|
|
||||||
let stageName = $state('');
|
|
||||||
let stageTagPattern = $state('*');
|
|
||||||
let stageAutoDeploy = $state(true);
|
|
||||||
let stageEnableProxy = $state(true);
|
|
||||||
let stageMaxInstances = $state('1');
|
|
||||||
let stageCpuLimit = $state('');
|
|
||||||
let stageMemoryLimit = $state('');
|
|
||||||
let addingStage = $state(false);
|
|
||||||
|
|
||||||
async function handleAddStage() {
|
|
||||||
if (!stageName.trim()) return;
|
|
||||||
addingStage = true;
|
|
||||||
try {
|
|
||||||
await api.createStage(projectId, {
|
|
||||||
name: stageName.trim(),
|
|
||||||
tag_pattern: stageTagPattern.trim() || '*',
|
|
||||||
auto_deploy: stageAutoDeploy,
|
|
||||||
enable_proxy: stageEnableProxy,
|
|
||||||
max_instances: parseInt(stageMaxInstances) || 1,
|
|
||||||
cpu_limit: parseFloat(stageCpuLimit) || 0,
|
|
||||||
memory_limit: parseInt(stageMemoryLimit) || 0,
|
|
||||||
});
|
|
||||||
toasts.success($t('projectDetail.stageCreated', { name: stageName }));
|
|
||||||
stageName = ''; stageTagPattern = '*'; stageAutoDeploy = true; stageEnableProxy = true; stageMaxInstances = '1'; stageCpuLimit = ''; stageMemoryLimit = '';
|
|
||||||
showAddStage = false;
|
|
||||||
await loadProject();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageCreateFailed'));
|
|
||||||
} finally {
|
|
||||||
addingStage = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit project
|
|
||||||
let editing = $state(false);
|
|
||||||
let editName = $state('');
|
|
||||||
let editImage = $state('');
|
|
||||||
let editPort = $state('');
|
|
||||||
let editHealthcheck = $state('');
|
|
||||||
let editAccessListId = $state(0);
|
|
||||||
let editAccessListName = $state('');
|
|
||||||
let editNotificationUrl = $state('');
|
|
||||||
let accessListPickerOpen = $state(false);
|
|
||||||
let accessListPickerItems = $state<EntityPickerItem[]>([]);
|
|
||||||
let loadingAccessLists = $state(false);
|
|
||||||
let saving = $state(false);
|
|
||||||
|
|
||||||
async function openProjectAccessListPicker() {
|
|
||||||
loadingAccessLists = true;
|
|
||||||
try {
|
|
||||||
const lists = await api.listNpmAccessLists();
|
|
||||||
if (lists.length === 0) { toasts.info($t('settingsNpm.noAccessLists')); return; }
|
|
||||||
accessListPickerItems = lists.map((al): EntityPickerItem => ({
|
|
||||||
value: String(al.id),
|
|
||||||
label: al.name || `Access List #${al.id}`,
|
|
||||||
}));
|
|
||||||
accessListPickerOpen = true;
|
|
||||||
} catch (err) {
|
|
||||||
toasts.error(err instanceof Error ? err.message : $t('settingsNpm.accessListLoadFailed'));
|
|
||||||
} finally { loadingAccessLists = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleProjectAccessListSelect(value: string) {
|
|
||||||
editAccessListId = parseInt(value, 10);
|
|
||||||
const item = accessListPickerItems.find((i) => i.value === value);
|
|
||||||
editAccessListName = item?.label ?? '';
|
|
||||||
accessListPickerOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearProjectAccessList() {
|
|
||||||
editAccessListId = 0;
|
|
||||||
editAccessListName = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditing() {
|
|
||||||
if (!project) return;
|
|
||||||
editName = project.name;
|
|
||||||
editImage = project.image;
|
|
||||||
editPort = String(project.port || '');
|
|
||||||
editHealthcheck = project.healthcheck || '';
|
|
||||||
editAccessListId = project.npm_access_list_id || 0;
|
|
||||||
editAccessListName = editAccessListId > 0 ? `Access List #${editAccessListId}` : '';
|
|
||||||
editNotificationUrl = project.notification_url ?? '';
|
|
||||||
editing = true;
|
|
||||||
// Resolve access list name in background.
|
|
||||||
if (editAccessListId > 0) {
|
|
||||||
api.listNpmAccessLists().then(lists => {
|
|
||||||
const match = lists.find(al => al.id === editAccessListId);
|
|
||||||
if (match) editAccessListName = match.name;
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveProject() {
|
|
||||||
if (!editName.trim() || !editImage.trim()) return;
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
await api.updateProject(projectId, {
|
|
||||||
name: editName.trim(),
|
|
||||||
image: editImage.trim(),
|
|
||||||
port: parseInt(editPort) || 0,
|
|
||||||
healthcheck: editHealthcheck.trim(),
|
|
||||||
npm_access_list_id: editAccessListId,
|
|
||||||
notification_url: editNotificationUrl.trim(),
|
|
||||||
});
|
|
||||||
toasts.success($t('projectDetail.projectUpdated'));
|
|
||||||
editing = false;
|
|
||||||
await loadProject();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.updateFailed'));
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteStage(stageId: string, name: string) {
|
|
||||||
try {
|
|
||||||
await api.deleteStage(projectId, stageId);
|
|
||||||
// Update local state immediately so the UI reflects the change.
|
|
||||||
stages = stages.filter((s) => s.id !== stageId);
|
|
||||||
const { [stageId]: _, ...rest } = instancesByStage;
|
|
||||||
instancesByStage = rest;
|
|
||||||
toasts.success($t('projectDetail.stageDeleted', { name }));
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('projectDetail.stageDeleteFailed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let settingsDomain = $state('');
|
|
||||||
let localImages = $state<LocalImage[]>([]);
|
|
||||||
|
|
||||||
let showDeleteConfirm = $state(false);
|
|
||||||
let stageDeleteTarget = $state<{ id: string; name: string } | null>(null);
|
|
||||||
let loadController: AbortController | null = null;
|
|
||||||
|
|
||||||
const projectId = $derived($page.params.id!); // always present on [id] route
|
|
||||||
|
|
||||||
async function loadProject() {
|
|
||||||
// Abort any previous in-flight load before starting a new one.
|
|
||||||
loadController?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
loadController = controller;
|
|
||||||
const signal = controller.signal;
|
|
||||||
|
|
||||||
if (!project) loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const detail = await api.getProject(projectId, signal);
|
|
||||||
project = detail.project;
|
|
||||||
stages = detail.stages ?? [];
|
|
||||||
|
|
||||||
const instanceResults = await Promise.all(
|
|
||||||
stages.map(async (s) => {
|
|
||||||
try {
|
|
||||||
const instances = await api.listInstances(projectId, s.id, signal);
|
|
||||||
return { stageId: s.id, instances };
|
|
||||||
} catch {
|
|
||||||
return { stageId: s.id, instances: [] };
|
|
||||||
}
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapped: Record<string, Instance[]> = {};
|
|
||||||
for (const r of instanceResults) {
|
|
||||||
mapped[r.stageId] = r.instances;
|
|
||||||
}
|
|
||||||
instancesByStage = mapped;
|
|
||||||
|
|
||||||
// Fetch deploys, settings, and images in parallel (independent of each other).
|
|
||||||
const [deploysResult, settingsResult, imagesResult] = await Promise.allSettled([
|
|
||||||
api.listDeploys(20, signal),
|
|
||||||
api.getSettings(signal),
|
|
||||||
api.listProjectImages(projectId, signal)
|
|
||||||
]);
|
|
||||||
|
|
||||||
deploys = deploysResult.status === 'fulfilled'
|
|
||||||
? deploysResult.value.filter((d) => d.project_id === projectId)
|
|
||||||
: [];
|
|
||||||
settingsDomain = settingsResult.status === 'fulfilled'
|
|
||||||
? (settingsResult.value.domain ?? '')
|
|
||||||
: settingsDomain;
|
|
||||||
localImages = imagesResult.status === 'fulfilled'
|
|
||||||
? imagesResult.value
|
|
||||||
: [];
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
|
||||||
error = e instanceof Error ? e.message : $t('projectDetail.loadFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let tagPickerOpen = $state(false);
|
|
||||||
let tagPickerItems = $state<EntityPickerItem[]>([]);
|
|
||||||
|
|
||||||
async function openTagPicker(stageId: string) {
|
|
||||||
deployStageId = stageId;
|
|
||||||
deployTag = '';
|
|
||||||
|
|
||||||
// Build local image suggestions.
|
|
||||||
const imgs = localImages;
|
|
||||||
const localItems: EntityPickerItem[] = imgs
|
|
||||||
.filter((img) => img.tag)
|
|
||||||
.map((img) => ({
|
|
||||||
value: img.tag,
|
|
||||||
label: img.tag,
|
|
||||||
group: $t('projectDetail.localTag'),
|
|
||||||
description: `${(img.size / (1024 * 1024)).toFixed(0)} MB`
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Try to fetch registry tags.
|
|
||||||
let registryItems: EntityPickerItem[] = [];
|
|
||||||
try {
|
|
||||||
const registries = await api.listRegistries();
|
|
||||||
// Match by registry URL hostname (project.registry stores the hostname)
|
|
||||||
// or by name, or try all registries if project.registry is empty.
|
|
||||||
const projectRegistry = project?.registry || '';
|
|
||||||
const projectImage = project?.image || '';
|
|
||||||
|
|
||||||
let reg = registries.find(r => {
|
|
||||||
if (!projectRegistry) return false;
|
|
||||||
const urlHost = new URL(r.url).hostname;
|
|
||||||
return r.name === projectRegistry || urlHost === projectRegistry;
|
|
||||||
});
|
|
||||||
|
|
||||||
// If project has no registry set but image contains a hostname, try matching by image prefix.
|
|
||||||
if (!reg && projectImage.includes('/')) {
|
|
||||||
const imageHost = projectImage.split('/')[0];
|
|
||||||
if (imageHost.includes('.')) {
|
|
||||||
reg = registries.find(r => {
|
|
||||||
try { return new URL(r.url).hostname === imageHost; } catch { return false; }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (reg) {
|
|
||||||
// Strip registry hostname from image if present (registry API expects owner/name).
|
|
||||||
let imageForRegistry = projectImage;
|
|
||||||
try {
|
|
||||||
const urlHost = new URL(reg.url).hostname;
|
|
||||||
if (imageForRegistry.startsWith(urlHost + '/')) {
|
|
||||||
imageForRegistry = imageForRegistry.substring(urlHost.length + 1);
|
|
||||||
}
|
|
||||||
} catch { /* keep as-is */ }
|
|
||||||
|
|
||||||
const tags = await api.listRegistryTags(reg.id, imageForRegistry);
|
|
||||||
const localTagSet = new Set(imgs.map((img) => img.tag));
|
|
||||||
registryItems = tags.map((tag) => ({
|
|
||||||
value: tag,
|
|
||||||
label: tag,
|
|
||||||
group: $t('projectDetail.registryTag'),
|
|
||||||
description: localTagSet.has(tag) ? $t('projectDetail.alsoLocal') : undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch { /* ignore registry errors */ }
|
|
||||||
|
|
||||||
// Merge: registry tags first, then local-only tags.
|
|
||||||
if (registryItems.length > 0) {
|
|
||||||
const registryTagSet = new Set(registryItems.map((item) => item.value));
|
|
||||||
const localOnly = localItems.filter((item) => !registryTagSet.has(item.value));
|
|
||||||
tagPickerItems = [...registryItems, ...localOnly];
|
|
||||||
} else {
|
|
||||||
tagPickerItems = localItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
tagPickerOpen = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTagSelect(tag: string) {
|
|
||||||
deployTag = tag;
|
|
||||||
tagPickerOpen = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeploy() {
|
|
||||||
if (!deployTag.trim() || !deployStageId) return;
|
|
||||||
|
|
||||||
deployLoading = true;
|
|
||||||
deployError = '';
|
|
||||||
try {
|
|
||||||
await api.deployInstance(projectId, deployStageId, deployTag.trim());
|
|
||||||
deployTag = '';
|
|
||||||
deployStageId = '';
|
|
||||||
await loadProject();
|
|
||||||
} catch (e) {
|
|
||||||
deployError = e instanceof Error ? e.message : $t('projectDetail.deployFailed');
|
|
||||||
} finally {
|
|
||||||
deployLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let deleted = $state(false);
|
|
||||||
|
|
||||||
async function handleDeleteProject() {
|
|
||||||
showDeleteConfirm = false;
|
|
||||||
deleted = true;
|
|
||||||
try {
|
|
||||||
await api.deleteProject(projectId);
|
|
||||||
goto('/projects');
|
|
||||||
} catch (e) {
|
|
||||||
deleted = false;
|
|
||||||
error = e instanceof Error ? e.message : $t('projectDetail.deleteFailed');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void projectId;
|
|
||||||
untrack(() => {
|
|
||||||
if (!deleted) loadProject();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
loadController?.abort();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{project?.name ?? $t('common.project')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="space-y-6">
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Skeleton width="4rem" height="0.875rem" />
|
|
||||||
<Skeleton width="12rem" height="1.75rem" />
|
|
||||||
<Skeleton width="16rem" height="0.875rem" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-4 gap-4">
|
|
||||||
{#each Array(4) as _}
|
|
||||||
<Skeleton height="3rem" />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadProject}>
|
|
||||||
{$t('common.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else if project}
|
|
||||||
{@const p = project}
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#snippet projectToolbar()}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="forge-btn-ghost forge-btn-danger"
|
|
||||||
onclick={() => { showDeleteConfirm = true; }}
|
|
||||||
>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
<span>{$t('projectDetail.deleteProject')}</span>
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
<ForgeHero
|
|
||||||
backHref="/projects"
|
|
||||||
eyebrowSuffix="PROJECT"
|
|
||||||
title={p.name}
|
|
||||||
kicker={p.image}
|
|
||||||
size="lg"
|
|
||||||
toolbar={projectToolbar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Project settings links -->
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<a
|
|
||||||
href="/projects/{projectId}/env"
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconKey size={16} />
|
|
||||||
{$t('projectDetail.envVars')}
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
href="/projects/{projectId}/volumes"
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconHardDrive size={16} />
|
|
||||||
{$t('projectDetail.volumes')}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Project info -->
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-5 shadow-[var(--shadow-sm)]">
|
|
||||||
{#if editing}
|
|
||||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
||||||
<FormField label={$t('projectDetail.nameLabel')} name="editName" bind:value={editName} />
|
|
||||||
<FormField label={$t('projectDetail.imageLabel')} name="editImage" bind:value={editImage} />
|
|
||||||
<FormField label={$t('projectDetail.portLabel')} name="editPort" type="number" bind:value={editPort} />
|
|
||||||
<FormField label={$t('projectDetail.healthcheckLabel')} name="editHealthcheck" bind:value={editHealthcheck} placeholder="/api/health" />
|
|
||||||
<FormField label={$t('projectDetail.notificationUrlLabel')} name="editNotificationUrl" bind:value={editNotificationUrl} placeholder="https://notify.example.com/webhook" helpText={$t('projectDetail.notificationUrlHelp')} />
|
|
||||||
<div class="flex flex-col gap-1.5">
|
|
||||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('settingsNpm.accessList')}</label>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button type="button" onclick={openProjectAccessListPicker} disabled={loadingAccessLists}
|
|
||||||
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-2 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50">
|
|
||||||
<IconShield size={14} />
|
|
||||||
{#if loadingAccessLists}
|
|
||||||
{$t('common.loading')}
|
|
||||||
{:else if editAccessListId > 0 && editAccessListName}
|
|
||||||
{editAccessListName}
|
|
||||||
{:else}
|
|
||||||
{$t('settingsNpm.noAccessList')}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
{#if editAccessListId > 0}
|
|
||||||
<button type="button" onclick={clearProjectAccessList}
|
|
||||||
class="rounded-lg border border-[var(--border-input)] px-2 py-2 text-sm text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors">
|
|
||||||
<IconX size={14} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('projectDetail.accessListIdHelp')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex items-center gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { editing = false; }}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconX size={14} />
|
|
||||||
{$t('projects.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={saveProject}
|
|
||||||
disabled={saving || !editName.trim() || !editImage.trim()}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
<IconCheck size={14} />
|
|
||||||
{saving ? $t('projectDetail.saving') : $t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-start justify-between">
|
|
||||||
<div class="grid grid-cols-2 gap-4 flex-1 sm:grid-cols-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.port')}</p>
|
|
||||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.port || 'Auto'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.healthcheck')}</p>
|
|
||||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.healthcheck || 'Auto'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.registry')}</p>
|
|
||||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{project.registry || '-'}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projects.created')}</p>
|
|
||||||
<p class="mt-1 text-sm text-[var(--text-primary)]">{$fmt.date(project.created_at)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={startEditing}
|
|
||||||
title={$t('common.edit')}
|
|
||||||
class="rounded-lg p-2 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconEdit size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stages & Instances -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.stages')}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { showAddStage = !showAddStage; }}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg {showAddStage ? 'border border-[var(--border-primary)] text-[var(--text-secondary)]' : 'bg-[var(--color-brand-600)] text-white'} px-3 py-1.5 text-xs font-medium transition-all hover:opacity-90"
|
|
||||||
>
|
|
||||||
{#if !showAddStage}<IconPlus size={14} />{/if}
|
|
||||||
{showAddStage ? $t('projects.cancel') : $t('projectDetail.addStage')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showAddStage}
|
|
||||||
<div class="mt-3 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 animate-scale-in">
|
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
||||||
<FormField label={$t('projectDetail.nameLabel')} name="stageName" bind:value={stageName} placeholder="dev" />
|
|
||||||
<FormField label={$t('projectDetail.tagPattern')} name="stagePattern" bind:value={stageTagPattern} placeholder="dev-*" helpText={$t('projectDetail.tagPatternHelp')} />
|
|
||||||
<FormField label={$t('projectDetail.maxInstances')} name="stageMax" type="number" bind:value={stageMaxInstances} />
|
|
||||||
<FormField label={$t('projectDetail.cpuLimit')} name="stageCpu" type="number" bind:value={stageCpuLimit} placeholder="0" helpText={$t('projectDetail.cpuLimitHelp')} />
|
|
||||||
<FormField label={$t('projectDetail.memoryLimit')} name="stageMem" type="number" bind:value={stageMemoryLimit} placeholder="0" helpText={$t('projectDetail.memoryLimitHelp')} />
|
|
||||||
<div class="flex gap-4 items-end pb-1">
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
|
||||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
|
||||||
<ToggleSwitch bind:checked={stageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
|
||||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
|
||||||
<ToggleSwitch bind:checked={stageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={handleAddStage}
|
|
||||||
disabled={addingStage || !stageName.trim()}
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-all"
|
|
||||||
>
|
|
||||||
{addingStage ? $t('projectDetail.creating') : $t('projectDetail.createStage')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if stages.length === 0 && !showAddStage}
|
|
||||||
<div class="mt-4">
|
|
||||||
<EmptyState title={$t('projectDetail.noStages')} icon="instances" />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="mt-4 space-y-4">
|
|
||||||
{#each stages as stage (stage.id)}
|
|
||||||
{@const stageInstances = instancesByStage[stage.id] ?? []}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<!-- Stage header -->
|
|
||||||
{#if editingStageId === stage.id}
|
|
||||||
<div class="border-b border-[var(--border-secondary)] px-5 py-4">
|
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
|
||||||
<FormField label={$t('projectDetail.nameLabel')} name="editStageName" bind:value={editStageName} />
|
|
||||||
<FormField label={$t('projectDetail.tagPattern')} name="editStagePattern" bind:value={editStageTagPattern} />
|
|
||||||
<FormField label={$t('projectDetail.maxInstances')} name="editStageMax" type="number" bind:value={editStageMaxInstances} />
|
|
||||||
<FormField label={$t('projectDetail.cpuLimit')} name="editStageCpu" type="number" bind:value={editStageCpuLimit} placeholder="0" />
|
|
||||||
<FormField label={$t('projectDetail.memoryLimit')} name="editStageMem" type="number" bind:value={editStageMemoryLimit} placeholder="0" />
|
|
||||||
<div class="flex gap-4 items-end pb-1">
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
|
||||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.autoDeployLabel')}</span>
|
|
||||||
<ToggleSwitch bind:checked={editStageAutoDeploy} label={$t('projectDetail.autoDeployLabel')} />
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col items-center gap-1">
|
|
||||||
<span class="text-xs font-medium text-[var(--text-tertiary)]">{$t('projectDetail.enableProxy')}</span>
|
|
||||||
<ToggleSwitch bind:checked={editStageEnableProxy} label={$t('projectDetail.enableProxy')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3">
|
|
||||||
<FormField
|
|
||||||
label={$t('projectDetail.stageNotificationUrlLabel')}
|
|
||||||
name="editStageNotificationUrl"
|
|
||||||
bind:value={editStageNotificationUrl}
|
|
||||||
placeholder="https://notify.example.com/webhook"
|
|
||||||
helpText={$t('projectDetail.stageNotificationUrlHelp')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center gap-2 justify-end">
|
|
||||||
<button type="button" onclick={() => { editingStageId = ''; }}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-1.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors">
|
|
||||||
<IconX size={14} />
|
|
||||||
{$t('projects.cancel')}
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick={handleUpdateStage} disabled={savingStage || !editStageName.trim()}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors">
|
|
||||||
<IconCheck size={14} />
|
|
||||||
{savingStage ? $t('projectDetail.saving') : $t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<!-- Stage-scoped outgoing webhook controls. Lives inside the
|
|
||||||
edit panel so operators see signing + test alongside the
|
|
||||||
URL they're configuring; collapses on save/cancel. -->
|
|
||||||
<div class="mt-4">
|
|
||||||
<OutgoingWebhookPanel
|
|
||||||
title={$t('projectDetail.stageOutgoingTitle')}
|
|
||||||
description={$t('projectDetail.stageOutgoingDesc')}
|
|
||||||
hasUrl={!!stage.notification_url}
|
|
||||||
fallbackLabel={$t('projectDetail.stageFallbackLabel')}
|
|
||||||
fetchSecret={() => api.getStageNotificationSecret(projectId, stage.id)}
|
|
||||||
regenerateSecret={() => api.regenerateStageNotificationSecret(projectId, stage.id)}
|
|
||||||
disableSigning={() => api.disableStageNotificationSigning(projectId, stage.id)}
|
|
||||||
sendTest={() => api.testStageNotification(projectId, stage.id)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2 border-b border-[var(--border-secondary)] px-5 py-4">
|
|
||||||
<div class="flex items-center gap-3 flex-wrap">
|
|
||||||
<h3 class="text-base font-semibold text-[var(--text-primary)]">{stage.name}</h3>
|
|
||||||
<span class="rounded-md bg-[var(--surface-card-hover)] px-2 py-0.5 font-mono text-xs text-[var(--text-tertiary)]">{stage.tag_pattern}</span>
|
|
||||||
{#if stage.auto_deploy}
|
|
||||||
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.autoDeploy')}</span>
|
|
||||||
{/if}
|
|
||||||
{#if stage.confirm}
|
|
||||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.requiresConfirm')}</span>
|
|
||||||
{/if}
|
|
||||||
{#if !stage.enable_proxy}
|
|
||||||
<span class="rounded-full badge-gray rounded-full px-2 py-0.5 text-xs font-medium">{$t('projectDetail.noProxy')}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">
|
|
||||||
{stageInstances.length} / {stage.max_instances} {$t('projectDetail.instances')}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors active:animate-press"
|
|
||||||
onclick={() => openTagPicker(stage.id)}
|
|
||||||
>
|
|
||||||
<IconDeploy size={14} />
|
|
||||||
{$t('projectDetail.deployNewVersion')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('common.edit')}
|
|
||||||
onclick={() => startEditStage(stage)}
|
|
||||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconEdit size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('projectDetail.deleteStage')}
|
|
||||||
onclick={() => { stageDeleteTarget = { id: stage.id, name: stage.name }; }}
|
|
||||||
class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--color-danger-light)] hover:text-[var(--color-danger)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Deploy confirmation -->
|
|
||||||
{#if deployStageId === stage.id && deployTag}
|
|
||||||
<div class="border-b border-[var(--border-secondary)] bg-[var(--surface-card-hover)] px-5 py-4">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-[var(--text-secondary)]">{$t('projectDetail.deployTag')}:</span>
|
|
||||||
<span class="rounded-md bg-[var(--surface-card)] px-2.5 py-1 font-mono text-sm font-medium text-[var(--text-primary)] border border-[var(--border-primary)]">{deployTag}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="text-xs text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors"
|
|
||||||
onclick={() => openTagPicker(stage.id)}
|
|
||||||
>
|
|
||||||
{$t('common.change')}
|
|
||||||
</button>
|
|
||||||
<div class="ml-auto flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-1.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
|
||||||
disabled={deployLoading}
|
|
||||||
onclick={handleDeploy}
|
|
||||||
>
|
|
||||||
{deployLoading ? $t('projectDetail.deploying') : $t('projectDetail.deploy')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--surface-card)] transition-colors"
|
|
||||||
onclick={() => { deployStageId = ''; deployTag = ''; }}
|
|
||||||
>
|
|
||||||
{$t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if deployError}
|
|
||||||
<p class="mt-2 text-xs text-[var(--color-danger)]">{deployError}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Instances -->
|
|
||||||
<div class="p-5">
|
|
||||||
{#if stageInstances.length === 0}
|
|
||||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noInstancesRunning')}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-3">
|
|
||||||
{#each stageInstances as instance (instance.id)}
|
|
||||||
<InstanceCard
|
|
||||||
{instance}
|
|
||||||
{projectId}
|
|
||||||
stageId={stage.id}
|
|
||||||
domain={settingsDomain}
|
|
||||||
onchange={loadProject}
|
|
||||||
/>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Local Docker Images -->
|
|
||||||
{#if localImages.length > 0}
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.localImages')}</h2>
|
|
||||||
<div class="mt-4 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageTag')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageId')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageSize')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('projectDetail.imageCreated')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#each localImages as img (img.id + img.tag)}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<span class="rounded-full bg-[var(--surface-card-hover)] px-2 py-0.5 text-xs font-mono text-[var(--text-secondary)]">{img.tag || 'untagged'}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 text-xs font-mono text-[var(--text-tertiary)]">{img.id.substring(7, 19)}</td>
|
|
||||||
<td class="px-4 py-2.5 text-sm text-[var(--text-secondary)]">{(img.size / (1024 * 1024)).toFixed(1)} MB</td>
|
|
||||||
<td class="px-4 py-2.5 text-sm text-[var(--text-tertiary)]">{$fmt.date(img.created)}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Webhook (inbound: trigger deploys via this URL). -->
|
|
||||||
<WebhookPanel
|
|
||||||
title={$t('projectDetail.webhookTitle')}
|
|
||||||
description={$t('projectDetail.webhookDesc')}
|
|
||||||
fetchWebhook={() => api.getProjectWebhook(projectId)}
|
|
||||||
regenerateWebhook={() => api.regenerateProjectWebhook(projectId)}
|
|
||||||
regenerateSigningSecret={() => api.regenerateProjectSigningSecret(projectId)}
|
|
||||||
disableSigning={() => api.disableProjectSigningSecret(projectId)}
|
|
||||||
setRequireSignature={(require) => api.setProjectRequireSignature(projectId, require)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
|
||||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listProjectWebhookDeliveries(projectId, signal)} />
|
|
||||||
|
|
||||||
<!-- Outgoing webhook (where Tinyforge sends events for THIS project). -->
|
|
||||||
<OutgoingWebhookPanel
|
|
||||||
title={$t('projectDetail.outgoingWebhookTitle')}
|
|
||||||
description={$t('projectDetail.outgoingWebhookDesc')}
|
|
||||||
hasUrl={!!project.notification_url}
|
|
||||||
fallbackLabel={$t('projectDetail.outgoingFallbackGlobal')}
|
|
||||||
fetchSecret={() => api.getProjectNotificationSecret(projectId)}
|
|
||||||
regenerateSecret={() => api.regenerateProjectNotificationSecret(projectId)}
|
|
||||||
disableSigning={() => api.disableProjectNotificationSigning(projectId)}
|
|
||||||
sendTest={() => api.testProjectNotification(projectId)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Deploy History Timeline -->
|
|
||||||
<div>
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">{$t('projectDetail.recentDeploys')}</h2>
|
|
||||||
|
|
||||||
{#if deploys.length === 0}
|
|
||||||
<p class="mt-4 text-sm text-[var(--text-tertiary)]">{$t('projectDetail.noDeployHistory')}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="mt-4 space-y-3">
|
|
||||||
{#each deploys as deploy (deploy.id)}
|
|
||||||
<div class="flex items-start gap-4 rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
|
|
||||||
<!-- Timeline dot -->
|
|
||||||
<div class="mt-1 flex flex-col items-center">
|
|
||||||
<div class="h-3 w-3 rounded-full {deploy.status === 'success' ? 'bg-emerald-500' : deploy.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}"></div>
|
|
||||||
</div>
|
|
||||||
<!-- Content -->
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
<span class="font-mono text-sm font-medium text-[var(--text-primary)]">{deploy.image_tag}</span>
|
|
||||||
<StatusBadge status={deploy.status} size="sm" />
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex items-center gap-4 text-xs text-[var(--text-tertiary)]">
|
|
||||||
{#if deploy.started_at}
|
|
||||||
<span class="inline-flex items-center gap-1">
|
|
||||||
<IconClock size={12} />
|
|
||||||
{$fmt.dateTime(deploy.started_at)}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{#if deploy.finished_at}
|
|
||||||
<span>→ {$fmt.dateTime(deploy.finished_at)}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if deploy.error}
|
|
||||||
<p class="mt-1 text-xs text-[var(--color-danger)] truncate">{deploy.error}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={showDeleteConfirm}
|
|
||||||
title={$t('projectDetail.deleteConfirmTitle')}
|
|
||||||
message={$t('projectDetail.deleteConfirmMessage', { name: project.name })}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={handleDeleteProject}
|
|
||||||
oncancel={() => { showDeleteConfirm = false; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={stageDeleteTarget !== null}
|
|
||||||
title={$t('projectDetail.deleteStage')}
|
|
||||||
message={stageDeleteTarget ? $t('projectDetail.deleteStageConfirm', { name: stageDeleteTarget.name }) : ''}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={async () => {
|
|
||||||
const target = stageDeleteTarget;
|
|
||||||
stageDeleteTarget = null;
|
|
||||||
if (target) await handleDeleteStage(target.id, target.name);
|
|
||||||
}}
|
|
||||||
oncancel={() => { stageDeleteTarget = null; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EntityPicker
|
|
||||||
bind:open={accessListPickerOpen}
|
|
||||||
items={accessListPickerItems}
|
|
||||||
current={String(editAccessListId)}
|
|
||||||
title={$t('settingsNpm.selectAccessList')}
|
|
||||||
onselect={handleProjectAccessListSelect}
|
|
||||||
onclose={() => { accessListPickerOpen = false; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EntityPicker
|
|
||||||
bind:open={tagPickerOpen}
|
|
||||||
items={tagPickerItems}
|
|
||||||
current={deployTag}
|
|
||||||
title={$t('projectDetail.selectTag')}
|
|
||||||
placeholder={$t('projectDetail.searchTags')}
|
|
||||||
onselect={handleTagSelect}
|
|
||||||
onclose={() => { tagPickerOpen = false; }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
-471
@@ -1,471 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import type { Stage, StageEnv } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { toasts } from '$lib/stores/toast';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconLock, IconLoader } from '$lib/components/icons';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
|
|
||||||
let stages = $state<Stage[]>([]);
|
|
||||||
let selectedStageId = $state('');
|
|
||||||
let envVars = $state<StageEnv[]>([]);
|
|
||||||
let projectEnv = $state<Record<string, string>>({});
|
|
||||||
let loading = $state(true);
|
|
||||||
let envLoading = $state(false);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
let newKey = $state('');
|
|
||||||
let newValue = $state('');
|
|
||||||
let newEncrypted = $state(false);
|
|
||||||
let saving = $state(false);
|
|
||||||
|
|
||||||
let editingId = $state('');
|
|
||||||
let editKey = $state('');
|
|
||||||
let editValue = $state('');
|
|
||||||
let editEncrypted = $state(false);
|
|
||||||
|
|
||||||
let envDeleteTarget = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Project-level env editing
|
|
||||||
let newProjectKey = $state('');
|
|
||||||
let newProjectValue = $state('');
|
|
||||||
let savingProject = $state(false);
|
|
||||||
let editingProjectKey = $state('');
|
|
||||||
let editProjectValue = $state('');
|
|
||||||
let projectEnvDeleteTarget = $state<string | null>(null);
|
|
||||||
|
|
||||||
// $page.params.id is typed string | undefined because SvelteKit can't
|
|
||||||
// statically prove the [id] segment is present, but inside this route file
|
|
||||||
// it always is — assert non-null so call sites don't need their own guards.
|
|
||||||
const projectId = $derived($page.params.id ?? '');
|
|
||||||
|
|
||||||
async function handleAddProjectEnv() {
|
|
||||||
if (!newProjectKey.trim()) return;
|
|
||||||
savingProject = true;
|
|
||||||
try {
|
|
||||||
const updated = { ...projectEnv, [newProjectKey.trim()]: newProjectValue };
|
|
||||||
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
|
|
||||||
projectEnv = updated;
|
|
||||||
newProjectKey = '';
|
|
||||||
newProjectValue = '';
|
|
||||||
toasts.success($t('envEditor.envAdded'));
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
|
|
||||||
} finally {
|
|
||||||
savingProject = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEditProjectEnv(key: string) {
|
|
||||||
editingProjectKey = key;
|
|
||||||
editProjectValue = projectEnv[key] ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateProjectEnv() {
|
|
||||||
if (!editingProjectKey) return;
|
|
||||||
savingProject = true;
|
|
||||||
try {
|
|
||||||
const updated = { ...projectEnv, [editingProjectKey]: editProjectValue };
|
|
||||||
await api.updateProject(projectId!, { env: JSON.stringify(updated) });
|
|
||||||
projectEnv = updated;
|
|
||||||
editingProjectKey = '';
|
|
||||||
toasts.success($t('envEditor.envUpdated'));
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
|
|
||||||
} finally {
|
|
||||||
savingProject = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteProjectEnv(key: string) {
|
|
||||||
savingProject = true;
|
|
||||||
try {
|
|
||||||
const { [key]: _, ...rest } = projectEnv;
|
|
||||||
await api.updateProject(projectId!, { env: JSON.stringify(rest) });
|
|
||||||
projectEnv = rest;
|
|
||||||
toasts.success($t('envEditor.envDeleted'));
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
|
|
||||||
} finally {
|
|
||||||
savingProject = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProject() {
|
|
||||||
if (stages.length === 0) loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const detail = await api.getProject(projectId);
|
|
||||||
stages = detail.stages ?? [];
|
|
||||||
try {
|
|
||||||
projectEnv = JSON.parse(detail.project.env || '{}');
|
|
||||||
} catch {
|
|
||||||
projectEnv = {};
|
|
||||||
}
|
|
||||||
if (stages.length > 0 && !selectedStageId) {
|
|
||||||
selectedStageId = stages[0].id;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('envEditor.loadFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStageEnv(stageId: string) {
|
|
||||||
if (!stageId) return;
|
|
||||||
envLoading = true;
|
|
||||||
try {
|
|
||||||
envVars = await api.listStageEnv(projectId, stageId);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.loadEnvFailed'));
|
|
||||||
envVars = [];
|
|
||||||
} finally {
|
|
||||||
envLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
if (!newKey.trim() || !selectedStageId) return;
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
await api.createStageEnv(projectId, selectedStageId, {
|
|
||||||
key: newKey.trim(),
|
|
||||||
value: newValue,
|
|
||||||
encrypted: newEncrypted
|
|
||||||
});
|
|
||||||
newKey = '';
|
|
||||||
newValue = '';
|
|
||||||
newEncrypted = false;
|
|
||||||
toasts.success($t('envEditor.envAdded'));
|
|
||||||
await loadStageEnv(selectedStageId);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.addFailed'));
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEdit(env: StageEnv) {
|
|
||||||
editingId = env.id;
|
|
||||||
editKey = env.key;
|
|
||||||
editValue = env.encrypted ? '' : env.value;
|
|
||||||
editEncrypted = env.encrypted;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
editingId = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdate() {
|
|
||||||
if (!editKey.trim()) return;
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
const data: { key?: string; value?: string; encrypted?: boolean } = {
|
|
||||||
key: editKey.trim(),
|
|
||||||
encrypted: editEncrypted
|
|
||||||
};
|
|
||||||
if (editValue) {
|
|
||||||
data.value = editValue;
|
|
||||||
}
|
|
||||||
await api.updateStageEnv(projectId, selectedStageId, editingId, data);
|
|
||||||
editingId = '';
|
|
||||||
toasts.success($t('envEditor.envUpdated'));
|
|
||||||
await loadStageEnv(selectedStageId);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.updateFailed'));
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(envId: string) {
|
|
||||||
try {
|
|
||||||
await api.deleteStageEnv(projectId, selectedStageId, envId);
|
|
||||||
toasts.success($t('envEditor.envDeleted'));
|
|
||||||
await loadStageEnv(selectedStageId);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('envEditor.deleteFailed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOverridden(key: string): boolean {
|
|
||||||
return envVars.some((e) => e.key === key);
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void projectId;
|
|
||||||
untrack(() => loadProject());
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const sid = selectedStageId;
|
|
||||||
if (sid) {
|
|
||||||
untrack(() => loadStageEnv(sid));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('envEditor.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<ForgeHero
|
|
||||||
backHref={`/projects/${projectId}`}
|
|
||||||
eyebrowSuffix="ENV"
|
|
||||||
title={$t('envEditor.title')}
|
|
||||||
lede={$t('envEditor.description')}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<Skeleton width="16rem" height="2.5rem" />
|
|
||||||
<Skeleton height="12rem" />
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Project-level env -->
|
|
||||||
{#if stages.length === 0}
|
|
||||||
<EmptyState title={$t('envEditor.noStages')} icon="instances" />
|
|
||||||
{:else}
|
|
||||||
<div>
|
|
||||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.projectDefaults')}</h2>
|
|
||||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#each Object.entries(projectEnv) as [key, val] (key)}
|
|
||||||
{#if editingProjectKey === key}
|
|
||||||
<tr class="bg-[var(--color-brand-50)]/30">
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={editProjectValue} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5"></td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={savingProject} onclick={handleUpdateProjectEnv}><IconCheck size={16} /></button>
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { editingProjectKey = ''; }}><IconX size={16} /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors {isOverridden(key) ? 'opacity-50' : ''}">
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{key}</td>
|
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{val}</td>
|
|
||||||
<td class="px-4 py-2.5 text-sm">
|
|
||||||
{#if isOverridden(key)}
|
|
||||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridden')}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="rounded-full badge-success rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.inherited')}</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEditProjectEnv(key)}><IconEdit size={16} /></button>
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { projectEnvDeleteTarget = key; }}><IconTrash size={16} /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Add new project env row -->
|
|
||||||
<tr class="bg-[var(--surface-card-hover)]">
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={newProjectKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={newProjectValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5"></td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
|
||||||
disabled={!newProjectKey.trim() || savingProject}
|
|
||||||
onclick={handleAddProjectEnv}
|
|
||||||
>
|
|
||||||
<IconPlus size={14} />
|
|
||||||
{savingProject ? $t('envEditor.adding') : $t('envEditor.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{#if Object.keys(projectEnv).length === 0}
|
|
||||||
<p class="mt-2 text-center text-xs text-[var(--text-tertiary)]">{$t('envEditor.noProjectEnv')}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stage-level overrides -->
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h2 class="text-sm font-semibold text-[var(--text-secondary)]">{$t('envEditor.stageOverrides')}</h2>
|
|
||||||
<select
|
|
||||||
id="stage-select"
|
|
||||||
bind:value={selectedStageId}
|
|
||||||
class="block w-48 rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-3 py-1.5 text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-2 focus:ring-[var(--color-brand-500)] focus:outline-none"
|
|
||||||
>
|
|
||||||
{#each stages as stage (stage.id)}
|
|
||||||
<option value={stage.id}>{stage.name}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if envLoading}
|
|
||||||
<div class="mt-4 flex items-center justify-center gap-2 py-8 text-[var(--text-tertiary)]">
|
|
||||||
<IconLoader size={20} />
|
|
||||||
<span class="text-sm">{$t('common.loading')}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="mt-2 overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.key')}</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.value')}</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.secret')}</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.source')}</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('envEditor.actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#each envVars as env (env.id)}
|
|
||||||
{#if editingId === env.id}
|
|
||||||
<tr class="bg-[var(--color-brand-50)]/30">
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={editKey} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type={editEncrypted ? 'password' : 'text'} bind:value={editValue} placeholder={env.encrypted ? $t('envEditor.leaveEmptyToKeep') : ''} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<ToggleSwitch bind:checked={editEncrypted} label={$t('envEditor.secret')} />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5"></td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate} title={$t('envEditor.save')}>
|
|
||||||
<IconCheck size={16} />
|
|
||||||
</button>
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit} title={$t('common.cancel')}>
|
|
||||||
<IconX size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">{env.key}</td>
|
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
|
||||||
{env.encrypted ? '••••••••' : env.value}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if env.encrypted}
|
|
||||||
<span class="inline-flex items-center gap-1 rounded-full badge-purple rounded-full px-2 py-0.5 text-xs font-medium">
|
|
||||||
<IconLock size={12} />
|
|
||||||
{$t('envEditor.secret')}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 text-sm">
|
|
||||||
{#if env.key in projectEnv}
|
|
||||||
<span class="rounded-full badge-warning rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.overridesProject')}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="rounded-full badge-info rounded-full px-2 py-0.5 text-xs font-medium">{$t('envEditor.stageOnly')}</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(env)} title={$t('envEditor.edit')}>
|
|
||||||
<IconEdit size={16} />
|
|
||||||
</button>
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { envDeleteTarget = env.id; }} title={$t('envEditor.delete')}>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Add new row -->
|
|
||||||
<tr class="bg-[var(--surface-card-hover)]">
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={newKey} placeholder="KEY_NAME" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type={newEncrypted ? 'password' : 'text'} bind:value={newValue} placeholder="value" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm text-[var(--text-primary)] focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<ToggleSwitch bind:checked={newEncrypted} label={$t('envEditor.secret')} />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5"></td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
|
||||||
disabled={!newKey.trim() || saving}
|
|
||||||
onclick={handleAdd}
|
|
||||||
>
|
|
||||||
<IconPlus size={14} />
|
|
||||||
{saving ? $t('envEditor.adding') : $t('envEditor.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={envDeleteTarget !== null}
|
|
||||||
title={$t('envEditor.deleteTitle')}
|
|
||||||
message={$t('envEditor.deleteMessage')}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={async () => {
|
|
||||||
const envId = envDeleteTarget;
|
|
||||||
envDeleteTarget = null;
|
|
||||||
if (envId) await handleDelete(envId);
|
|
||||||
}}
|
|
||||||
oncancel={() => { envDeleteTarget = null; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={projectEnvDeleteTarget !== null}
|
|
||||||
title={$t('envEditor.deleteTitle')}
|
|
||||||
message={$t('envEditor.deleteMessage')}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={async () => {
|
|
||||||
const key = projectEnvDeleteTarget;
|
|
||||||
projectEnvDeleteTarget = null;
|
|
||||||
if (key) await handleDeleteProjectEnv(key);
|
|
||||||
}}
|
|
||||||
oncancel={() => { projectEnvDeleteTarget = null; }}
|
|
||||||
/>
|
|
||||||
@@ -1,324 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import type { Volume, VolumeScopeInfo, VolumeScope } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { toasts } from '$lib/stores/toast';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { IconPlus, IconEdit, IconTrash, IconCheck, IconX, IconInfo } from '$lib/components/icons';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
|
|
||||||
let volumes = $state<Volume[]>([]);
|
|
||||||
let scopes = $state<VolumeScopeInfo[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
|
|
||||||
let newSource = $state('');
|
|
||||||
let newTarget = $state('');
|
|
||||||
let newScope = $state<VolumeScope>('project');
|
|
||||||
let newName = $state('');
|
|
||||||
let saving = $state(false);
|
|
||||||
|
|
||||||
let editingId = $state('');
|
|
||||||
let editSource = $state('');
|
|
||||||
let editTarget = $state('');
|
|
||||||
let editScope = $state<VolumeScope>('project');
|
|
||||||
let editName = $state('');
|
|
||||||
|
|
||||||
let volumeDeleteTarget = $state<string | null>(null);
|
|
||||||
|
|
||||||
const projectId = $derived($page.params.id ?? '');
|
|
||||||
|
|
||||||
const newScopeNeedsName = $derived(scopes.find(s => s.scope === newScope)?.needs_name ?? false);
|
|
||||||
const editScopeNeedsName = $derived(scopes.find(s => s.scope === editScope)?.needs_name ?? false);
|
|
||||||
const newScopeIsEphemeral = $derived(newScope === 'ephemeral');
|
|
||||||
const editScopeIsEphemeral = $derived(editScope === 'ephemeral');
|
|
||||||
|
|
||||||
function scopeColor(scope: string): string {
|
|
||||||
switch (scope) {
|
|
||||||
case 'instance': return 'bg-amber-50 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
|
|
||||||
case 'stage': return 'bg-cyan-50 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400';
|
|
||||||
case 'project': return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
|
||||||
case 'project_named': return 'bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400';
|
|
||||||
case 'named': return 'bg-purple-50 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
|
|
||||||
case 'ephemeral': return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
|
|
||||||
case 'absolute': return 'bg-rose-50 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
|
|
||||||
default: return 'bg-gray-100 text-gray-600';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeLabel(scope: string): string {
|
|
||||||
return scope.replaceAll('_', ' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadVolumes() {
|
|
||||||
if (volumes.length === 0) loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const [vols, scopeList] = await Promise.all([
|
|
||||||
api.listVolumes(projectId),
|
|
||||||
scopes.length === 0 ? api.listVolumeScopes() : Promise.resolve(scopes)
|
|
||||||
]);
|
|
||||||
volumes = vols;
|
|
||||||
scopes = scopeList;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('volumeEditor.loadFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
if (newScope !== 'ephemeral' && !newSource.trim()) return;
|
|
||||||
if (!newTarget.trim()) return;
|
|
||||||
if (newScopeNeedsName && !newName.trim()) return;
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
await api.createVolume(projectId, {
|
|
||||||
source: newSource.trim(),
|
|
||||||
target: newTarget.trim(),
|
|
||||||
scope: newScope,
|
|
||||||
name: newScopeNeedsName ? newName.trim() : undefined
|
|
||||||
});
|
|
||||||
newSource = '';
|
|
||||||
newTarget = '';
|
|
||||||
newScope = 'project';
|
|
||||||
newName = '';
|
|
||||||
toasts.success($t('volumeEditor.volumeAdded'));
|
|
||||||
await loadVolumes();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.addFailed'));
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startEdit(vol: Volume) {
|
|
||||||
editingId = vol.id;
|
|
||||||
editSource = vol.source;
|
|
||||||
editTarget = vol.target;
|
|
||||||
editScope = (vol.scope || 'project') as VolumeScope;
|
|
||||||
editName = vol.name || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() { editingId = ''; }
|
|
||||||
|
|
||||||
async function handleUpdate() {
|
|
||||||
if (editScope !== 'ephemeral' && !editSource.trim()) return;
|
|
||||||
if (!editTarget.trim()) return;
|
|
||||||
if (editScopeNeedsName && !editName.trim()) return;
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
await api.updateVolume(projectId, editingId, {
|
|
||||||
source: editSource.trim(),
|
|
||||||
target: editTarget.trim(),
|
|
||||||
scope: editScope,
|
|
||||||
name: editScopeNeedsName ? editName.trim() : undefined
|
|
||||||
});
|
|
||||||
editingId = '';
|
|
||||||
toasts.success($t('volumeEditor.volumeUpdated'));
|
|
||||||
await loadVolumes();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.updateFailed'));
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(volId: string) {
|
|
||||||
try {
|
|
||||||
await api.deleteVolume(projectId, volId);
|
|
||||||
toasts.success($t('volumeEditor.volumeDeleted'));
|
|
||||||
await loadVolumes();
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeEditor.deleteFailed'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void projectId;
|
|
||||||
untrack(() => loadVolumes());
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('volumeEditor.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<ForgeHero
|
|
||||||
backHref={`/projects/${projectId}`}
|
|
||||||
eyebrowSuffix="VOLUMES"
|
|
||||||
title={$t('volumeEditor.title')}
|
|
||||||
lede={$t('volumeEditor.description')}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Scope legend -->
|
|
||||||
{#if scopes.length > 0 && !loading}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-4 shadow-[var(--shadow-sm)]">
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<IconInfo size={16} class="text-[var(--text-tertiary)]" />
|
|
||||||
<h3 class="text-sm font-semibold text-[var(--text-primary)]">{$t('volumeEditor.scopeGuide')}</h3>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{#each scopes as scope}
|
|
||||||
<div class="flex items-start gap-2 rounded-lg bg-[var(--surface-card-hover)] px-3 py-2">
|
|
||||||
<span class="mt-0.5 inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(scope.scope)}">{scopeLabel(scope.scope)}</span>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="text-xs text-[var(--text-secondary)]">{scope.description}</p>
|
|
||||||
<p class="mt-0.5 font-mono text-[10px] text-[var(--text-tertiary)]">{scope.path_example}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="space-y-4">
|
|
||||||
<Skeleton height="12rem" />
|
|
||||||
</div>
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadVolumes}>
|
|
||||||
{$t('common.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.sourceHost')}</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.targetContainer')}</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.scope')}</th>
|
|
||||||
<th class="px-4 py-3 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.nameColumn')}</th>
|
|
||||||
<th class="px-4 py-3 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeEditor.actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#each volumes as vol (vol.id)}
|
|
||||||
{#if editingId === vol.id}
|
|
||||||
<tr class="bg-[var(--color-brand-50)]/30">
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if editScopeIsEphemeral}
|
|
||||||
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
|
|
||||||
{:else}
|
|
||||||
<input type="text" bind:value={editSource} placeholder={editScope === 'absolute' ? '/mnt/data' : 'uploads'} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={editTarget} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<select bind:value={editScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
|
||||||
{#each scopes as s}
|
|
||||||
<option value={s.scope}>{scopeLabel(s.scope)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if editScopeNeedsName}
|
|
||||||
<input type="text" bind:value={editName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-emerald-600 hover:bg-emerald-50 transition-colors" disabled={saving} onclick={handleUpdate}><IconCheck size={16} /></button>
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={cancelEdit}><IconX size={16} /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{:else}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors">
|
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-primary)]">
|
|
||||||
{#if vol.scope === 'ephemeral'}
|
|
||||||
<span class="italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
|
|
||||||
{:else}
|
|
||||||
{vol.source}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">{vol.target}</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {scopeColor(vol.scope)}">{scopeLabel(vol.scope)}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 font-mono text-sm text-[var(--text-secondary)]">
|
|
||||||
{vol.name || '—'}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-4 py-2.5 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-1">
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-link)] transition-colors" onclick={() => startEdit(vol)}><IconEdit size={16} /></button>
|
|
||||||
<button type="button" class="rounded-lg p-1.5 text-[var(--text-tertiary)] hover:bg-red-50 hover:text-red-600 transition-colors" onclick={() => { volumeDeleteTarget = vol.id; }} title={$t('common.delete')} aria-label={$t('common.delete')}><IconTrash size={16} /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<!-- Add new row -->
|
|
||||||
<tr class="bg-[var(--surface-card-hover)]">
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if newScopeIsEphemeral}
|
|
||||||
<span class="text-xs italic text-[var(--text-tertiary)]">{$t('volumeEditor.tmpfs')}</span>
|
|
||||||
{:else}
|
|
||||||
<input type="text" bind:value={newSource} placeholder={newScope === 'absolute' ? '/mnt/nfs/data' : 'uploads'} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<input type="text" bind:value={newTarget} placeholder="/app/uploads" class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
<select bind:value={newScope} class="rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none">
|
|
||||||
{#each scopes as s}
|
|
||||||
<option value={s.scope}>{scopeLabel(s.scope)}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5">
|
|
||||||
{#if newScopeNeedsName}
|
|
||||||
<input type="text" bind:value={newName} placeholder={$t('volumeEditor.namePlaceholder')} class="block w-full rounded-lg border border-[var(--border-input)] bg-[var(--surface-input)] px-2 py-1 font-mono text-sm focus:border-[var(--color-brand-500)] focus:ring-1 focus:ring-[var(--color-brand-500)] focus:outline-none" />
|
|
||||||
{:else}
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">—</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2.5 text-right">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 rounded-lg bg-[var(--color-brand-600)] px-3 py-1.5 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors active:animate-press"
|
|
||||||
disabled={(!newScopeIsEphemeral && !newSource.trim()) || !newTarget.trim() || (newScopeNeedsName && !newName.trim()) || saving}
|
|
||||||
onclick={handleAdd}
|
|
||||||
>
|
|
||||||
<IconPlus size={14} />
|
|
||||||
{saving ? $t('volumeEditor.adding') : $t('volumeEditor.add')}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if volumes.length === 0}
|
|
||||||
<p class="text-center text-sm text-[var(--text-tertiary)]">{$t('volumeEditor.noVolumes')}</p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={volumeDeleteTarget !== null}
|
|
||||||
title={$t('volumeEditor.deleteTitle')}
|
|
||||||
message={$t('volumeEditor.deleteMessage')}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={async () => {
|
|
||||||
const volId = volumeDeleteTarget;
|
|
||||||
volumeDeleteTarget = null;
|
|
||||||
if (volId) await handleDelete(volId);
|
|
||||||
}}
|
|
||||||
oncancel={() => { volumeDeleteTarget = null; }}
|
|
||||||
/>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import type { FileEntry } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { toasts } from '$lib/stores/toast';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
import { IconLoader, IconChevronRight } from '$lib/components/icons';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import Skeleton from '$lib/components/Skeleton.svelte';
|
|
||||||
|
|
||||||
const projectId = $derived($page.params.id ?? '');
|
|
||||||
const volId = $derived($page.params.volId ?? '');
|
|
||||||
|
|
||||||
let entries = $state<FileEntry[]>([]);
|
|
||||||
let currentPath = $state('');
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let uploading = $state(false);
|
|
||||||
|
|
||||||
// Query params for instance/stage scoped volumes.
|
|
||||||
const stage = $derived($page.url.searchParams.get('stage') ?? '');
|
|
||||||
const tag = $derived($page.url.searchParams.get('tag') ?? '');
|
|
||||||
|
|
||||||
const breadcrumbs = $derived(() => {
|
|
||||||
if (!currentPath) return [];
|
|
||||||
return currentPath.split('/').filter(Boolean);
|
|
||||||
});
|
|
||||||
|
|
||||||
function fileIcon(entry: FileEntry): string {
|
|
||||||
if (entry.is_dir) return '📁';
|
|
||||||
const ext = entry.name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
jpg: '🖼️', jpeg: '🖼️', png: '🖼️', gif: '🖼️', svg: '🖼️', webp: '🖼️',
|
|
||||||
txt: '📄', md: '📄', log: '📄', csv: '📄',
|
|
||||||
json: '📋', yaml: '📋', yml: '📋', toml: '📋', xml: '📋',
|
|
||||||
js: '📜', ts: '📜', go: '📜', py: '📜', rs: '📜', sh: '📜',
|
|
||||||
zip: '📦', tar: '📦', gz: '📦', rar: '📦',
|
|
||||||
db: '🗄️', sqlite: '🗄️', sql: '🗄️',
|
|
||||||
};
|
|
||||||
return icons[ext] ?? '📄';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
|
||||||
if (bytes === 0) return '—';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
let i = 0;
|
|
||||||
let size = bytes;
|
|
||||||
while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; }
|
|
||||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDir(path: string) {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const result = await api.browseVolume(projectId, volId, { path, stage, tag });
|
|
||||||
entries = result.entries;
|
|
||||||
currentPath = result.path || '';
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed');
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateTo(path: string) {
|
|
||||||
loadDir(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function navigateToBreadcrumb(index: number) {
|
|
||||||
const parts = currentPath.split('/').filter(Boolean);
|
|
||||||
const path = parts.slice(0, index + 1).join('/');
|
|
||||||
navigateTo(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleEntryClick(entry: FileEntry) {
|
|
||||||
if (entry.is_dir) {
|
|
||||||
const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
|
||||||
navigateTo(newPath);
|
|
||||||
} else {
|
|
||||||
// Download single file.
|
|
||||||
const filePath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
|
|
||||||
window.open(api.volumeDownloadUrl(projectId, volId, { path: filePath, stage, tag }), '_blank');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadCurrent() {
|
|
||||||
window.open(api.volumeDownloadUrl(projectId, volId, { path: currentPath, stage, tag }), '_blank');
|
|
||||||
}
|
|
||||||
|
|
||||||
let fileInput: HTMLInputElement;
|
|
||||||
|
|
||||||
async function handleUpload() {
|
|
||||||
if (!fileInput.files?.length) return;
|
|
||||||
uploading = true;
|
|
||||||
try {
|
|
||||||
const result = await api.uploadToVolume(projectId, volId, fileInput.files, { path: currentPath, stage, tag });
|
|
||||||
toasts.success(`${$t('volumeBrowser.uploaded')} ${result.count} ${$t('volumeBrowser.files')}`);
|
|
||||||
fileInput.value = '';
|
|
||||||
await loadDir(currentPath);
|
|
||||||
} catch (e) {
|
|
||||||
toasts.error(e instanceof Error ? e.message : $t('volumeBrowser.uploadFailed'));
|
|
||||||
} finally {
|
|
||||||
uploading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void projectId;
|
|
||||||
void volId;
|
|
||||||
loadDir('');
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('volumeBrowser.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
{#snippet browserToolbar()}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg border border-[var(--border-primary)] px-3 py-2 text-xs font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
onclick={downloadCurrent}
|
|
||||||
>
|
|
||||||
📦 {currentPath ? $t('volumeBrowser.downloadFolder') : $t('volumeBrowser.downloadAll')}
|
|
||||||
</button>
|
|
||||||
<label
|
|
||||||
class="inline-flex cursor-pointer items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-xs font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors {uploading ? 'opacity-50 pointer-events-none' : ''}"
|
|
||||||
>
|
|
||||||
{#if uploading}
|
|
||||||
<IconLoader size={14} class="animate-spin" />
|
|
||||||
{/if}
|
|
||||||
{$t('volumeBrowser.upload')}
|
|
||||||
<input bind:this={fileInput} type="file" multiple class="hidden" onchange={handleUpload} />
|
|
||||||
</label>
|
|
||||||
{/snippet}
|
|
||||||
<ForgeHero
|
|
||||||
backHref={`/projects/${projectId}/volumes`}
|
|
||||||
eyebrowSuffix="VOLUME BROWSER"
|
|
||||||
title={$t('volumeBrowser.title')}
|
|
||||||
size="lg"
|
|
||||||
toolbar={browserToolbar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Path breadcrumbs -->
|
|
||||||
<nav class="flex items-center gap-1 text-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {currentPath === '' ? 'font-semibold' : ''}"
|
|
||||||
onclick={() => navigateTo('')}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</button>
|
|
||||||
{#each breadcrumbs() as segment, i}
|
|
||||||
<IconChevronRight size={12} class="text-[var(--text-tertiary)]" />
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded px-1.5 py-0.5 text-[var(--text-link)] hover:bg-[var(--surface-card-hover)] transition-colors {i === breadcrumbs().length - 1 ? 'font-semibold text-[var(--text-primary)]' : ''}"
|
|
||||||
onclick={() => navigateToBreadcrumb(i)}
|
|
||||||
>
|
|
||||||
{segment}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<Skeleton height="16rem" />
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={() => loadDir(currentPath)}>
|
|
||||||
{$t('common.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else if entries.length === 0}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('volumeBrowser.empty')}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-hidden rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2.5 text-left text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.name')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.size')}</th>
|
|
||||||
<th class="px-4 py-2.5 text-right text-xs font-medium text-[var(--text-tertiary)] uppercase">{$t('volumeBrowser.modified')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#if currentPath}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] cursor-pointer transition-colors" onclick={() => {
|
|
||||||
const parts = currentPath.split('/').filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
navigateTo(parts.join('/'));
|
|
||||||
}}>
|
|
||||||
<td class="px-4 py-2 text-sm text-[var(--text-link)]">
|
|
||||||
<span class="mr-2">📁</span>..
|
|
||||||
</td>
|
|
||||||
<td></td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
{/if}
|
|
||||||
{#each entries.sort((a, b) => {
|
|
||||||
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
}) as entry (entry.name)}
|
|
||||||
<tr
|
|
||||||
class="hover:bg-[var(--surface-card-hover)] transition-colors {entry.is_dir ? 'cursor-pointer' : ''}"
|
|
||||||
onclick={() => handleEntryClick(entry)}
|
|
||||||
>
|
|
||||||
<td class="px-4 py-2 text-sm text-[var(--text-primary)]">
|
|
||||||
<span class="mr-2">{fileIcon(entry)}</span>
|
|
||||||
{#if entry.is_dir}
|
|
||||||
<span class="text-[var(--text-link)]">{entry.name}</span>
|
|
||||||
{:else}
|
|
||||||
{entry.name}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-secondary)] tabular-nums">
|
|
||||||
{entry.is_dir ? '—' : formatSize(entry.size)}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-2 text-right text-xs text-[var(--text-tertiary)]">
|
|
||||||
{$fmt.compact(entry.mod_time)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export const ssr = false;
|
|
||||||
@@ -48,8 +48,13 @@
|
|||||||
: 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]';
|
: 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)] dark:text-[var(--color-brand-200)]';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy /projects/{id} and /sites/{id} routes were retired with the
|
||||||
|
// hard cutover. Proxy rows now point at the workload-first containers
|
||||||
|
// page filtered by name; the app deep-link is not available because
|
||||||
|
// proxy_route rows don't carry an app_id today.
|
||||||
function targetHref(route: ProxyRoute): string {
|
function targetHref(route: ProxyRoute): string {
|
||||||
return route.source === 'static_site' ? `/sites/${route.instance_id}` : `/projects/${route.project_id}`;
|
const q = encodeURIComponent(route.project_name ?? '');
|
||||||
|
return q ? `/containers?q=${q}` : '/containers';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRoutes() {
|
async function loadRoutes() {
|
||||||
|
|||||||
@@ -1,271 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { StaticSite } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
import { IconPlus, IconSearch, IconRefresh, IconTrash, IconPlay, IconStop } from '$lib/components/icons';
|
|
||||||
import SkeletonTable from '$lib/components/SkeletonTable.svelte';
|
|
||||||
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
|
|
||||||
let sites = $state<StaticSite[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let searchQuery = $state('');
|
|
||||||
let deploying = $state<Record<string, boolean>>({});
|
|
||||||
let confirmDelete = $state<StaticSite | null>(null);
|
|
||||||
|
|
||||||
const filteredSites = $derived(
|
|
||||||
searchQuery.trim()
|
|
||||||
? sites.filter(s => {
|
|
||||||
const q = searchQuery.toLowerCase();
|
|
||||||
return s.name.toLowerCase().includes(q)
|
|
||||||
|| s.domain.toLowerCase().includes(q)
|
|
||||||
|| s.repo_name.toLowerCase().includes(q);
|
|
||||||
})
|
|
||||||
: sites
|
|
||||||
);
|
|
||||||
|
|
||||||
async function loadSites() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
sites = await api.listStaticSites();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load sites';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeploy(site: StaticSite) {
|
|
||||||
deploying = { ...deploying, [site.id]: true };
|
|
||||||
try {
|
|
||||||
await api.deployStaticSite(site.id);
|
|
||||||
// Refresh after a short delay to pick up status change.
|
|
||||||
setTimeout(() => loadSites(), 2000);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
|
||||||
} finally {
|
|
||||||
deploying = { ...deploying, [site.id]: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStop(site: StaticSite) {
|
|
||||||
try {
|
|
||||||
await api.stopStaticSite(site.id);
|
|
||||||
setTimeout(() => loadSites(), 2000);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Stop failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStart(site: StaticSite) {
|
|
||||||
try {
|
|
||||||
await api.startStaticSite(site.id);
|
|
||||||
setTimeout(() => loadSites(), 3000);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Start failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!confirmDelete) return;
|
|
||||||
const id = confirmDelete.id;
|
|
||||||
confirmDelete = null;
|
|
||||||
try {
|
|
||||||
await api.deleteStaticSite(id);
|
|
||||||
await loadSites();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Delete failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadge(status: string): { text: string; class: string } {
|
|
||||||
switch (status) {
|
|
||||||
case 'deployed':
|
|
||||||
return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
|
||||||
case 'syncing':
|
|
||||||
return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
|
||||||
case 'failed':
|
|
||||||
return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
|
||||||
default:
|
|
||||||
return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function modeBadge(mode: string): { text: string; class: string } {
|
|
||||||
if (mode === 'deno') {
|
|
||||||
return { text: 'Deno', class: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' };
|
|
||||||
}
|
|
||||||
return { text: 'Static', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
loadSites();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('sites.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#snippet heroToolbar()}
|
|
||||||
<a href="/sites/new" class="forge-btn">
|
|
||||||
<IconPlus size={14} />
|
|
||||||
<span>{$t('sites.addSite')}</span>
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
<ForgeHero
|
|
||||||
eyebrowSuffix="SITES"
|
|
||||||
title={$t('sites.title')}
|
|
||||||
size="lg"
|
|
||||||
toolbar={heroToolbar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<SkeletonTable rows={4} cols={5} />
|
|
||||||
{:else if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
<button type="button" class="mt-2 text-sm font-medium text-[var(--color-danger)] underline hover:no-underline" onclick={loadSites}>
|
|
||||||
{$t('common.retry')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else if sites.length === 0}
|
|
||||||
<EmptyState
|
|
||||||
title={$t('sites.noSites')}
|
|
||||||
description={$t('sites.noSitesDesc')}
|
|
||||||
actionLabel={$t('sites.addSite')}
|
|
||||||
onaction={() => { window.location.href = '/sites/new'; }}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<!-- Search -->
|
|
||||||
<div class="relative">
|
|
||||||
<IconSearch size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-[var(--text-tertiary)]" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
placeholder={$t('sites.searchPlaceholder')}
|
|
||||||
class="w-full rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] py-2.5 pl-10 pr-4 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-tertiary)] focus:border-[var(--color-brand-500)] focus:outline-none focus:ring-1 focus:ring-[var(--color-brand-500)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if filteredSites.length === 0}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-8 text-center">
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noMatching')}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="overflow-x-auto rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] shadow-[var(--shadow-sm)]">
|
|
||||||
<table class="min-w-full divide-y divide-[var(--border-primary)]">
|
|
||||||
<thead class="bg-[var(--surface-card-hover)]">
|
|
||||||
<tr>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.name')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.domain')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.mode')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.status')}</th>
|
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium tracking-wider text-[var(--text-tertiary)] uppercase">{$t('sites.lastSync')}</th>
|
|
||||||
<th class="px-6 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-[var(--border-secondary)]">
|
|
||||||
{#each filteredSites as site (site.id)}
|
|
||||||
{@const status = statusBadge(site.status)}
|
|
||||||
{@const mode = modeBadge(site.mode)}
|
|
||||||
<tr class="hover:bg-[var(--surface-card-hover)] transition-colors duration-150">
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<a href="/sites/{site.id}" class="font-medium text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
|
||||||
{site.name}
|
|
||||||
</a>
|
|
||||||
<p class="mt-0.5 text-xs text-[var(--text-tertiary)]">{site.repo_owner}/{site.repo_name}</p>
|
|
||||||
</td>
|
|
||||||
<td class="max-w-xs truncate px-6 py-4 font-mono text-sm">
|
|
||||||
{#if site.domain}
|
|
||||||
<a href="https://{site.domain}" target="_blank" rel="noopener noreferrer" class="text-[var(--text-link)] hover:text-[var(--text-link-hover)] transition-colors">
|
|
||||||
{site.domain}
|
|
||||||
</a>
|
|
||||||
{:else}
|
|
||||||
<span class="text-[var(--text-tertiary)]">-</span>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {mode.class}">
|
|
||||||
{mode.text}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4">
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {status.class}">
|
|
||||||
{status.text}
|
|
||||||
</span>
|
|
||||||
{#if site.error}
|
|
||||||
<p class="mt-0.5 max-w-[200px] truncate text-xs text-red-500" title={site.error}>{site.error}</p>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-[var(--text-secondary)]">
|
|
||||||
{#if site.last_sync_at}
|
|
||||||
{$fmt.dateTime(site.last_sync_at)}
|
|
||||||
{:else}
|
|
||||||
-
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td class="whitespace-nowrap px-6 py-4 text-right">
|
|
||||||
<div class="flex items-center justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('sites.deploy')}
|
|
||||||
disabled={deploying[site.id]}
|
|
||||||
onclick={() => handleDeploy(site)}
|
|
||||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<IconRefresh size={16} class={deploying[site.id] ? 'animate-spin' : ''} />
|
|
||||||
</button>
|
|
||||||
{#if site.status === 'stopped'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('sites.start')}
|
|
||||||
onclick={() => handleStart(site)}
|
|
||||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-emerald-600 hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconPlay size={16} />
|
|
||||||
</button>
|
|
||||||
{:else if site.status === 'deployed'}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('sites.stop')}
|
|
||||||
onclick={() => handleStop(site)}
|
|
||||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--text-primary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconStop size={16} />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title={$t('common.delete')}
|
|
||||||
onclick={() => { confirmDelete = site; }}
|
|
||||||
class="rounded-md p-1.5 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmDelete}
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmDelete !== null}
|
|
||||||
title={$t('sites.confirmDelete')}
|
|
||||||
message={`${$t('sites.confirmDeleteMsg')} "${confirmDelete.name}"?`}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
onconfirm={handleDelete}
|
|
||||||
oncancel={() => { confirmDelete = null; }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -1,484 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { StaticSite, StaticSiteSecret, StaticSiteStorageUsage } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
|
||||||
import WebhookPanel from '$lib/components/WebhookPanel.svelte';
|
|
||||||
import WebhookDeliveryLog from '$lib/components/WebhookDeliveryLog.svelte';
|
|
||||||
import OutgoingWebhookPanel from '$lib/components/OutgoingWebhookPanel.svelte';
|
|
||||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
|
||||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
|
||||||
import { IconRefresh, IconGlobe, IconTrash, IconPlus, IconLoader, IconLock, IconUnlock, IconPlay, IconStop } from '$lib/components/icons';
|
|
||||||
|
|
||||||
let site = $state<StaticSite | null>(null);
|
|
||||||
let secrets = $state<StaticSiteSecret[]>([]);
|
|
||||||
let loading = $state(true);
|
|
||||||
let error = $state('');
|
|
||||||
let deploying = $state(false);
|
|
||||||
let confirmDelete = $state(false);
|
|
||||||
let confirmDeleteSecretId = $state<string | null>(null);
|
|
||||||
|
|
||||||
// Outgoing notification URL inline editor. The site has no full edit
|
|
||||||
// form on this page; this small input lets operators set/clear the
|
|
||||||
// per-site URL without going back to the create wizard.
|
|
||||||
let editNotificationUrl = $state('');
|
|
||||||
let savingNotificationUrl = $state(false);
|
|
||||||
|
|
||||||
async function saveNotificationUrl() {
|
|
||||||
if (!site) return;
|
|
||||||
savingNotificationUrl = true;
|
|
||||||
try {
|
|
||||||
await api.updateStaticSite(site.id, { notification_url: editNotificationUrl.trim() });
|
|
||||||
site = { ...site, notification_url: editNotificationUrl.trim() };
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to save notification URL';
|
|
||||||
} finally {
|
|
||||||
savingNotificationUrl = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync the editor with the loaded site once it arrives.
|
|
||||||
$effect(() => {
|
|
||||||
if (site && editNotificationUrl === '') {
|
|
||||||
editNotificationUrl = site.notification_url ?? '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Secret form.
|
|
||||||
let showSecretForm = $state(false);
|
|
||||||
let secretKey = $state('');
|
|
||||||
let secretValue = $state('');
|
|
||||||
let secretEncrypted = $state(true);
|
|
||||||
let secretSubmitting = $state(false);
|
|
||||||
let storageUsage = $state<StaticSiteStorageUsage | null>(null);
|
|
||||||
let showLogs = $state(false);
|
|
||||||
|
|
||||||
const siteId = $derived($page.params.id);
|
|
||||||
|
|
||||||
async function loadSite() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
site = await api.getStaticSite(siteId!);
|
|
||||||
secrets = await api.listStaticSiteSecrets(siteId!);
|
|
||||||
if (site.storage_enabled) {
|
|
||||||
storageUsage = await api.getStaticSiteStorage(siteId!);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to load site';
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeploy() {
|
|
||||||
if (!site) return;
|
|
||||||
deploying = true;
|
|
||||||
try {
|
|
||||||
await api.deployStaticSite(site.id);
|
|
||||||
setTimeout(() => loadSite(), 3000);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Deploy failed';
|
|
||||||
} finally {
|
|
||||||
deploying = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStop() {
|
|
||||||
if (!site) return;
|
|
||||||
try {
|
|
||||||
await api.stopStaticSite(site.id);
|
|
||||||
setTimeout(() => loadSite(), 2000);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Stop failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStart() {
|
|
||||||
if (!site) return;
|
|
||||||
try {
|
|
||||||
await api.startStaticSite(site.id);
|
|
||||||
setTimeout(() => loadSite(), 3000);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Start failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete() {
|
|
||||||
if (!site) return;
|
|
||||||
confirmDelete = false;
|
|
||||||
try {
|
|
||||||
await api.deleteStaticSite(site.id);
|
|
||||||
goto('/sites');
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Delete failed';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddSecret() {
|
|
||||||
if (!site || !secretKey.trim()) return;
|
|
||||||
secretSubmitting = true;
|
|
||||||
try {
|
|
||||||
await api.createStaticSiteSecret(site.id, {
|
|
||||||
key: secretKey.trim(),
|
|
||||||
value: secretValue,
|
|
||||||
encrypted: secretEncrypted
|
|
||||||
});
|
|
||||||
secretKey = '';
|
|
||||||
secretValue = '';
|
|
||||||
secretEncrypted = true;
|
|
||||||
showSecretForm = false;
|
|
||||||
secrets = await api.listStaticSiteSecrets(site.id);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to add secret';
|
|
||||||
} finally {
|
|
||||||
secretSubmitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteSecret() {
|
|
||||||
if (!site || !confirmDeleteSecretId) return;
|
|
||||||
try {
|
|
||||||
await api.deleteStaticSiteSecret(site.id, confirmDeleteSecretId);
|
|
||||||
secrets = await api.listStaticSiteSecrets(site.id);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Failed to delete secret';
|
|
||||||
} finally {
|
|
||||||
confirmDeleteSecretId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusBadge(status: string): { text: string; class: string } {
|
|
||||||
switch (status) {
|
|
||||||
case 'deployed': return { text: 'Deployed', class: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400' };
|
|
||||||
case 'syncing': return { text: 'Syncing', class: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' };
|
|
||||||
case 'failed': return { text: 'Failed', class: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400' };
|
|
||||||
default: return { text: 'Idle', class: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
void siteId;
|
|
||||||
loadSite();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{site?.name ?? $t('sites.title')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
{#if loading}
|
|
||||||
<div class="flex items-center gap-2 py-8">
|
|
||||||
<IconLoader size={20} class="animate-spin text-[var(--text-tertiary)]" />
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('common.loading')}</span>
|
|
||||||
</div>
|
|
||||||
{:else if error && !site}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-4">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
</div>
|
|
||||||
{:else if site}
|
|
||||||
{@const s = site}
|
|
||||||
{#snippet siteToolbar()}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={deploying}
|
|
||||||
onclick={handleDeploy}
|
|
||||||
class="forge-btn"
|
|
||||||
>
|
|
||||||
<IconRefresh size={14} class={deploying ? 'animate-spin' : ''} />
|
|
||||||
<span>{$t('sites.deploy')}</span>
|
|
||||||
</button>
|
|
||||||
{#if s.status === 'stopped'}
|
|
||||||
<button type="button" onclick={handleStart} class="forge-btn-ghost">
|
|
||||||
<IconPlay size={14} />
|
|
||||||
<span>{$t('sites.start')}</span>
|
|
||||||
</button>
|
|
||||||
{:else if s.status === 'deployed'}
|
|
||||||
<button type="button" onclick={handleStop} class="forge-btn-ghost">
|
|
||||||
<IconStop size={14} />
|
|
||||||
<span>{$t('sites.stop')}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if s.domain}
|
|
||||||
<a
|
|
||||||
href="https://{s.domain}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="forge-btn-ghost"
|
|
||||||
>
|
|
||||||
<IconGlobe size={14} />
|
|
||||||
<span>{$t('sites.openSite')}</span>
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { confirmDelete = true; }}
|
|
||||||
class="forge-btn-icon forge-btn-danger"
|
|
||||||
aria-label="Delete"
|
|
||||||
>
|
|
||||||
<IconTrash size={16} />
|
|
||||||
</button>
|
|
||||||
{/snippet}
|
|
||||||
<ForgeHero
|
|
||||||
backHref="/sites"
|
|
||||||
eyebrowSuffix="SITE"
|
|
||||||
title={s.name}
|
|
||||||
kicker="{s.repo_owner}/{s.repo_name} · {s.branch}"
|
|
||||||
size="lg"
|
|
||||||
toolbar={siteToolbar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if error}
|
|
||||||
<div class="rounded-xl border border-[var(--color-danger-light)] bg-[var(--color-danger-light)] p-3">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{error}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Status & Info -->
|
|
||||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
<!-- Site Info -->
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
|
||||||
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.siteInfo')}</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.status')}</span>
|
|
||||||
<span>
|
|
||||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium {statusBadge(site.status).class}">{statusBadge(site.status).text}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{site.mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
|
|
||||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.domain || '-'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
|
|
||||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.folder_path || '/ (root)'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{site.sync_trigger}{site.sync_trigger === 'tag' ? ` (${site.tag_pattern})` : ''}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.lastSync')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{site.last_sync_at ? $fmt.dateTime(site.last_sync_at) : '-'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.commitSha')}</span>
|
|
||||||
<span class="text-[var(--text-primary)] font-mono text-xs">{site.last_commit_sha ? site.last_commit_sha.slice(0, 8) : '-'}</span>
|
|
||||||
|
|
||||||
{#if site.mode === 'deno' && site.storage_enabled}
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.dataPath')}</span>
|
|
||||||
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if site.error}
|
|
||||||
<div class="mt-4 rounded-lg bg-red-50 dark:bg-red-900/20 p-3">
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">{site.error}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Secrets -->
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('sites.secrets')}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { showSecretForm = !showSecretForm; }}
|
|
||||||
class="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconPlus size={14} />
|
|
||||||
{$t('sites.addSecret')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showSecretForm}
|
|
||||||
<div class="mb-4 space-y-3 rounded-lg bg-[var(--surface-card-hover)] p-4">
|
|
||||||
<FormField label={$t('sites.secretKey')} name="secretKey" bind:value={secretKey} placeholder="API_KEY" required />
|
|
||||||
<FormField label={$t('sites.secretValue')} name="secretValue" bind:value={secretValue} placeholder="sk-..." />
|
|
||||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
|
||||||
<ToggleSwitch bind:checked={secretEncrypted} label={$t('sites.encryptSecret')} />
|
|
||||||
<span>{$t('sites.encryptSecret')}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={!secretKey.trim() || secretSubmitting}
|
|
||||||
onclick={handleAddSecret}
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-3 py-2 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{secretSubmitting ? $t('common.saving') : $t('sites.saveSecret')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if secrets.length === 0}
|
|
||||||
<p class="text-sm text-[var(--text-tertiary)]">{$t('sites.noSecrets')}</p>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-2">
|
|
||||||
{#each secrets as secret (secret.id)}
|
|
||||||
<div class="flex items-center justify-between rounded-lg border border-[var(--border-secondary)] px-3 py-2">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#if secret.encrypted}
|
|
||||||
<IconLock size={14} class="text-[var(--text-tertiary)]" />
|
|
||||||
{:else}
|
|
||||||
<IconUnlock size={14} class="text-[var(--text-tertiary)]" />
|
|
||||||
{/if}
|
|
||||||
<span class="font-mono text-sm text-[var(--text-primary)]">{secret.key}</span>
|
|
||||||
<span class="text-xs text-[var(--text-tertiary)]">{secret.value}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { confirmDeleteSecretId = secret.id; }}
|
|
||||||
class="rounded p-1 text-[var(--text-tertiary)] hover:text-[var(--color-danger)] transition-colors"
|
|
||||||
>
|
|
||||||
<IconTrash size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resource usage + logs for deployed sites. -->
|
|
||||||
{#if site.container_id}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
|
||||||
<div class="mb-3 flex items-center justify-between">
|
|
||||||
<h2 class="text-base font-semibold text-[var(--text-primary)]">{$t('resources.sectionTitle')}</h2>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={() => { showLogs = !showLogs; }}
|
|
||||||
class="rounded-md px-2 py-1 text-xs font-medium text-[var(--color-brand-600)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
>
|
|
||||||
{showLogs ? $t('resources.hideLogs') : $t('resources.showLogs')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ContainerStats source={{ kind: 'site', siteId: site.id }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if showLogs}
|
|
||||||
<ContainerLogs
|
|
||||||
source={{ kind: 'site', siteId: site.id }}
|
|
||||||
onclose={() => { showLogs = false; }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Webhook (inbound: triggers a re-sync from the Git provider). -->
|
|
||||||
<WebhookPanel
|
|
||||||
title={$t('sites.webhookTitle')}
|
|
||||||
description={$t('sites.webhookDesc')}
|
|
||||||
fetchWebhook={() => api.getStaticSiteWebhook(siteId!)}
|
|
||||||
regenerateWebhook={() => api.regenerateStaticSiteWebhook(siteId!)}
|
|
||||||
regenerateSigningSecret={() => api.regenerateStaticSiteSigningSecret(siteId!)}
|
|
||||||
disableSigning={() => api.disableStaticSiteSigningSecret(siteId!)}
|
|
||||||
setRequireSignature={(require) => api.setStaticSiteRequireSignature(siteId!, require)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Recent inbound webhook activity (debug + audit). -->
|
|
||||||
<WebhookDeliveryLog fetchDeliveries={(signal) => api.listStaticSiteWebhookDeliveries(siteId!, signal)} />
|
|
||||||
|
|
||||||
<!-- Outgoing notification URL (per-site override; falls through to global). -->
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 shadow-[var(--shadow-sm)]">
|
|
||||||
<h2 class="mb-1 text-base font-semibold text-[var(--text-primary)]">{$t('sites.outgoingUrlTitle')}</h2>
|
|
||||||
<p class="mb-4 text-sm text-[var(--text-secondary)]">{$t('sites.outgoingUrlDesc')}</p>
|
|
||||||
<div class="flex items-end gap-3">
|
|
||||||
<div class="flex-1">
|
|
||||||
<FormField
|
|
||||||
label=""
|
|
||||||
name="siteNotificationUrl"
|
|
||||||
bind:value={editNotificationUrl}
|
|
||||||
placeholder="https://notify.example.com/webhook"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={saveNotificationUrl}
|
|
||||||
disabled={savingNotificationUrl || editNotificationUrl === (site.notification_url ?? '')}
|
|
||||||
class="inline-flex items-center gap-1.5 rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] disabled:opacity-50 active:animate-press"
|
|
||||||
>
|
|
||||||
{#if savingNotificationUrl}<IconLoader size={16} />{/if}
|
|
||||||
{$t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Outgoing webhook (where Tinyforge posts site_sync_* events). -->
|
|
||||||
<OutgoingWebhookPanel
|
|
||||||
title={$t('sites.outgoingWebhookTitle')}
|
|
||||||
description={$t('sites.outgoingWebhookDesc')}
|
|
||||||
hasUrl={!!site.notification_url}
|
|
||||||
fallbackLabel={$t('sites.outgoingFallbackGlobal')}
|
|
||||||
fetchSecret={() => api.getStaticSiteNotificationSecret(siteId!)}
|
|
||||||
regenerateSecret={() => api.regenerateStaticSiteNotificationSecret(siteId!)}
|
|
||||||
disableSigning={() => api.disableStaticSiteNotificationSigning(siteId!)}
|
|
||||||
sendTest={() => api.testStaticSiteNotification(siteId!)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Storage -->
|
|
||||||
{#if site.storage_enabled && site.mode === 'deno'}
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6">
|
|
||||||
<h2 class="text-base font-semibold text-[var(--text-primary)] mb-4">{$t('sites.storage')}</h2>
|
|
||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageVolume')}</span>
|
|
||||||
<span class="font-mono text-xs text-[var(--text-primary)]">tinyforge-site-{site.name}-data</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageMountPath')}</span>
|
|
||||||
<span class="font-mono text-xs text-[var(--text-primary)]">/app/data</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageLimit')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{site.storage_limit_mb > 0 ? `${site.storage_limit_mb} MB` : $t('sites.unlimited')}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storageUsed')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">
|
|
||||||
{#if storageUsage}
|
|
||||||
{storageUsage.used_bytes < 1024 ? `${storageUsage.used_bytes} B` : storageUsage.used_bytes < 1048576 ? `${(storageUsage.used_bytes / 1024).toFixed(1)} KB` : `${(storageUsage.used_bytes / 1048576).toFixed(1)} MB`}
|
|
||||||
{:else}
|
|
||||||
-
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if storageUsage && site.storage_limit_mb > 0}
|
|
||||||
{@const pct = Math.min(100, (storageUsage.used_bytes / (site.storage_limit_mb * 1048576)) * 100)}
|
|
||||||
<div class="mt-4">
|
|
||||||
<div class="h-2 rounded-full bg-[var(--surface-card-hover)] overflow-hidden">
|
|
||||||
<div
|
|
||||||
class="h-full rounded-full transition-all {pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-emerald-500'}"
|
|
||||||
style="width: {pct.toFixed(1)}%"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)] mt-1">{pct.toFixed(1)}% {$t('sites.storageOfLimit')}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if confirmDelete}
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmDelete}
|
|
||||||
title={$t('sites.confirmDelete')}
|
|
||||||
message={`${$t('sites.confirmDeleteMsg')} "${site?.name}"?`}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
onconfirm={handleDelete}
|
|
||||||
oncancel={() => { confirmDelete = false; }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if confirmDeleteSecretId}
|
|
||||||
<ConfirmDialog
|
|
||||||
open={!!confirmDeleteSecretId}
|
|
||||||
title={$t('sites.confirmDeleteSecret')}
|
|
||||||
message={`${$t('sites.confirmDeleteSecretMsg')} "${secrets.find(s => s.id === confirmDeleteSecretId)?.key}"?`}
|
|
||||||
confirmLabel={$t('common.delete')}
|
|
||||||
onconfirm={handleDeleteSecret}
|
|
||||||
oncancel={() => { confirmDeleteSecretId = null; }}
|
|
||||||
/>
|
|
||||||
{/if}
|
|
||||||
@@ -1,702 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { FolderEntry, GitProvider } from '$lib/types';
|
|
||||||
import * as api from '$lib/api';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
import FormField from '$lib/components/FormField.svelte';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
|
||||||
import { IconCheck, IconLoader, IconChevronRight, IconSearch } from '$lib/components/icons';
|
|
||||||
import EntityPicker from '$lib/components/EntityPicker.svelte';
|
|
||||||
import type { EntityPickerItem } from '$lib/types';
|
|
||||||
|
|
||||||
// Provider options.
|
|
||||||
const providerOptions: { value: GitProvider; label: string }[] = [
|
|
||||||
{ value: '', label: 'Auto-detect' },
|
|
||||||
{ value: 'gitea', label: 'Gitea / Forgejo / Gogs' },
|
|
||||||
{ value: 'github', label: 'GitHub' },
|
|
||||||
{ value: 'gitlab', label: 'GitLab' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Wizard state.
|
|
||||||
let step = $state(1);
|
|
||||||
const totalSteps = 5;
|
|
||||||
|
|
||||||
// Step 1: Repo URL.
|
|
||||||
let fullRepoUrl = $state('');
|
|
||||||
let provider = $state<GitProvider>('');
|
|
||||||
let detectedProvider = $state<GitProvider>('');
|
|
||||||
let detecting = $state(false);
|
|
||||||
let giteaUrl = $state('');
|
|
||||||
let repoOwner = $state('');
|
|
||||||
let repoName = $state('');
|
|
||||||
let accessToken = $state('');
|
|
||||||
let connectionTested = $state(false);
|
|
||||||
let connectionError = $state('');
|
|
||||||
let testing = $state(false);
|
|
||||||
|
|
||||||
// Repo picker.
|
|
||||||
let showRepoPicker = $state(false);
|
|
||||||
let repoPickerItems = $state<EntityPickerItem[]>([]);
|
|
||||||
let repoPickerLoading = $state(false);
|
|
||||||
|
|
||||||
// The effective provider (explicit selection or autodetected).
|
|
||||||
const effectiveProvider = $derived(provider || detectedProvider || 'gitea');
|
|
||||||
|
|
||||||
// Step 2: Branch picker.
|
|
||||||
let branches = $state<string[]>([]);
|
|
||||||
let selectedBranch = $state('');
|
|
||||||
let branchesLoading = $state(false);
|
|
||||||
let showBranchPicker = $state(false);
|
|
||||||
|
|
||||||
// Step 3: Folder picker.
|
|
||||||
let tree = $state<FolderEntry[]>([]);
|
|
||||||
let selectedFolder = $state('');
|
|
||||||
let treeLoading = $state(false);
|
|
||||||
let expandedDirs = $state<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Step 4: Configuration.
|
|
||||||
let siteName = $state('');
|
|
||||||
let domain = $state('');
|
|
||||||
let mode = $state<'static' | 'deno'>('static');
|
|
||||||
let renderMarkdown = $state(false);
|
|
||||||
let syncTrigger = $state<'push' | 'tag' | 'manual'>('manual');
|
|
||||||
let tagPattern = $state('');
|
|
||||||
let storageEnabled = $state(false);
|
|
||||||
let storageLimitStr = $state('0');
|
|
||||||
|
|
||||||
// Step 5: Review + submit.
|
|
||||||
let submitting = $state(false);
|
|
||||||
let submitError = $state('');
|
|
||||||
|
|
||||||
// Parse repo URL into components and autodetect provider.
|
|
||||||
function parseRepoUrl(url: string) {
|
|
||||||
try {
|
|
||||||
const parsed = new URL(url.trim());
|
|
||||||
const pathParts = parsed.pathname.split('/').filter(Boolean);
|
|
||||||
if (pathParts.length >= 2) {
|
|
||||||
giteaUrl = `${parsed.protocol}//${parsed.host}`;
|
|
||||||
repoOwner = pathParts[0];
|
|
||||||
repoName = pathParts[1];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Not a valid URL yet.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function browseRepos() {
|
|
||||||
if (!giteaUrl) return;
|
|
||||||
showRepoPicker = true;
|
|
||||||
if (repoPickerItems.length > 0) return;
|
|
||||||
|
|
||||||
repoPickerLoading = true;
|
|
||||||
try {
|
|
||||||
await autoDetectProvider();
|
|
||||||
const repos = await api.listStaticSiteRepos({
|
|
||||||
provider: effectiveProvider,
|
|
||||||
gitea_url: giteaUrl,
|
|
||||||
access_token: accessToken || undefined,
|
|
||||||
});
|
|
||||||
repoPickerItems = repos.map(r => ({
|
|
||||||
value: JSON.stringify({ owner: r.owner, name: r.name }),
|
|
||||||
label: r.full_name,
|
|
||||||
description: r.description || undefined,
|
|
||||||
icon: r.private ? 'lock' : undefined,
|
|
||||||
}));
|
|
||||||
} catch {
|
|
||||||
repoPickerItems = [];
|
|
||||||
} finally {
|
|
||||||
repoPickerLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectPickedRepo(value: string) {
|
|
||||||
const parsed = JSON.parse(value) as { owner: string; name: string };
|
|
||||||
repoOwner = parsed.owner;
|
|
||||||
repoName = parsed.name;
|
|
||||||
showRepoPicker = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function autoDetectProvider() {
|
|
||||||
if (!giteaUrl || provider) return; // skip if manually selected
|
|
||||||
detecting = true;
|
|
||||||
try {
|
|
||||||
const result = await api.detectStaticSiteProvider(giteaUrl);
|
|
||||||
detectedProvider = result.provider;
|
|
||||||
} catch {
|
|
||||||
detectedProvider = 'gitea';
|
|
||||||
} finally {
|
|
||||||
detecting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testConnection() {
|
|
||||||
testing = true;
|
|
||||||
connectionError = '';
|
|
||||||
connectionTested = false;
|
|
||||||
try {
|
|
||||||
// Autodetect provider if not manually set.
|
|
||||||
await autoDetectProvider();
|
|
||||||
|
|
||||||
await api.testStaticSiteConnection({
|
|
||||||
provider: effectiveProvider,
|
|
||||||
gitea_url: giteaUrl,
|
|
||||||
access_token: accessToken || undefined,
|
|
||||||
repo_owner: repoOwner,
|
|
||||||
repo_name: repoName
|
|
||||||
});
|
|
||||||
connectionTested = true;
|
|
||||||
} catch (e) {
|
|
||||||
connectionError = e instanceof Error ? e.message : 'Connection failed';
|
|
||||||
} finally {
|
|
||||||
testing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadBranches() {
|
|
||||||
branchesLoading = true;
|
|
||||||
try {
|
|
||||||
branches = await api.listStaticSiteBranches({
|
|
||||||
provider: effectiveProvider,
|
|
||||||
gitea_url: giteaUrl,
|
|
||||||
access_token: accessToken || undefined,
|
|
||||||
repo_owner: repoOwner,
|
|
||||||
repo_name: repoName
|
|
||||||
});
|
|
||||||
if (branches.length > 0 && !selectedBranch) {
|
|
||||||
// Default to main/master if available.
|
|
||||||
selectedBranch = branches.find(b => b === 'main') ?? branches.find(b => b === 'master') ?? branches[0];
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
branches = [];
|
|
||||||
} finally {
|
|
||||||
branchesLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTree() {
|
|
||||||
treeLoading = true;
|
|
||||||
try {
|
|
||||||
tree = await api.listStaticSiteTree({
|
|
||||||
provider: effectiveProvider,
|
|
||||||
gitea_url: giteaUrl,
|
|
||||||
access_token: accessToken || undefined,
|
|
||||||
repo_owner: repoOwner,
|
|
||||||
repo_name: repoName,
|
|
||||||
branch: selectedBranch
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
tree = [];
|
|
||||||
} finally {
|
|
||||||
treeLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function goToStep(s: number) {
|
|
||||||
step = s;
|
|
||||||
if (s === 2 && branches.length === 0) loadBranches();
|
|
||||||
if (s === 3 && tree.length === 0) loadTree();
|
|
||||||
if (s === 4) {
|
|
||||||
if (!siteName) siteName = repoName;
|
|
||||||
// Autodetect Deno mode: check if selected folder has an api/ subdirectory.
|
|
||||||
const apiPrefix = selectedFolder ? selectedFolder + '/api' : 'api';
|
|
||||||
const hasApi = tree.some(e => e.is_dir && (e.path === apiPrefix || e.path.startsWith(apiPrefix + '/')));
|
|
||||||
if (hasApi) {
|
|
||||||
mode = 'deno';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tree helpers.
|
|
||||||
const folders = $derived(tree.filter(e => e.is_dir).sort((a, b) => a.path.localeCompare(b.path)));
|
|
||||||
|
|
||||||
function getTopLevelFolders(): FolderEntry[] {
|
|
||||||
return folders.filter(f => !f.path.includes('/'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getChildFolders(parentPath: string): FolderEntry[] {
|
|
||||||
return folders.filter(f => {
|
|
||||||
if (!f.path.startsWith(parentPath + '/')) return false;
|
|
||||||
const rest = f.path.slice(parentPath.length + 1);
|
|
||||||
return !rest.includes('/');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDir(path: string) {
|
|
||||||
const next = new Set(expandedDirs);
|
|
||||||
if (next.has(path)) {
|
|
||||||
next.delete(path);
|
|
||||||
} else {
|
|
||||||
next.add(path);
|
|
||||||
}
|
|
||||||
expandedDirs = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectFolder(path: string) {
|
|
||||||
selectedFolder = path;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Branch picker items.
|
|
||||||
const branchPickerItems = $derived<EntityPickerItem[]>(
|
|
||||||
branches.map(b => ({ value: b, label: b }))
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
submitting = true;
|
|
||||||
submitError = '';
|
|
||||||
try {
|
|
||||||
const site = await api.createStaticSite({
|
|
||||||
name: siteName,
|
|
||||||
provider: effectiveProvider,
|
|
||||||
gitea_url: giteaUrl,
|
|
||||||
repo_owner: repoOwner,
|
|
||||||
repo_name: repoName,
|
|
||||||
branch: selectedBranch,
|
|
||||||
folder_path: selectedFolder,
|
|
||||||
access_token: accessToken || undefined,
|
|
||||||
domain: domain || undefined,
|
|
||||||
mode,
|
|
||||||
render_markdown: renderMarkdown,
|
|
||||||
sync_trigger: syncTrigger,
|
|
||||||
tag_pattern: syncTrigger === 'tag' ? tagPattern : undefined,
|
|
||||||
storage_enabled: storageEnabled,
|
|
||||||
storage_limit_mb: parseInt(storageLimitStr, 10) || 0
|
|
||||||
});
|
|
||||||
goto(`/sites/${site.id}`);
|
|
||||||
} catch (e) {
|
|
||||||
submitError = e instanceof Error ? e.message : 'Failed to create site';
|
|
||||||
} finally {
|
|
||||||
submitting = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:head>
|
|
||||||
<title>{$t('sites.newSite')} - {$t('app.name')}</title>
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
<div class="space-y-6">
|
|
||||||
<ForgeHero
|
|
||||||
backHref="/sites"
|
|
||||||
eyebrowSuffix="NEW SITE"
|
|
||||||
title={$t('sites.newSite')}
|
|
||||||
size="lg"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Progress -->
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{#each Array(totalSteps) as _, i}
|
|
||||||
<div class="h-1.5 flex-1 rounded-full transition-colors {i < step ? 'bg-[var(--color-brand-600)]' : 'bg-[var(--border-primary)]'}"></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] p-6 animate-scale-in">
|
|
||||||
<!-- Step 1: Repository -->
|
|
||||||
{#if step === 1}
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step1Title')}</h2>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<!-- Provider selector -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.provider')}</label>
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
|
||||||
{#each providerOptions as opt}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border px-3 py-2 text-sm font-medium transition-colors {provider === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
|
||||||
onclick={() => { provider = opt.value; detectedProvider = ''; }}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if provider === '' && detectedProvider}
|
|
||||||
<p class="text-xs text-emerald-600 dark:text-emerald-400">
|
|
||||||
{$t('sites.detectedProvider')}: {providerOptions.find(o => o.value === detectedProvider)?.label ?? detectedProvider}
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Paste full URL for auto-fill -->
|
|
||||||
<FormField
|
|
||||||
label={$t('sites.fullRepoUrl')}
|
|
||||||
name="fullRepoUrl"
|
|
||||||
bind:value={fullRepoUrl}
|
|
||||||
placeholder="https://git.example.com/owner/repo"
|
|
||||||
helpText={$t('sites.fullRepoUrlHelp')}
|
|
||||||
oninput={(e) => {
|
|
||||||
const val = (e.target as HTMLInputElement).value;
|
|
||||||
if (val.includes('/') && val.startsWith('http')) {
|
|
||||||
parseRepoUrl(val);
|
|
||||||
autoDetectProvider();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Individual fields (auto-filled or manual) -->
|
|
||||||
<FormField label={$t('sites.serverUrl')} name="serverUrl" bind:value={giteaUrl} placeholder="https://git.example.com" required />
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label={$t('sites.repoOwner')} name="repoOwner" bind:value={repoOwner} placeholder="username" required />
|
|
||||||
<div class="flex items-end gap-2">
|
|
||||||
<div class="flex-1">
|
|
||||||
<FormField label={$t('sites.repoName')} name="repoName" bind:value={repoName} placeholder="my-app" required />
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onclick={browseRepos}
|
|
||||||
title={$t('sites.browseRepos')}
|
|
||||||
disabled={!giteaUrl}
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] p-2 text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{#if repoPickerLoading}
|
|
||||||
<IconLoader size={16} class="animate-spin" />
|
|
||||||
{:else}
|
|
||||||
<IconSearch size={16} />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<EntityPicker
|
|
||||||
bind:open={showRepoPicker}
|
|
||||||
items={repoPickerItems}
|
|
||||||
current={repoOwner && repoName ? JSON.stringify({ owner: repoOwner, name: repoName }) : ''}
|
|
||||||
title={$t('sites.selectRepo')}
|
|
||||||
placeholder={$t('entityPicker.search')}
|
|
||||||
onselect={selectPickedRepo}
|
|
||||||
onclose={() => { showRepoPicker = false; }}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
label={$t('sites.accessToken')}
|
|
||||||
name="accessToken"
|
|
||||||
type="password"
|
|
||||||
bind:value={accessToken}
|
|
||||||
placeholder={$t('sites.accessTokenPlaceholder')}
|
|
||||||
helpText={$t('sites.accessTokenHelp')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#if connectionError}
|
|
||||||
<div class="rounded-lg bg-[var(--color-danger-light)] p-3">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{connectionError}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if connectionTested}
|
|
||||||
<div class="rounded-lg bg-emerald-50 dark:bg-emerald-900/20 p-3 flex items-center gap-2">
|
|
||||||
<IconCheck size={16} class="text-emerald-600" />
|
|
||||||
<p class="text-sm text-emerald-700 dark:text-emerald-400">{$t('sites.connectionSuccess')}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
disabled={!giteaUrl || !repoOwner || !repoName || testing}
|
|
||||||
onclick={testConnection}
|
|
||||||
>
|
|
||||||
{#if testing}
|
|
||||||
<IconLoader size={14} class="inline mr-1 animate-spin" />
|
|
||||||
{/if}
|
|
||||||
{$t('sites.testConnection')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
|
||||||
disabled={!connectionTested}
|
|
||||||
onclick={() => goToStep(2)}
|
|
||||||
>
|
|
||||||
{$t('common.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 2: Branch -->
|
|
||||||
{:else if step === 2}
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step2Title')}</h2>
|
|
||||||
|
|
||||||
{#if branchesLoading}
|
|
||||||
<div class="flex items-center gap-2 py-4">
|
|
||||||
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
|
|
||||||
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingBranches')}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="space-y-2">
|
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectBranch')}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full text-left rounded-lg border border-[var(--border-primary)] px-4 py-3 text-sm hover:bg-[var(--surface-card-hover)] transition-colors"
|
|
||||||
onclick={() => { showBranchPicker = true; }}
|
|
||||||
>
|
|
||||||
<span class="font-medium text-[var(--text-primary)]">{selectedBranch || $t('sites.chooseBranch')}</span>
|
|
||||||
</button>
|
|
||||||
<EntityPicker
|
|
||||||
bind:open={showBranchPicker}
|
|
||||||
items={branchPickerItems}
|
|
||||||
current={selectedBranch}
|
|
||||||
title={$t('sites.selectBranch')}
|
|
||||||
placeholder={$t('entityPicker.search')}
|
|
||||||
onselect={(val) => { selectedBranch = val; showBranchPicker = false; tree = []; }}
|
|
||||||
onclose={() => { showBranchPicker = false; }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-between">
|
|
||||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 1; }}>
|
|
||||||
{$t('common.back')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
|
||||||
disabled={!selectedBranch}
|
|
||||||
onclick={() => goToStep(3)}
|
|
||||||
>
|
|
||||||
{$t('common.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 3: Folder -->
|
|
||||||
{:else if step === 3}
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step3Title')}</h2>
|
|
||||||
|
|
||||||
{#if treeLoading}
|
|
||||||
<div class="flex items-center gap-2 py-4">
|
|
||||||
<IconLoader size={16} class="animate-spin text-[var(--text-tertiary)]" />
|
|
||||||
<span class="text-sm text-[var(--text-tertiary)]">{$t('sites.loadingTree')}</span>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-[var(--text-secondary)] mb-3">{$t('sites.selectFolder')}</p>
|
|
||||||
|
|
||||||
<!-- Root option -->
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full text-left rounded-lg px-4 py-2 text-sm transition-colors mb-1 {selectedFolder === '' ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
|
|
||||||
onclick={() => selectFolder('')}
|
|
||||||
>
|
|
||||||
/ (root)
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="max-h-64 overflow-y-auto rounded-lg border border-[var(--border-primary)] p-2">
|
|
||||||
{#each getTopLevelFolders() as folder (folder.path)}
|
|
||||||
{@const isSelected = selectedFolder === folder.path}
|
|
||||||
{@const isExpanded = expandedDirs.has(folder.path)}
|
|
||||||
{@const children = getChildFolders(folder.path)}
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
{#if children.length > 0}
|
|
||||||
<button type="button" class="p-0.5 text-[var(--text-tertiary)]" onclick={() => toggleDir(folder.path)}>
|
|
||||||
<IconChevronRight size={14} class="transition-transform {isExpanded ? 'rotate-90' : ''}" />
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<span class="w-5"></span>
|
|
||||||
{/if}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 text-left rounded px-2 py-1.5 text-sm transition-colors {isSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-primary)]'}"
|
|
||||||
onclick={() => selectFolder(folder.path)}
|
|
||||||
>
|
|
||||||
{folder.path}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{#if isExpanded}
|
|
||||||
<div class="ml-5">
|
|
||||||
{#each children as child (child.path)}
|
|
||||||
{@const childSelected = selectedFolder === child.path}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full text-left rounded px-2 py-1.5 text-sm transition-colors {childSelected ? 'bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'hover:bg-[var(--surface-card-hover)] text-[var(--text-secondary)]'}"
|
|
||||||
onclick={() => selectFolder(child.path)}
|
|
||||||
>
|
|
||||||
{child.path.split('/').pop()}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedFolder}
|
|
||||||
<p class="mt-2 text-xs text-[var(--text-tertiary)]">{$t('sites.selectedFolder')}: <strong>{selectedFolder || '/'}</strong></p>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-between">
|
|
||||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 2; }}>
|
|
||||||
{$t('common.back')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] transition-colors"
|
|
||||||
onclick={() => goToStep(4)}
|
|
||||||
>
|
|
||||||
{$t('common.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 4: Configuration -->
|
|
||||||
{:else if step === 4}
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step4Title')}</h2>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
||||||
<FormField label={$t('sites.siteName')} name="siteName" bind:value={siteName} placeholder="my-site" required />
|
|
||||||
<FormField label={$t('sites.domain')} name="domain" bind:value={domain} placeholder="site.example.com" helpText={$t('sites.domainHelp')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Mode -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.mode')}</label>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'static' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
|
|
||||||
onclick={() => { mode = 'static'; }}
|
|
||||||
>
|
|
||||||
<div class="font-medium text-[var(--text-primary)]">Static</div>
|
|
||||||
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeStaticDesc')}</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 rounded-lg border px-4 py-3 text-sm text-left transition-colors {mode === 'deno' ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] hover:bg-[var(--surface-card-hover)]'}"
|
|
||||||
onclick={() => { mode = 'deno'; }}
|
|
||||||
>
|
|
||||||
<div class="font-medium text-[var(--text-primary)]">Deno</div>
|
|
||||||
<div class="text-xs text-[var(--text-tertiary)] mt-0.5">{$t('sites.modeDenoDesc')}</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sync trigger -->
|
|
||||||
<div class="space-y-2">
|
|
||||||
<label class="text-sm font-medium text-[var(--text-primary)]">{$t('sites.syncTrigger')}</label>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
{#each [
|
|
||||||
{ value: 'manual', label: $t('sites.triggerManual') },
|
|
||||||
{ value: 'push', label: $t('sites.triggerPush') },
|
|
||||||
{ value: 'tag', label: $t('sites.triggerTag') }
|
|
||||||
] as opt}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex-1 rounded-lg border px-4 py-2.5 text-sm text-center font-medium transition-colors {syncTrigger === opt.value ? 'border-[var(--color-brand-600)] bg-[var(--color-brand-50)] text-[var(--color-brand-700)] dark:bg-[var(--color-brand-900)]/20' : 'border-[var(--border-primary)] text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)]'}"
|
|
||||||
onclick={() => { syncTrigger = opt.value as 'push' | 'tag' | 'manual'; }}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if syncTrigger === 'tag'}
|
|
||||||
<FormField label={$t('sites.tagPattern')} name="tagPattern" bind:value={tagPattern} placeholder="v*" helpText={$t('sites.tagPatternHelp')} />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Options -->
|
|
||||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
|
||||||
<ToggleSwitch bind:checked={renderMarkdown} label={$t('sites.renderMarkdown')} />
|
|
||||||
<span>{$t('sites.renderMarkdown')}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Persistent Storage (Deno only) -->
|
|
||||||
{#if mode === 'deno'}
|
|
||||||
<div class="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
|
||||||
<ToggleSwitch bind:checked={storageEnabled} label={$t('sites.enableStorage')} />
|
|
||||||
<span>{$t('sites.enableStorage')}</span>
|
|
||||||
</div>
|
|
||||||
{#if storageEnabled}
|
|
||||||
<div class="space-y-3 rounded-lg border border-[var(--border-secondary)] p-4">
|
|
||||||
<p class="text-xs text-[var(--text-tertiary)]">{$t('sites.storageHelp')}</p>
|
|
||||||
<FormField
|
|
||||||
label={$t('sites.storageLimitMB')}
|
|
||||||
name="storageLimitMB"
|
|
||||||
type="number"
|
|
||||||
bind:value={storageLimitStr}
|
|
||||||
placeholder="0"
|
|
||||||
helpText={$t('sites.storageLimitHelp')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-between">
|
|
||||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 3; }}>
|
|
||||||
{$t('common.back')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
|
||||||
disabled={!siteName.trim()}
|
|
||||||
onclick={() => { step = 5; }}
|
|
||||||
>
|
|
||||||
{$t('common.next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Step 5: Review -->
|
|
||||||
{:else if step === 5}
|
|
||||||
<h2 class="text-lg font-semibold text-[var(--text-primary)] mb-4">{$t('sites.step5Title')}</h2>
|
|
||||||
|
|
||||||
<div class="space-y-3 text-sm">
|
|
||||||
<div class="grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg bg-[var(--surface-card-hover)] p-4">
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.provider')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{providerOptions.find(o => o.value === effectiveProvider)?.label ?? effectiveProvider}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.repoUrl')}</span>
|
|
||||||
<span class="text-[var(--text-primary)] font-mono text-xs">{giteaUrl}/{repoOwner}/{repoName}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.branch')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{selectedBranch}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.folder')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{selectedFolder || '/ (root)'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.siteName')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{siteName}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.domain')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{domain || '-'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.mode')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{mode === 'deno' ? 'Deno (Static + API)' : 'Static (Nginx)'}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.syncTrigger')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{syncTrigger}{syncTrigger === 'tag' ? ` (${tagPattern})` : ''}</span>
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.renderMarkdown')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{renderMarkdown ? $t('common.yes') : $t('common.no')}</span>
|
|
||||||
|
|
||||||
{#if mode === 'deno'}
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.storage')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{storageEnabled ? (parseInt(storageLimitStr, 10) > 0 ? `${storageLimitStr} MB` : $t('sites.unlimited')) : $t('common.no')}</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<span class="text-[var(--text-tertiary)]">{$t('sites.accessToken')}</span>
|
|
||||||
<span class="text-[var(--text-primary)]">{accessToken ? '••••••••' : $t('sites.noToken')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if submitError}
|
|
||||||
<div class="mt-4 rounded-lg bg-[var(--color-danger-light)] p-3">
|
|
||||||
<p class="text-sm text-[var(--color-danger)]">{submitError}</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="mt-6 flex justify-between">
|
|
||||||
<button type="button" class="rounded-lg border border-[var(--border-primary)] px-4 py-2.5 text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--surface-card-hover)] transition-colors" onclick={() => { step = 4; }}>
|
|
||||||
{$t('common.back')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-lg bg-[var(--color-brand-600)] px-4 py-2.5 text-sm font-medium text-white hover:bg-[var(--color-brand-700)] disabled:opacity-50 transition-colors"
|
|
||||||
disabled={submitting}
|
|
||||||
onclick={handleSubmit}
|
|
||||||
>
|
|
||||||
{#if submitting}
|
|
||||||
<IconLoader size={14} class="inline mr-1 animate-spin" />
|
|
||||||
{/if}
|
|
||||||
{$t('sites.createSite')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,535 +0,0 @@
|
|||||||
<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';
|
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
|
|
||||||
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: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
|
||||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
|
||||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
|
||||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onMount(loadStacks);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="forge">
|
|
||||||
{#snippet stacksToolbar()}
|
|
||||||
<button class="forge-btn-icon" onclick={loadStacks} aria-label={$t('stacks.refresh')}>
|
|
||||||
<IconRefresh size={16} />
|
|
||||||
</button>
|
|
||||||
<a href="/stacks/new" class="forge-btn">
|
|
||||||
<IconPlus size={14} />
|
|
||||||
<span>{$t('stacks.newStack')}</span>
|
|
||||||
</a>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet stacksStats()}
|
|
||||||
<div><dt>{$t('stacks.total').toUpperCase()}</dt><dd>{loading ? '—' : String(stacks.length).padStart(2, '0')}</dd></div>
|
|
||||||
<div><dt>{$t('stacks.running').toUpperCase()}</dt><dd class="accent">{loading ? '—' : stacks.filter(s=>s.status==='running').length}</dd></div>
|
|
||||||
<div><dt>{$t('stacks.deploying').toUpperCase()}</dt><dd>{loading ? '—' : stacks.filter(s=>s.status==='deploying').length}</dd></div>
|
|
||||||
<div><dt>{$t('stacks.failed').toUpperCase()}</dt><dd class:warn={stacks.some(s=>s.status==='failed')}>{loading ? '—' : stacks.filter(s=>s.status==='failed').length}</dd></div>
|
|
||||||
{/snippet}
|
|
||||||
{#snippet stacksLede()}{@html $t('stacks.lede')}{/snippet}
|
|
||||||
<ForgeHero
|
|
||||||
eyebrow={$t('stacks.eyebrow')}
|
|
||||||
eyebrowSuffix={$t('stacks.title').toUpperCase()}
|
|
||||||
title={$t('stacks.title')}
|
|
||||||
size="lg"
|
|
||||||
toolbar={stacksToolbar}
|
|
||||||
lede_html={stacksLede}
|
|
||||||
stats={stacksStats}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{#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>{$t('stacks.empty.title')}</h2>
|
|
||||||
<p>{$t('stacks.empty.desc')}</p>
|
|
||||||
<a href="/stacks/new" class="btn-primary">
|
|
||||||
<IconPlus size={16} /><span>{$t('stacks.newStack')}</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">{$t('stacks.card.noDescription')}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if s.error}
|
|
||||||
<div class="card-err" title={s.error}>{s.error}</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="card-meta">
|
|
||||||
<span class="meta-k">{$t('stacks.card.updated')}</span>
|
|
||||||
<span class="meta-v">{$fmt.dateTime(s.updated_at)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="card-foot">
|
|
||||||
{#if s.status === 'running'}
|
|
||||||
<button class="act" onclick={() => handleStop(s)} aria-label={$t('stacks.card.stop')}>
|
|
||||||
<IconStop size={13} /><span>{$t('stacks.card.stop')}</span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button class="act" onclick={() => handleStart(s)} aria-label={$t('stacks.card.start')}>
|
|
||||||
<IconPlay size={13} /><span>{$t('stacks.card.start')}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button class="act danger" onclick={() => (confirmDelete = s)} aria-label={$t('stacks.card.delete')}>
|
|
||||||
<IconTrash size={13} /><span>{$t('stacks.card.delete')}</span>
|
|
||||||
</button>
|
|
||||||
<a class="act-link" href="/stacks/{s.id}">{$t('stacks.card.open')} <span class="arrow">→</span></a>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmDelete !== null}
|
|
||||||
title={$t('stacks.detail.delete.title')}
|
|
||||||
message={confirmDelete ? $t('stacks.detail.delete.messageBase', { name: confirmDelete.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
|
||||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={handleDelete}
|
|
||||||
oncancel={() => { confirmDelete = null; deleteRemoveVolumes = false; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.forge {
|
|
||||||
--serif: var(--font-family-sans);
|
|
||||||
--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);
|
|
||||||
}
|
|
||||||
.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(2rem, 4vw, 2.75rem);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.title-accent {
|
|
||||||
color: var(--accent);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.lede {
|
|
||||||
font-family: var(--serif);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0.75rem 0 0;
|
|
||||||
max-width: 60ch;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
line-height: 1.55;
|
|
||||||
}
|
|
||||||
.lede :global(em) {
|
|
||||||
color: var(--accent);
|
|
||||||
font-style: normal;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 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: 700;
|
|
||||||
font-size: 1.5rem; margin: 0 0 0.5rem;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
.empty p { color: var(--text-secondary); margin: 0 0 1.5rem; font-size: 0.95rem; }
|
|
||||||
.empty :global(.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.15rem; line-height: 1.3;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
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>
|
|
||||||
@@ -1,953 +0,0 @@
|
|||||||
<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';
|
|
||||||
import { t } from '$lib/i18n';
|
|
||||||
import { fmt } from '$lib/format/datetime';
|
|
||||||
|
|
||||||
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 : $t('stacks.detail.errors.load');
|
|
||||||
} 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 : $t('stacks.detail.errors.stop'); }
|
|
||||||
}
|
|
||||||
async function handleStart() {
|
|
||||||
if (!stack) return;
|
|
||||||
try { await api.startStack(stack.id); setTimeout(loadAll, 1500); }
|
|
||||||
catch (e) { error = e instanceof Error ? e.message : $t('stacks.detail.errors.start'); }
|
|
||||||
}
|
|
||||||
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 : $t('stacks.detail.errors.update'); }
|
|
||||||
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 : $t('stacks.detail.errors.rollback'); }
|
|
||||||
}
|
|
||||||
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 : $t('stacks.detail.errors.delete'); }
|
|
||||||
}
|
|
||||||
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 : $t('stacks.detail.errors.fetchLogs'); }
|
|
||||||
finally { logsLoading = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusMeta(status: string) {
|
|
||||||
switch (status) {
|
|
||||||
case 'running': return { label: $t('stacks.running').toUpperCase(), cls: 'st-running' };
|
|
||||||
case 'deploying':return { label: $t('stacks.deploying').toUpperCase(), cls: 'st-deploying' };
|
|
||||||
case 'failed': return { label: $t('stacks.failed').toUpperCase(), cls: 'st-failed' };
|
|
||||||
default: return { label: $t('stacks.stopped').toUpperCase(), cls: 'st-stopped' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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>{$t('stacks.title').toUpperCase()}</span>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{#if loading && !stack}
|
|
||||||
<div class="loading">
|
|
||||||
<span class="spinner"></span>
|
|
||||||
<span>{$t('stacks.detail.loading')}</span>
|
|
||||||
</div>
|
|
||||||
{:else if error && !stack}
|
|
||||||
<div class="alert"><span class="alert-tag">{$t('stacks.detail.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>STACK</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">{$t('stacks.detail.noDescription')}</p>
|
|
||||||
{/if}
|
|
||||||
<span class="project-chip">
|
|
||||||
<span class="chip-k">{$t('stacks.detail.composeProject')}</span>
|
|
||||||
<code>{stack.compose_project_name}</code>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="toolbar">
|
|
||||||
<button class="btn-ghost" onclick={loadAll} aria-label={$t('stacks.detail.refresh')}>
|
|
||||||
<IconRefresh size={15} />
|
|
||||||
</button>
|
|
||||||
{#if stack.status === 'running'}
|
|
||||||
<button onclick={handleStop} class="chip-btn">
|
|
||||||
<IconStop size={13} /> <span>{$t('stacks.detail.stop')}</span>
|
|
||||||
</button>
|
|
||||||
{:else}
|
|
||||||
<button onclick={handleStart} class="chip-btn primary">
|
|
||||||
<IconPlay size={13} /> <span>{$t('stacks.detail.start')}</span>
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button onclick={() => (confirmDelete = true)} class="chip-btn danger">
|
|
||||||
<IconTrash size={13} /> <span>{$t('stacks.detail.delete')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if stack.error}
|
|
||||||
<div class="alert">
|
|
||||||
<span class="alert-tag">{$t('stacks.detail.fault')}</span>
|
|
||||||
<span>{stack.error}</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- ── Stat tiles ─────────────────────────────── -->
|
|
||||||
<section class="stats">
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">{$t('stacks.detail.stats.services')}</span>
|
|
||||||
<span class="stat-value">{String(services.length).padStart(2,'0')}</span>
|
|
||||||
<span class="stat-sub">{$t('stacks.detail.stats.servicesSub')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">{$t('stacks.detail.stats.running')}</span>
|
|
||||||
<span class="stat-value accent">
|
|
||||||
{String(services.filter(s => serviceState(s.State) === 'running').length).padStart(2,'0')}
|
|
||||||
</span>
|
|
||||||
<span class="stat-sub">{$t('stacks.detail.stats.runningSub')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">{$t('stacks.detail.stats.revisions')}</span>
|
|
||||||
<span class="stat-value">{String(revisions.length).padStart(2,'0')}</span>
|
|
||||||
<span class="stat-sub">{$t('stacks.detail.stats.revisionsSub')}</span>
|
|
||||||
</div>
|
|
||||||
<div class="stat">
|
|
||||||
<span class="stat-label">{$t('stacks.detail.stats.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">{$t('stacks.detail.stats.currentSub')}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Services ───────────────────────────────── -->
|
|
||||||
<section class="panel">
|
|
||||||
<header class="panel-head">
|
|
||||||
<h2 class="panel-title">{$t('stacks.detail.services.title')}<span class="title-accent">.</span></h2>
|
|
||||||
<span class="panel-count">{$t('stacks.detail.services.count', { n: String(services.length) })}</span>
|
|
||||||
</header>
|
|
||||||
{#if services.length === 0}
|
|
||||||
<p class="panel-empty">{$t('stacks.detail.services.empty')}</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>{$t('stacks.detail.tabs.blueprint')}</span>
|
|
||||||
</button>
|
|
||||||
<button role="tab" class="tab" class:active={tab==='revisions'} onclick={() => tab='revisions'}>
|
|
||||||
<span class="tab-num">II</span><span>{$t('stacks.detail.tabs.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>{$t('stacks.detail.tabs.logs')}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if tab === 'yaml'}
|
|
||||||
<div class="panel-body">
|
|
||||||
<div class="panel-toolbar">
|
|
||||||
<span class="dim">{$t('stacks.detail.yaml.currentRevision')}</span>
|
|
||||||
{#if !editing}
|
|
||||||
<button class="chip" onclick={() => (editing = true)}>{$t('stacks.detail.yaml.edit')}</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)}>{$t('stacks.detail.yaml.cancel')}</button>
|
|
||||||
<button class="btn-primary" onclick={submitNewRevision} disabled={submitting}>
|
|
||||||
<span>{submitting ? $t('stacks.detail.yaml.forging') : $t('stacks.detail.yaml.deployNew')}</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">{$t('stacks.detail.revisions.current')}</span>
|
|
||||||
{/if}
|
|
||||||
<span class="tl-status">{rev.status}</span>
|
|
||||||
<span class="tl-time">{$fmt.dateTime(rev.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div class="tl-meta">
|
|
||||||
{$t('stacks.detail.revisions.by')} <strong>{rev.author || 'operator'}</strong>
|
|
||||||
</div>
|
|
||||||
{#if rev.id !== stack.current_revision_id}
|
|
||||||
<button class="tl-action" onclick={() => (confirmRollback = rev)}>
|
|
||||||
{$t('stacks.detail.revisions.rollback')}
|
|
||||||
</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">{$t('stacks.detail.logs.service')}</span>
|
|
||||||
<select bind:value={logsService}>
|
|
||||||
<option value="">{$t('stacks.detail.logs.allServices')}</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 ? $t('stacks.detail.logs.fetching') : $t('stacks.detail.logs.fetch')}
|
|
||||||
</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">{$t('stacks.detail.logs.empty')}</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmRollback !== null}
|
|
||||||
title={$t('stacks.detail.revisions.rollbackTitle')}
|
|
||||||
message={confirmRollback ? $t('stacks.detail.revisions.rollbackMessage', { n: String(confirmRollback.revision) }) : ''}
|
|
||||||
confirmLabel={$t('stacks.detail.revisions.rollbackConfirm')}
|
|
||||||
confirmVariant="primary"
|
|
||||||
onconfirm={doRollback}
|
|
||||||
oncancel={() => (confirmRollback = null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={confirmDelete}
|
|
||||||
title={$t('stacks.detail.delete.title')}
|
|
||||||
message={stack ? $t('stacks.detail.delete.messageBase', { name: stack.name }) + (deleteRemoveVolumes ? $t('stacks.detail.delete.messageVolumes') : '') : ''}
|
|
||||||
confirmLabel={$t('stacks.detail.delete.confirm')}
|
|
||||||
confirmVariant="danger"
|
|
||||||
onconfirm={doDelete}
|
|
||||||
oncancel={() => { confirmDelete = false; deleteRemoveVolumes = false; }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.forge {
|
|
||||||
--serif: var(--font-family-sans);
|
|
||||||
--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(1.875rem, 4vw, 2.5rem);
|
|
||||||
font-weight: 700; line-height: 1.1;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
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: 2rem; line-height: 1.1;
|
|
||||||
font-weight: 700; letter-spacing: -0.02em;
|
|
||||||
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.35rem;
|
|
||||||
margin: 0; font-weight: 600; line-height: 1.2;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
|
||||||
.title-accent { color: var(--accent); font-weight: 700; }
|
|
||||||
.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>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user