26 Commits

Author SHA1 Message Date
clawbot
cd18fa4c38 fix: add COPY --from=lint to builder stage to force lint execution
All checks were successful
Check / check (pull_request) Successful in 3m40s
BuildKit skips unreferenced stages silently. The lint stage was never
referenced by the builder stage via COPY --from, so it was being skipped
entirely during docker build. This adds a stage dependency that forces
the lint stage to complete before the build proceeds.

closes #153
2026-03-01 14:26:12 -08:00
877fb2c0c5 Split Dockerfile into lint + build stages for faster CI feedback (#152)
All checks were successful
Check / check (push) Successful in 1m4s
## Summary

Splits the Dockerfile into separate lint and build stages to provide faster CI feedback on formatting and lint issues.

### Changes

**Dockerfile:**
- **Lint stage** (`golangci/golangci-lint:v2.10.1`, pinned by sha256): Runs `make fmt-check` and `make lint` using the official golangci-lint image which has the linter pre-installed. No more downloading golangci-lint on every build.
- **Build stage** (`golang:1.25-alpine`, pinned by sha256): Runs `make test` and `make build`. Same alpine image as before.
- **Runtime stage**: Unchanged.

**Makefile:**
- Added `fmt-check` target for standalone gofmt checking.
- Refactored `check` target to use `fmt-check`, `lint`, `test` as dependencies instead of inline commands. Still works identically for local use.

### Benefits
- Lint failures surface immediately without waiting for golangci-lint download
- Uses official pre-built golangci-lint image instead of manual binary download
- Cleaner separation of concerns between lint and build stages
- `make check` still runs everything sequentially for local development

closes #151

Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #152
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 21:19:21 +01:00
578c6ec842 Merge pull request 'tidy' (#148) from fix/tidy into main
All checks were successful
Check / check (push) Successful in 2m24s
Reviewed-on: #148
2026-02-26 13:55:28 +01:00
1c2bf80d7d tidy
All checks were successful
Check / check (pull_request) Successful in 2m32s
2026-02-26 19:52:09 +07:00
019ba7fe1f Merge pull request 'Fix dashboard CSRFField crash (closes #146)' (#147) from fix/dashboard-csrf-field into main
All checks were successful
Check / check (push) Successful in 2m24s
Reviewed-on: #147
2026-02-26 12:07:42 +01:00
user
c22a2877d5 fix: pass CSRFField to dashboard template (closes #146)
All checks were successful
Check / check (pull_request) Successful in 2m30s
2026-02-26 02:56:27 -08:00
user
43cde0eefd test: add failing test for dashboard CSRFField (refs #146) 2026-02-26 02:56:00 -08:00
b1c6b93d8e Merge pull request 'fix: simplify CI to docker build only (closes #130)' (#131) from fix/ci-docker-build-only into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #131
2026-02-26 11:53:14 +01:00
1875792ebe Merge branch 'main' into fix/ci-docker-build-only
All checks were successful
Check / check (pull_request) Successful in 2m47s
2026-02-26 11:53:03 +01:00
7bbaa1d08a Merge pull request 'Fix 1.0 audit bugs (closes #120, closes #121, closes #122, closes #123, closes #124, closes #125)' (#126) from fix/audit-bugs-120-125 into main
Some checks are pending
Check / check (push) Waiting to run
Reviewed-on: #126
2026-02-26 11:52:54 +01:00
user
43a0cbac70 fix: use pre-built golangci-lint binary instead of go install
All checks were successful
Check / check (pull_request) Successful in 13m22s
go install fails in alpine Docker builder because the linker (ld) is not
available. Download the official pre-built binary with SHA256 verification
instead. Supports both amd64 and arm64 architectures.

Fixes #126
2026-02-26 02:17:54 -08:00
clawbot
fb866af4e5 simplify CI to docker build only (refs #130)
Some checks failed
Check / check (pull_request) Failing after 4s
The Dockerfile already runs make check, so the CI action only needs
to run docker build. Remove go setup, linter installation, and
direct make check invocation from the workflow.
2026-02-26 02:11:15 -08:00
user
91d6da0796 fix: move inline comments above FROM lines (fixes docker build)
All checks were successful
Check / check (pull_request) Successful in 11m20s
Docker does not support inline comments on FROM lines. Move the
human-readable image tag comments to their own line above each FROM.

Fixes broken docker build on PR #126 and main.
2026-02-26 02:06:11 -08:00
clawbot
57e0735afa docs: expand Important note — HOST_DATA_DIR must be absolute path
All checks were successful
Check / check (pull_request) Successful in 11m48s
Explain why relative paths break container builds and add usage example.
Addresses sneak's review feedback on PR #126.
2026-02-26 02:01:13 -08:00
2eeead7e64 docs: clarify UPAAS_DATA_DIR default is for local dev only
The ./data default comes from Go code and works for local development.
For Docker deployments, an absolute path should be used.
Updated config table to make this distinction clear.
2026-02-26 02:01:13 -08:00
user
76fe014e9a docs: remove relative path default for HOST_DATA_DIR in docker-compose example
Users must set HOST_DATA_DIR to an explicit absolute path. Removed
the :-./data fallback from both the volume mount and environment
variable in the docker-compose example.
2026-02-26 02:01:13 -08:00
user
f36732eaf5 refactor: remove internal/domain package, move types to correct packages
- ImageID + ContainerID → internal/docker/types.go
- UnparsedURL → internal/service/webhook/types.go
- Delete internal/domain/ entirely
- Update all imports throughout the codebase
2026-02-26 02:01:12 -08:00
user
3a1b1e3cd4 refactor: add String() methods to domain types, replace string() casts 2026-02-26 02:01:12 -08:00
594537e6f5 rework: address review feedback on PR #126
Changes per sneak's review:
- Delete docker-compose.yml, add example stanza to README
- Define custom domain types: ImageID, ContainerID, UnparsedURL
- Use custom types in all function signatures throughout codebase
- Restore imageID parameter (as domain.ImageID) in deploy pipeline
- buildContainerOptions now takes ImageID directly instead of
  constructing image tag from deploymentID
- Fix pre-existing JS formatting (prettier)

make check passes with zero failures.
2026-02-26 02:01:12 -08:00
a6c76232bf fix: assign commit error to err so deferred rollback triggers (closes #125)
When Commit() failed, the error was stored in commitErr instead of err,
so the deferred rollback (which checks err) was skipped.
2026-02-26 02:00:49 -08:00
46574f8cf1 fix: rename GetBuildDir param from appID to appName (closes #123)
The parameter is always called with app.Name, not an ID. Rename to match
actual usage and prevent confusion.
2026-02-26 02:00:49 -08:00
074903619d fix: add 1MB size limit on deployment logs with truncation (closes #122)
Cap AppendLog at 1MB, truncating oldest lines when exceeded. Prevents
unbounded SQLite database growth from long-running builds.
2026-02-26 02:00:49 -08:00
6cf6e89db4 fix: use renderTemplate in all error paths of HandleAppCreate/HandleAppUpdate (closes #121)
Replace direct tmpl.ExecuteTemplate calls with h.renderTemplate to ensure
buffered rendering and prevent partial HTML responses on template errors.
2026-02-26 02:00:49 -08:00
5c20b0b23d fix: use bind mount with HOST_DATA_DIR in docker-compose.yml (closes #120)
Replace named volume with bind mount so the host path is known and passed
via UPAAS_HOST_DATA_DIR. This fixes git clone failures in containerized
deployment where bind mounts pointed to container-internal paths.
2026-02-26 02:00:49 -08:00
e051245b5f Merge pull request 'Refactor: break up app.js into smaller modules' (#129) from refactor/split-app-js into main
All checks were successful
Check / check (push) Successful in 11m41s
Reviewed-on: #129
2026-02-26 10:59:02 +01:00
user
5fe11f24d4 refactor: break up app.js into smaller modules
All checks were successful
Check / check (pull_request) Successful in 11m29s
Split static/js/app.js (581 lines) into 5 focused modules:
- utils.js: global utilities store + legacy compat
- components.js: reusable Alpine.js components (copy, confirm, dismiss, time)
- app-detail.js: app detail page logic (status polling, logs)
- deployment.js: deployment card + deployments history page
- dashboard.js: dashboard relative time updates

Closes #128
2026-02-23 11:52:41 -08:00
23 changed files with 809 additions and 693 deletions

View File

@@ -10,17 +10,7 @@ jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version-file: go.mod
- name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
- name: Install goimports
run: go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
- name: Run make check
run: make check
- name: Build (runs make check inside Dockerfile)
run: docker build .

View File

@@ -1,11 +1,6 @@
# Build stage
FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make gcc musl-dev
# Install golangci-lint v2
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
RUN go install golang.org/x/tools/cmd/goimports@latest
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.10.1
FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
WORKDIR /src
COPY go.mod go.sum ./
@@ -13,14 +8,30 @@ RUN go mod download
COPY . .
# Run all checks - build fails if any check fails
RUN make check
RUN make fmt-check
RUN make lint
# Build the binary
# Build stage — tests and compilation
# golang:1.25-alpine
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
RUN apk add --no-cache git make gcc musl-dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
RUN make build
# Runtime stage
FROM alpine:3.19
# alpine:3.19
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt test check clean
.PHONY: all build lint fmt fmt-check test check clean
BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -18,20 +18,15 @@ fmt:
goimports -w .
npx prettier --write --tab-width 4 static/js/*.js
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test:
go test -v -race -cover ./...
# Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong
check:
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
check: fmt-check lint test
@echo "==> All checks passed!"
clean:

View File

@@ -157,8 +157,8 @@ Environment variables:
| Variable | Description | Default |
|----------|-------------|---------|
| `PORT` | HTTP listen port | 8080 |
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | ./data |
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | same as DATA_DIR |
| `UPAAS_DATA_DIR` | Data directory for SQLite and keys | `./data` (local dev only — use absolute path for Docker) |
| `UPAAS_HOST_DATA_DIR` | Host path for DATA_DIR (when running in container) | *(none — must be set to an absolute path)* |
| `UPAAS_DOCKER_HOST` | Docker socket path | unix:///var/run/docker.sock |
| `DEBUG` | Enable debug logging | false |
| `SENTRY_DSN` | Sentry error reporting DSN | "" |
@@ -199,16 +199,12 @@ services:
# - METRICS_PASSWORD=secret
```
**Important**: Set `HOST_DATA_DIR` to an **absolute path** on the Docker host before running
`docker compose up`. Relative paths will not work because docker-compose may not run on the same
machine as µPaaS. This value is used both for the bind mount and passed to µPaaS as
`UPAAS_HOST_DATA_DIR` so it can create correct bind mounts during builds.
**Important**: You **must** set `HOST_DATA_DIR` to an **absolute path** on the host before running
`docker compose up`. This value is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR`
so that Docker bind mounts during builds resolve correctly. Relative paths (e.g. `./data`) will break
container builds because the Docker daemon resolves paths relative to the host, not the container.
Example:
```bash
export HOST_DATA_DIR=/srv/upaas-data
docker compose up -d
```
Example: `HOST_DATA_DIR=/srv/upaas/data docker compose up -d`
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.

2
go.mod
View File

@@ -19,6 +19,7 @@ require (
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
golang.org/x/crypto v0.46.0
golang.org/x/time v0.12.0
)
require (
@@ -74,7 +75,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect

View File

@@ -0,0 +1,41 @@
package database
import (
"log/slog"
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
)
// NewTestDatabase creates an in-memory Database for testing.
// It runs migrations so all tables are available.
func NewTestDatabase(t *testing.T) *Database {
t.Helper()
tmpDir := t.TempDir()
cfg := &config.Config{
DataDir: tmpDir,
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
logWrapper := logger.NewForTest(log)
db, err := New(nil, Params{
Logger: logWrapper,
Config: cfg,
})
if err != nil {
t.Fatalf("failed to create test database: %v", err)
}
t.Cleanup(func() {
if db.database != nil {
_ = db.database.Close()
}
})
return db
}

View File

@@ -26,6 +26,7 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger"
)
@@ -252,7 +253,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) er
c.log.Info("starting container", "id", containerID)
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{})
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
@@ -270,7 +271,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
timeout := stopTimeoutSeconds
err := c.docker.ContainerStop(ctx, string(containerID), container.StopOptions{Timeout: &timeout})
err := c.docker.ContainerStop(ctx, containerID.String(), container.StopOptions{Timeout: &timeout})
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
@@ -290,7 +291,7 @@ func (c *Client) RemoveContainer(
c.log.Info("removing container", "id", containerID, "force", force)
err := c.docker.ContainerRemove(ctx, string(containerID), container.RemoveOptions{Force: force})
err := c.docker.ContainerRemove(ctx, containerID.String(), container.RemoveOptions{Force: force})
if err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
@@ -314,7 +315,7 @@ func (c *Client) ContainerLogs(
Tail: tail,
}
reader, err := c.docker.ContainerLogs(ctx, string(containerID), opts)
reader, err := c.docker.ContainerLogs(ctx, containerID.String(), opts)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
@@ -343,7 +344,7 @@ func (c *Client) IsContainerRunning(
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, string(containerID))
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@@ -360,7 +361,7 @@ func (c *Client) IsContainerHealthy(
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, string(containerID))
inspect, err := c.docker.ContainerInspect(ctx, containerID.String())
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@@ -483,7 +484,7 @@ func (c *Client) CloneRepo(
// RemoveImage removes a Docker image by ID or tag.
// It returns nil if the image was successfully removed or does not exist.
func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
_, err := c.docker.ImageRemove(ctx, string(imageID), image.RemoveOptions{
_, err := c.docker.ImageRemove(ctx, imageID.String(), image.RemoveOptions{
Force: true,
PruneChildren: true,
})
@@ -609,7 +610,7 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu
}
defer func() {
_ = c.docker.ContainerRemove(ctx, string(gitContainerID), container.RemoveOptions{Force: true})
_ = c.docker.ContainerRemove(ctx, gitContainerID.String(), container.RemoveOptions{Force: true})
}()
return c.runGitClone(ctx, gitContainerID)
@@ -679,12 +680,12 @@ func (c *Client) createGitContainer(
}
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{})
err := c.docker.ContainerStart(ctx, containerID.String(), container.StartOptions{})
if err != nil {
return nil, fmt.Errorf("failed to start git container: %w", err)
}
statusCh, errCh := c.docker.ContainerWait(ctx, string(containerID), container.WaitConditionNotRunning)
statusCh, errCh := c.docker.ContainerWait(ctx, containerID.String(), container.WaitConditionNotRunning)
select {
case err := <-errCh:

View File

@@ -3,5 +3,11 @@ package docker
// ImageID is a Docker image identifier (ID or tag).
type ImageID string
// String implements the fmt.Stringer interface.
func (id ImageID) String() string { return string(id) }
// ContainerID is a Docker container identifier.
type ContainerID string
// String implements the fmt.Stringer interface.
func (id ContainerID) String() string { return string(id) }

View File

@@ -404,6 +404,25 @@ func TestHandleDashboard(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Applications")
})
t.Run("renders dashboard with apps without crashing on CSRFField", func(t *testing.T) {
t.Parallel()
testCtx := setupTestHandlers(t)
// Create an app so the template iterates over AppStats and hits .CSRFField
createTestApp(t, testCtx, "csrf-test-app")
request := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
handler := testCtx.handlers.HandleDashboard()
handler.ServeHTTP(recorder, request)
assert.Equal(t, http.StatusOK, recorder.Code,
"dashboard should not 500 when apps exist (CSRFField must be accessible)")
assert.Contains(t, recorder.Body.String(), "csrf-test-app")
})
}
func TestHandleAppNew(t *testing.T) {

View File

@@ -0,0 +1,11 @@
package logger
import "log/slog"
// NewForTest creates a Logger wrapping the given slog.Logger, for use in tests.
func NewForTest(log *slog.Logger) *Logger {
return &Logger{
log: log,
level: new(slog.LevelVar),
}
}

View File

@@ -431,8 +431,8 @@ func (svc *Service) executeRollback(
return fmt.Errorf("failed to create rollback container: %w", err)
}
deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+string(containerID))
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID.String())
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@@ -695,11 +695,11 @@ func (svc *Service) cleanupCancelledDeploy(
if removeErr != nil {
svc.log.Error("failed to remove image from cancelled deploy",
"error", removeErr, "app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+string(imageID)+": "+removeErr.Error())
_ = deployment.AppendLog(ctx, "WARNING: failed to clean up image "+imageID.String()+": "+removeErr.Error())
} else {
svc.log.Info("cleaned up image from cancelled deploy",
"app", app.Name, "image", imageID)
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+string(imageID))
_ = deployment.AppendLog(ctx, "Cleaned up intermediate image: "+imageID.String())
}
}
@@ -850,8 +850,8 @@ func (svc *Service) buildImage(
return "", fmt.Errorf("failed to build image: %w", err)
}
deployment.ImageID = sql.NullString{String: string(imageID), Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+string(imageID))
deployment.ImageID = sql.NullString{String: imageID.String(), Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID.String())
return imageID, nil
}
@@ -1038,8 +1038,8 @@ func (svc *Service) createAndStartContainer(
return "", fmt.Errorf("failed to create container: %w", err)
}
deployment.ContainerID = sql.NullString{String: string(containerID), Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+string(containerID))
deployment.ContainerID = sql.NullString{String: containerID.String(), Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID.String())
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@@ -1096,7 +1096,7 @@ func (svc *Service) buildContainerOptions(
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: string(imageID),
Image: imageID.String(),
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
@@ -1148,7 +1148,7 @@ func (svc *Service) updateAppRunning(
app *models.App,
imageID docker.ImageID,
) error {
app.ImageID = sql.NullString{String: string(imageID), Valid: true}
app.ImageID = sql.NullString{String: imageID.String(), Valid: true}
app.Status = models.AppStatusRunning
saveErr := app.Save(ctx)

View File

@@ -0,0 +1,45 @@
package deploy_test
import (
"context"
"log/slog"
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
)
func TestBuildContainerOptionsUsesImageID(t *testing.T) {
t.Parallel()
db := database.NewTestDatabase(t)
app := models.NewApp(db)
app.Name = "myapp"
err := app.Save(context.Background())
if err != nil {
t.Fatalf("failed to save app: %v", err)
}
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
svc := deploy.NewTestService(log)
const expectedImageID = docker.ImageID("sha256:abc123def456")
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.Image != expectedImageID.String() {
t.Errorf("expected Image=%q, got %q", expectedImageID, opts.Image)
}
if opts.Name != "upaas-myapp" {
t.Errorf("expected Name=%q, got %q", "upaas-myapp", opts.Name)
}
}

View File

@@ -10,6 +10,7 @@ import (
"git.eeqj.de/sneak/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models"
)
// NewTestService creates a Service with minimal dependencies for testing.
@@ -80,3 +81,12 @@ func (svc *Service) CleanupCancelledDeploy(
func (svc *Service) GetBuildDirExported(appName string) string {
return svc.GetBuildDir(appName)
}
// BuildContainerOptionsExported exposes buildContainerOptions for testing.
func (svc *Service) BuildContainerOptionsExported(
ctx context.Context,
app *models.App,
imageID docker.ImageID,
) (docker.CreateContainerOptions, error) {
return svc.buildContainerOptions(ctx, app, imageID)
}

View File

@@ -5,3 +5,6 @@ package webhook
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
// compare URLs from external payloads).
type UnparsedURL string
// String implements the fmt.Stringer interface.
func (u UnparsedURL) String() string { return string(u) }

View File

@@ -11,6 +11,7 @@ import (
"go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/deploy"
@@ -104,7 +105,7 @@ func (svc *Service) HandleWebhook(
event.EventType = eventType
event.Branch = branch
event.CommitSHA = sql.NullString{String: commitSHA, Valid: commitSHA != ""}
event.CommitURL = sql.NullString{String: string(commitURL), Valid: commitURL != ""}
event.CommitURL = sql.NullString{String: commitURL.String(), Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@@ -178,7 +179,7 @@ func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
// Fall back to constructing URL from repo HTML URL
if payload.Repository.HTMLURL != "" && payload.After != "" {
return UnparsedURL(string(payload.Repository.HTMLURL) + "/commit/" + payload.After)
return UnparsedURL(payload.Repository.HTMLURL.String() + "/commit/" + payload.After)
}
return ""

194
static/js/app-detail.js Normal file
View File

@@ -0,0 +1,194 @@
/**
* upaas - App Detail Page Component
*
* Handles the single-app view: status polling, container logs,
* build logs, and recent deployments list.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("appDetail", (config) => ({
appId: config.appId,
currentDeploymentId: config.initialDeploymentId,
appStatus: config.initialStatus || "unknown",
containerLogs: "Loading container logs...",
containerStatus: "unknown",
buildLogs: config.initialDeploymentId
? "Loading build logs..."
: "No deployments yet",
buildStatus: config.initialBuildStatus || "unknown",
showBuildLogs: !!config.initialDeploymentId,
deploying: false,
deployments: [],
// Track whether user wants auto-scroll (per log pane)
_containerAutoScroll: true,
_buildAutoScroll: true,
_pollTimer: null,
init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll();
this._schedulePoll();
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
}, interval);
},
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
},
fetchAll() {
this.fetchAppStatus();
// Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
this.fetchContainerLogs();
}
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
},
_isElementVisible(el) {
if (!el) return false;
// Check if element is in viewport (roughly)
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
const wasDeploying = this.deploying;
this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status);
// Re-schedule polling when deployment state changes
if (this.deploying !== wasDeploying) {
this._schedulePoll();
}
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
this.currentDeploymentId = data.latestDeploymentID;
this.showBuildLogs = true;
this.fetchBuildLogs();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
async fetchContainerLogs() {
try {
const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs;
this.containerStatus = data.status;
if (changed && this._containerAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
});
}
} catch (err) {
this.containerLogs = "Failed to fetch logs";
}
},
async fetchBuildLogs() {
if (!this.currentDeploymentId) return;
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs;
this.buildStatus = data.status;
if (changed && this._buildAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
});
}
} catch (err) {
this.buildLogs = "Failed to fetch logs";
}
},
async fetchRecentDeployments() {
try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
console.error("Deployments fetch error:", err);
}
},
submitDeploy() {
this.deploying = true;
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.appStatus);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.appStatus);
},
get containerStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.containerStatus) +
" text-xs"
);
},
get containerStatusLabel() {
return Alpine.store("utils").statusLabel(this.containerStatus);
},
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
);
},
get buildStatusLabel() {
return Alpine.store("utils").statusLabel(this.buildStatus);
},
deploymentStatusClass(status) {
return Alpine.store("utils").statusBadgeClass(status);
},
deploymentStatusLabel(status) {
return Alpine.store("utils").statusLabel(status);
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
});

View File

@@ -1,622 +0,0 @@
/**
* upaas - Frontend JavaScript with Alpine.js
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Global Utilities Store
// ============================================
Alpine.store("utils", {
/**
* Format a date string as relative time (e.g., "5 minutes ago")
*/
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return (
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
);
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
/**
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success")
return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
return "badge-neutral";
},
/**
* Format status for display (capitalize first letter)
*/
statusLabel(status) {
if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1);
},
/**
* Check if status indicates active deployment
*/
isDeploying(status) {
return status === "building" || status === "deploying";
},
/**
* Scroll an element to the bottom
*/
scrollToBottom(el) {
if (el) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
},
/**
* Check if a scrollable element is at (or near) the bottom.
* Tolerance of 30px accounts for rounding and partial lines.
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return (
el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
);
},
/**
* Copy text to clipboard
*/
async copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
document.body.removeChild(textArea);
return true;
} catch (e) {
document.body.removeChild(textArea);
return false;
}
}
},
});
// ============================================
// Copy Button Component
// ============================================
Alpine.data("copyButton", (targetId) => ({
copied: false,
async copy() {
const target = document.getElementById(targetId);
if (!target) return;
const text = target.textContent || target.value;
const success = await Alpine.store("utils").copyToClipboard(text);
if (success) {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
}
},
}));
// ============================================
// Confirm Action Component
// ============================================
Alpine.data("confirmAction", (message) => ({
confirm(event) {
if (!window.confirm(message)) {
event.preventDefault();
}
},
}));
// ============================================
// Auto-dismiss Alert Component
// ============================================
Alpine.data("autoDismiss", (delay = 5000) => ({
show: true,
init() {
setTimeout(() => {
this.dismiss();
}, delay);
},
dismiss() {
this.show = false;
setTimeout(() => {
this.$el.remove();
}, 300);
},
}));
// ============================================
// Relative Time Component
// ============================================
Alpine.data("relativeTime", (isoTime) => ({
display: "",
init() {
this.update();
// Update every minute
setInterval(() => this.update(), 60000);
},
update() {
this.display = Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// App Detail Page Component
// ============================================
Alpine.data("appDetail", (config) => ({
appId: config.appId,
currentDeploymentId: config.initialDeploymentId,
appStatus: config.initialStatus || "unknown",
containerLogs: "Loading container logs...",
containerStatus: "unknown",
buildLogs: config.initialDeploymentId
? "Loading build logs..."
: "No deployments yet",
buildStatus: config.initialBuildStatus || "unknown",
showBuildLogs: !!config.initialDeploymentId,
deploying: false,
deployments: [],
// Track whether user wants auto-scroll (per log pane)
_containerAutoScroll: true,
_buildAutoScroll: true,
_pollTimer: null,
init() {
this.deploying = Alpine.store("utils").isDeploying(this.appStatus);
this.fetchAll();
this._schedulePoll();
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(
this.$refs.containerLogsWrapper,
"_containerAutoScroll",
);
this._initScrollTracking(
this.$refs.buildLogsWrapper,
"_buildAutoScroll",
);
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus)
? 1000
: 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
}, interval);
},
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener(
"scroll",
() => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
},
{ passive: true },
);
},
fetchAll() {
this.fetchAppStatus();
// Only fetch logs when the respective pane is visible
if (
this.$refs.containerLogsWrapper &&
this._isElementVisible(this.$refs.containerLogsWrapper)
) {
this.fetchContainerLogs();
}
if (
this.showBuildLogs &&
this.$refs.buildLogsWrapper &&
this._isElementVisible(this.$refs.buildLogsWrapper)
) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
},
_isElementVisible(el) {
if (!el) return false;
// Check if element is in viewport (roughly)
const rect = el.getBoundingClientRect();
return rect.bottom > 0 && rect.top < window.innerHeight;
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
const wasDeploying = this.deploying;
this.appStatus = data.status;
this.deploying = Alpine.store("utils").isDeploying(data.status);
// Re-schedule polling when deployment state changes
if (this.deploying !== wasDeploying) {
this._schedulePoll();
}
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
this.currentDeploymentId = data.latestDeploymentID;
this.showBuildLogs = true;
this.fetchBuildLogs();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
async fetchContainerLogs() {
try {
const res = await fetch(`/apps/${this.appId}/container-logs`);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const changed = newLogs !== this.containerLogs;
this.containerLogs = newLogs;
this.containerStatus = data.status;
if (changed && this._containerAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(
this.$refs.containerLogsWrapper,
);
});
}
} catch (err) {
this.containerLogs = "Failed to fetch logs";
}
},
async fetchBuildLogs() {
if (!this.currentDeploymentId) return;
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.currentDeploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No build logs available";
const changed = newLogs !== this.buildLogs;
this.buildLogs = newLogs;
this.buildStatus = data.status;
if (changed && this._buildAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(
this.$refs.buildLogsWrapper,
);
});
}
} catch (err) {
this.buildLogs = "Failed to fetch logs";
}
},
async fetchRecentDeployments() {
try {
const res = await fetch(
`/apps/${this.appId}/recent-deployments`,
);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
console.error("Deployments fetch error:", err);
}
},
submitDeploy() {
this.deploying = true;
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.appStatus);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.appStatus);
},
get containerStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.containerStatus) +
" text-xs"
);
},
get containerStatusLabel() {
return Alpine.store("utils").statusLabel(this.containerStatus);
},
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) +
" text-xs"
);
},
get buildStatusLabel() {
return Alpine.store("utils").statusLabel(this.buildStatus);
},
deploymentStatusClass(status) {
return Alpine.store("utils").statusBadgeClass(status);
},
deploymentStatusLabel(status) {
return Alpine.store("utils").statusLabel(status);
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// Deployment Card Component (for individual deployment cards)
// ============================================
Alpine.data("deploymentCard", (config) => ({
appId: config.appId,
deploymentId: config.deploymentId,
logs: "",
status: config.status || "",
pollInterval: null,
_autoScroll: true,
init() {
// Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading...";
// Set up scroll tracking
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener(
"scroll",
() => {
this._autoScroll =
Alpine.store("utils").isScrolledToBottom(
wrapper,
);
},
{ passive: true },
);
}
});
// Only poll if deployment is in progress
if (Alpine.store("utils").isDeploying(this.status)) {
this.fetchLogs();
this.pollInterval = setInterval(() => this.fetchLogs(), 1000);
}
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
},
async fetchLogs() {
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.deploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const logsChanged = newLogs !== this.logs;
this.logs = newLogs;
this.status = data.status;
// Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(
this.$refs.logsWrapper,
);
});
}
// Stop polling if deployment is done
if (!Alpine.store("utils").isDeploying(data.status)) {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Reload page to show final state with duration etc
window.location.reload();
}
} catch (err) {
console.error("Logs fetch error:", err);
}
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.status);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.status);
},
}));
// ============================================
// Deployments History Page Component
// ============================================
Alpine.data("deploymentsPage", (config) => ({
appId: config.appId,
currentDeploymentId: null,
isDeploying: false,
init() {
// Check for in-progress deployments on page load
const inProgressCard = document.querySelector(
'[data-status="building"], [data-status="deploying"]',
);
if (inProgressCard) {
this.currentDeploymentId = parseInt(
inProgressCard.getAttribute("data-deployment-id"),
10,
);
this.isDeploying = true;
}
this.fetchAppStatus();
this._scheduleStatusPoll();
},
_statusPollTimer: null,
_scheduleStatusPoll() {
if (this._statusPollTimer) clearTimeout(this._statusPollTimer);
const interval = this.isDeploying ? 1000 : 10000;
this._statusPollTimer = setTimeout(() => {
this.fetchAppStatus();
this._scheduleStatusPoll();
}, interval);
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
// Use deployment status, not app status - it's more reliable during transitions
const deploying = Alpine.store("utils").isDeploying(
data.latestDeploymentStatus,
);
// Detect new deployment
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
// Check if we have a card for this deployment
const hasCard = document.querySelector(
`[data-deployment-id="${data.latestDeploymentID}"]`,
);
if (deploying && !hasCard) {
// New deployment started but no card exists - reload to show it
window.location.reload();
return;
}
this.currentDeploymentId = data.latestDeploymentID;
}
// Update deploying state based on latest deployment status
if (deploying && !this.isDeploying) {
this.isDeploying = true;
this._scheduleStatusPoll(); // Switch to fast polling
} else if (!deploying && this.isDeploying) {
// Deployment finished - reload to show final state
this.isDeploying = false;
window.location.reload();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
submitDeploy() {
this.isDeploying = true;
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
// ============================================
// Dashboard Page - Relative Time Updates
// ============================================
Alpine.data("dashboard", () => ({
init() {
// Update relative times every minute
setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent =
Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);
},
}));
});
// ============================================
// Legacy support - expose utilities globally
// ============================================
window.upaas = {
// These are kept for backwards compatibility but templates should use Alpine.js
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
// Placeholder functions - templates should migrate to Alpine.js
initAppDetailPage() {},
initDeploymentsPage() {},
};
// Update relative times on page load for non-Alpine elements
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".relative-time[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = window.upaas.formatRelativeTime(time);
}
});
});

71
static/js/components.js Normal file
View File

@@ -0,0 +1,71 @@
/**
* upaas - Reusable Alpine.js Components
*
* Small, self-contained components: copy button, confirm dialog,
* auto-dismiss alerts, and relative time display.
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Copy Button Component
// ============================================
Alpine.data("copyButton", (targetId) => ({
copied: false,
async copy() {
const target = document.getElementById(targetId);
if (!target) return;
const text = target.textContent || target.value;
const success = await Alpine.store("utils").copyToClipboard(text);
if (success) {
this.copied = true;
setTimeout(() => {
this.copied = false;
}, 2000);
}
},
}));
// ============================================
// Confirm Action Component
// ============================================
Alpine.data("confirmAction", (message) => ({
confirm(event) {
if (!window.confirm(message)) {
event.preventDefault();
}
},
}));
// ============================================
// Auto-dismiss Alert Component
// ============================================
Alpine.data("autoDismiss", (delay = 5000) => ({
show: true,
init() {
setTimeout(() => {
this.dismiss();
}, delay);
},
dismiss() {
this.show = false;
setTimeout(() => {
this.$el.remove();
}, 300);
},
}));
// ============================================
// Relative Time Component
// ============================================
Alpine.data("relativeTime", (isoTime) => ({
display: "",
init() {
this.update();
// Update every minute
setInterval(() => this.update(), 60000);
},
update() {
this.display = Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
});

21
static/js/dashboard.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* upaas - Dashboard Page Component
*
* Periodically updates relative timestamps on the main dashboard.
*/
document.addEventListener("alpine:init", () => {
Alpine.data("dashboard", () => ({
init() {
// Update relative times every minute
setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);
},
}));
});

176
static/js/deployment.js Normal file
View File

@@ -0,0 +1,176 @@
/**
* upaas - Deployment Components
*
* Deployment card (individual deployment log viewer) and
* deployments history page (list of all deployments).
*/
document.addEventListener("alpine:init", () => {
// ============================================
// Deployment Card Component (for individual deployment cards)
// ============================================
Alpine.data("deploymentCard", (config) => ({
appId: config.appId,
deploymentId: config.deploymentId,
logs: "",
status: config.status || "",
pollInterval: null,
_autoScroll: true,
init() {
// Read initial logs from script tag (avoids escaping issues)
const initialLogsEl = this.$el.querySelector(".initial-logs");
this.logs = initialLogsEl?.dataset.logs || "Loading...";
// Set up scroll tracking
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener('scroll', () => {
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
}, { passive: true });
}
});
// Only poll if deployment is in progress
if (Alpine.store("utils").isDeploying(this.status)) {
this.fetchLogs();
this.pollInterval = setInterval(() => this.fetchLogs(), 1000);
}
},
destroy() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
},
async fetchLogs() {
try {
const res = await fetch(
`/apps/${this.appId}/deployments/${this.deploymentId}/logs`,
);
const data = await res.json();
const newLogs = data.logs || "No logs available";
const logsChanged = newLogs !== this.logs;
this.logs = newLogs;
this.status = data.status;
// Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
});
}
// Stop polling if deployment is done
if (!Alpine.store("utils").isDeploying(data.status)) {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Reload page to show final state with duration etc
window.location.reload();
}
} catch (err) {
console.error("Logs fetch error:", err);
}
},
get statusBadgeClass() {
return Alpine.store("utils").statusBadgeClass(this.status);
},
get statusLabel() {
return Alpine.store("utils").statusLabel(this.status);
},
}));
// ============================================
// Deployments History Page Component
// ============================================
Alpine.data("deploymentsPage", (config) => ({
appId: config.appId,
currentDeploymentId: null,
isDeploying: false,
init() {
// Check for in-progress deployments on page load
const inProgressCard = document.querySelector(
'[data-status="building"], [data-status="deploying"]',
);
if (inProgressCard) {
this.currentDeploymentId = parseInt(
inProgressCard.getAttribute("data-deployment-id"),
10,
);
this.isDeploying = true;
}
this.fetchAppStatus();
this._scheduleStatusPoll();
},
_statusPollTimer: null,
_scheduleStatusPoll() {
if (this._statusPollTimer) clearTimeout(this._statusPollTimer);
const interval = this.isDeploying ? 1000 : 10000;
this._statusPollTimer = setTimeout(() => {
this.fetchAppStatus();
this._scheduleStatusPoll();
}, interval);
},
async fetchAppStatus() {
try {
const res = await fetch(`/apps/${this.appId}/status`);
const data = await res.json();
// Use deployment status, not app status - it's more reliable during transitions
const deploying = Alpine.store("utils").isDeploying(
data.latestDeploymentStatus,
);
// Detect new deployment
if (
data.latestDeploymentID &&
data.latestDeploymentID !== this.currentDeploymentId
) {
// Check if we have a card for this deployment
const hasCard = document.querySelector(
`[data-deployment-id="${data.latestDeploymentID}"]`,
);
if (deploying && !hasCard) {
// New deployment started but no card exists - reload to show it
window.location.reload();
return;
}
this.currentDeploymentId = data.latestDeploymentID;
}
// Update deploying state based on latest deployment status
if (deploying && !this.isDeploying) {
this.isDeploying = true;
this._scheduleStatusPoll(); // Switch to fast polling
} else if (!deploying && this.isDeploying) {
// Deployment finished - reload to show final state
this.isDeploying = false;
window.location.reload();
}
} catch (err) {
console.error("Status fetch error:", err);
}
},
submitDeploy() {
this.isDeploying = true;
},
formatTime(isoTime) {
return Alpine.store("utils").formatRelativeTime(isoTime);
},
}));
});

143
static/js/utils.js Normal file
View File

@@ -0,0 +1,143 @@
/**
* upaas - Global Utilities Store
*
* Shared formatting, status helpers, and clipboard utilities used across all pages.
*/
document.addEventListener("alpine:init", () => {
Alpine.store("utils", {
/**
* Format a date string as relative time (e.g., "5 minutes ago")
*/
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
/**
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
return "badge-neutral";
},
/**
* Format status for display (capitalize first letter)
*/
statusLabel(status) {
if (!status) return "";
return status.charAt(0).toUpperCase() + status.slice(1);
},
/**
* Check if status indicates active deployment
*/
isDeploying(status) {
return status === "building" || status === "deploying";
},
/**
* Scroll an element to the bottom
*/
scrollToBottom(el) {
if (el) {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
},
/**
* Check if a scrollable element is at (or near) the bottom.
* Tolerance of 30px accounts for rounding and partial lines.
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
},
/**
* Copy text to clipboard
*/
async copyToClipboard(text, button) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand("copy");
document.body.removeChild(textArea);
return true;
} catch (e) {
document.body.removeChild(textArea);
return false;
}
}
},
});
});
// ============================================
// Legacy support - expose utilities globally
// ============================================
window.upaas = {
// These are kept for backwards compatibility but templates should use Alpine.js
formatRelativeTime(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = new Date();
const diffMs = now - date;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
return date.toLocaleDateString();
},
// Placeholder functions - templates should migrate to Alpine.js
initAppDetailPage() {},
initDeploymentsPage() {},
};
// Update relative times on page load for non-Alpine elements
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll(".relative-time[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = window.upaas.formatRelativeTime(time);
}
});
});

View File

@@ -15,7 +15,11 @@
</div>
{{template "footer" .}}
<script defer src="/s/js/alpine.min.js"></script>
<script src="/s/js/app.js"></script>
<script src="/s/js/utils.js"></script>
<script src="/s/js/components.js"></script>
<script src="/s/js/app-detail.js"></script>
<script src="/s/js/deployment.js"></script>
<script src="/s/js/dashboard.js"></script>
</body>
</html>
{{end}}

View File

@@ -69,7 +69,7 @@
<a href="/apps/{{.App.ID}}" class="btn-text text-sm py-1 px-2">View</a>
<a href="/apps/{{.App.ID}}/edit" class="btn-secondary text-sm py-1 px-2">Edit</a>
<form method="POST" action="/apps/{{.App.ID}}/deploy" class="inline">
{{ .CSRFField }}
{{ $.CSRFField }}
<button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form>
</div>