1 Commits

Author SHA1 Message Date
user
478746c356 rebase: apply audit bug fixes on latest main
Rebased fix/1.0-audit-bugs onto current main (post-merge of PRs #119, #127).

Changes:
- Custom domain types: docker.ImageID, docker.ContainerID, webhook.UnparsedURL
- Type-safe function signatures throughout docker/deploy/webhook packages
- Remove docker-compose.yml, test helper files
- README and Dockerfile updates
- Prettier-formatted JS files
2026-02-23 11:56:09 -08:00
62 changed files with 811 additions and 935 deletions

View File

@@ -10,7 +10,17 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4, 2024-10-13 - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Build (runs make check inside Dockerfile) - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
run: docker build . 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

View File

@@ -1,24 +1,11 @@
# Build stage # Build stage
# golang:1.25-alpine FROM golang:1.25-alpine AS builder
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev RUN apk add --no-cache git make gcc musl-dev
# Install golangci-lint v2 (pre-built binary — go install fails in alpine due to missing linker) # Install golangci-lint v2
RUN set -e; \ RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
GOLANGCI_VERSION="2.10.1"; \ RUN go install golang.org/x/tools/cmd/goimports@latest
case "$(uname -m)" in \
x86_64) ARCH="amd64"; SHA256="dfa775874cf0561b404a02a8f4481fc69b28091da95aa697259820d429b09c99" ;; \
aarch64) ARCH="arm64"; SHA256="6652b42ae02915eb2f9cb2a2e0cac99514c8eded8388d88ae3e06e1a52c00de8" ;; \
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; \
esac; \
wget -q -O /tmp/golangci-lint.tar.gz \
"https://github.com/golangci/golangci-lint/releases/download/v${GOLANGCI_VERSION}/golangci-lint-${GOLANGCI_VERSION}-linux-${ARCH}.tar.gz"; \
echo "${SHA256} /tmp/golangci-lint.tar.gz" | sha256sum -c -; \
tar -xzf /tmp/golangci-lint.tar.gz -C /usr/local/bin --strip-components=1 "golangci-lint-${GOLANGCI_VERSION}-linux-${ARCH}/golangci-lint"; \
rm /tmp/golangci-lint.tar.gz; \
golangci-lint version
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -33,8 +20,7 @@ RUN make check
RUN make build RUN make build
# Runtime stage # Runtime stage
# alpine:3.19 FROM alpine:3.19
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli

View File

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

View File

@@ -4,20 +4,20 @@ package main
import ( import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/server" "git.eeqj.de/sneak/upaas/internal/server"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
) )

4
go.mod
View File

@@ -1,4 +1,4 @@
module sneak.berlin/go/upaas module git.eeqj.de/sneak/upaas
go 1.25 go 1.25
@@ -19,7 +19,6 @@ require (
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/crypto v0.46.0 golang.org/x/crypto v0.46.0
golang.org/x/time v0.12.0
) )
require ( require (
@@ -75,6 +74,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.39.0 // indirect golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.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 google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect gotest.tools/v3 v3.5.2 // indirect

View File

@@ -13,8 +13,8 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
// defaultPort is the default HTTP server port. // defaultPort is the default HTTP server port.

View File

@@ -14,8 +14,8 @@ import (
_ "github.com/mattn/go-sqlite3" // SQLite driver _ "github.com/mattn/go-sqlite3" // SQLite driver
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
// dataDirPermissions is the file permission for the data directory. // dataDirPermissions is the file permission for the data directory.

View File

@@ -5,7 +5,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
func TestHashWebhookSecret(t *testing.T) { func TestHashWebhookSecret(t *testing.T) {

View File

@@ -1,41 +0,0 @@
package database
import (
"log/slog"
"os"
"testing"
"sneak.berlin/go/upaas/internal/config"
"sneak.berlin/go/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

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

View File

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

View File

@@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// apiAppResponse is the JSON representation of an app. // apiAppResponse is the JSON representation of an app.

View File

@@ -11,7 +11,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
) )
// apiRouter builds a chi router with the API routes using session auth middleware. // apiRouter builds a chi router with the API routes using session auth middleware.

View File

@@ -15,9 +15,9 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
const ( const (

View File

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
// HandleLoginGET returns the login page handler. // HandleLoginGET returns the login page handler.

View File

@@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"time" "time"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
// AppStats holds deployment statistics for an app. // AppStats holds deployment statistics for an app.

View File

@@ -10,16 +10,16 @@ import (
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
// Params contains dependencies for Handlers. // Params contains dependencies for Handlers.

View File

@@ -15,21 +15,21 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/healthcheck" "git.eeqj.de/sneak/upaas/internal/healthcheck"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/middleware"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
) )
type testContext struct { type testContext struct {
@@ -404,25 +404,6 @@ func TestHandleDashboard(t *testing.T) {
assert.Equal(t, http.StatusOK, recorder.Code) assert.Equal(t, http.StatusOK, recorder.Code)
assert.Contains(t, recorder.Body.String(), "Applications") 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) { func TestHandleAppNew(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
) )
func TestValidateRepoURL(t *testing.T) { func TestValidateRepoURL(t *testing.T) {

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
) )
func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests func TestSanitizeLogs(t *testing.T) { //nolint:funlen // table-driven tests

View File

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"sneak.berlin/go/upaas/templates" "git.eeqj.de/sneak/upaas/templates"
) )
const ( const (

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
) )
func TestSanitizeTail(t *testing.T) { func TestSanitizeTail(t *testing.T) {

View File

@@ -6,7 +6,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB). // maxWebhookBodySize is the maximum allowed size of a webhook request body (1MB).

View File

@@ -8,10 +8,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
) )
// Params contains dependencies for Healthcheck. // Params contains dependencies for Healthcheck.

View File

@@ -7,7 +7,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
) )
// Params contains dependencies for Logger. // Params contains dependencies for Logger.

View File

@@ -1,11 +0,0 @@
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

@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
) )
//nolint:gosec // test credentials //nolint:gosec // test credentials

View File

@@ -18,10 +18,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
) )
// corsMaxAge is the maximum age for CORS preflight responses in seconds. // corsMaxAge is the maximum age for CORS preflight responses in seconds.

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
) )
func newTestMiddleware(t *testing.T) *Middleware { func newTestMiddleware(t *testing.T) *Middleware {

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// appColumns is the standard column list for app queries. // appColumns is the standard column list for app queries.

View File

@@ -8,7 +8,7 @@ import (
"strings" "strings"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// DeploymentStatus represents the status of a deployment. // DeploymentStatus represents the status of a deployment.

View File

@@ -7,7 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// EnvVar represents an environment variable for an app. // EnvVar represents an environment variable for an app.

View File

@@ -7,7 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// Label represents a Docker label for an app container. // Label represents a Docker label for an app container.

View File

@@ -10,11 +10,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// Test constants to satisfy goconst linter. // Test constants to satisfy goconst linter.

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// PortProtocol represents the protocol for a port mapping. // PortProtocol represents the protocol for a port mapping.

View File

@@ -8,7 +8,7 @@ import (
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// User represents a user in the system. // User represents a user in the system.

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// Volume represents a volume mount for an app container. // Volume represents a volume mount for an app container.

View File

@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
) )
// WebhookEvent represents a received webhook event. // WebhookEvent represents a received webhook event.

View File

@@ -8,7 +8,7 @@ import (
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/upaas/static" "git.eeqj.de/sneak/upaas/static"
) )
// requestTimeout is the maximum duration for handling a request. // requestTimeout is the maximum duration for handling a request.

View File

@@ -12,11 +12,11 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/handlers" "git.eeqj.de/sneak/upaas/internal/handlers"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/middleware" "git.eeqj.de/sneak/upaas/internal/middleware"
) )
// Params contains dependencies for Server. // Params contains dependencies for Server.

View File

@@ -14,10 +14,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/ssh" "git.eeqj.de/sneak/upaas/internal/ssh"
) )
// ServiceParams contains dependencies for Service. // ServiceParams contains dependencies for Service.

View File

@@ -8,12 +8,12 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/app" "git.eeqj.de/sneak/upaas/internal/service/app"
) )
func setupTestService(t *testing.T) (*app.Service, func()) { func setupTestService(t *testing.T) (*app.Service, func()) {

View File

@@ -15,10 +15,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
const ( const (

View File

@@ -12,11 +12,11 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/service/auth" "git.eeqj.de/sneak/upaas/internal/service/auth"
) )
func setupTestService(t *testing.T) (*auth.Service, func()) { func setupTestService(t *testing.T) (*auth.Service, func()) {

View File

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

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
) )
func TestCancelActiveDeploy_NoExisting(t *testing.T) { func TestCancelActiveDeploy_NoExisting(t *testing.T) {

View File

@@ -10,8 +10,8 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
) )
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) { func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {

View File

@@ -1,45 +0,0 @@
package deploy_test
import (
"context"
"log/slog"
"os"
"testing"
"sneak.berlin/go/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/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

@@ -8,9 +8,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/models"
) )
// NewTestService creates a Service with minimal dependencies for testing. // NewTestService creates a Service with minimal dependencies for testing.
@@ -81,12 +80,3 @@ func (svc *Service) CleanupCancelledDeploy(
func (svc *Service) GetBuildDirExported(appName string) string { func (svc *Service) GetBuildDirExported(appName string) string {
return svc.GetBuildDir(appName) 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

@@ -15,8 +15,8 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
) )
// HTTP client timeout. // HTTP client timeout.

View File

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

View File

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

View File

@@ -12,15 +12,15 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/upaas/internal/config" "git.eeqj.de/sneak/upaas/internal/config"
"sneak.berlin/go/upaas/internal/database" "git.eeqj.de/sneak/upaas/internal/database"
"sneak.berlin/go/upaas/internal/docker" "git.eeqj.de/sneak/upaas/internal/docker"
"sneak.berlin/go/upaas/internal/globals" "git.eeqj.de/sneak/upaas/internal/globals"
"sneak.berlin/go/upaas/internal/logger" "git.eeqj.de/sneak/upaas/internal/logger"
"sneak.berlin/go/upaas/internal/models" "git.eeqj.de/sneak/upaas/internal/models"
"sneak.berlin/go/upaas/internal/service/deploy" "git.eeqj.de/sneak/upaas/internal/service/deploy"
"sneak.berlin/go/upaas/internal/service/notify" "git.eeqj.de/sneak/upaas/internal/service/notify"
"sneak.berlin/go/upaas/internal/service/webhook" "git.eeqj.de/sneak/upaas/internal/service/webhook"
) )
type testDeps struct { type testDeps struct {

View File

@@ -4,9 +4,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/ssh"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/upaas/internal/ssh"
) )
func TestGenerateKeyPair(t *testing.T) { func TestGenerateKeyPair(t *testing.T) {

View File

@@ -1,194 +0,0 @@
/**
* 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);
},
}));
});

622
static/js/app.js Normal file
View File

@@ -0,0 +1,622 @@
/**
* 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);
}
});
});

View File

@@ -1,71 +0,0 @@
/**
* 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);
},
}));
});

View File

@@ -1,21 +0,0 @@
/**
* 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);
},
}));
});

View File

@@ -1,176 +0,0 @@
/**
* 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);
},
}));
});

View File

@@ -1,143 +0,0 @@
/**
* 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,11 +15,7 @@
</div> </div>
{{template "footer" .}} {{template "footer" .}}
<script defer src="/s/js/alpine.min.js"></script> <script defer src="/s/js/alpine.min.js"></script>
<script src="/s/js/utils.js"></script> <script src="/s/js/app.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> </body>
</html> </html>
{{end}} {{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}}" 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> <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"> <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> <button type="submit" class="btn-success text-sm py-1 px-2">Deploy</button>
</form> </form>
</div> </div>