3 Commits

Author SHA1 Message Date
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
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
24 changed files with 823 additions and 3826 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,11 @@
# Build stage
FROM golang:1.25-alpine AS builder
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder # golang:1.25-alpine
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
RUN go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@5d1e709b7be35cb2025444e19de266b056b7b7ee # v2.10.1
RUN go install golang.org/x/tools/cmd/goimports@009367f5c17a8d4c45a961a3a509277190a9a6f0 # v0.42.0
WORKDIR /src
COPY go.mod go.sum ./
@@ -20,7 +20,7 @@ RUN make check
RUN make build
# Runtime stage
FROM alpine:3.19
FROM alpine@sha256:6baf43584bcb78f2e5847d1de515f23499913ac9f12bdf834811a3145eb11ca1 # alpine:3.19
RUN apk add --no-cache ca-certificates tzdata git openssh-client docker-cli

View File

@@ -176,39 +176,8 @@ docker run -d \
upaas
```
### Docker Compose
```yaml
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ${HOST_DATA_DIR}:/var/lib/upaas
environment:
- UPAAS_HOST_DATA_DIR=${HOST_DATA_DIR}
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - 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.
Example:
```bash
export HOST_DATA_DIR=/srv/upaas-data
docker compose up -d
```
**Important**: When running µPaaS inside a container, set `UPAAS_HOST_DATA_DIR` to the host path
that maps to `UPAAS_DATA_DIR`. This is required for Docker bind mounts during builds to work correctly.
Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`.

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
services:
upaas:
build: .
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- upaas-data:/var/lib/upaas
# environment:
# Optional: uncomment to enable debug logging
# - DEBUG=true
# Optional: Sentry error reporting
# - SENTRY_DSN=https://...
# Optional: Prometheus metrics auth
# - METRICS_USERNAME=prometheus
# - METRICS_PASSWORD=secret
volumes:
upaas-data:

View File

@@ -113,9 +113,9 @@ func (d *Database) applyMigration(ctx context.Context, filename string) error {
return fmt.Errorf("failed to record migration: %w", err)
}
err = transaction.Commit()
if err != nil {
return fmt.Errorf("failed to commit migration: %w", err)
commitErr := transaction.Commit()
if commitErr != nil {
return fmt.Errorf("failed to commit migration: %w", commitErr)
}
return nil

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

@@ -14,7 +14,7 @@ import (
"strconv"
"strings"
dockertypes "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/image"
@@ -116,7 +116,7 @@ type BuildImageOptions struct {
func (c *Client) BuildImage(
ctx context.Context,
opts BuildImageOptions,
) (ImageID, error) {
) (string, error) {
if c.docker == nil {
return "", ErrNotConnected
}
@@ -188,7 +188,7 @@ func buildPortConfig(ports []PortMapping) (nat.PortSet, nat.PortMap) {
func (c *Client) CreateContainer(
ctx context.Context,
opts CreateContainerOptions,
) (ContainerID, error) {
) (string, error) {
if c.docker == nil {
return "", ErrNotConnected
}
@@ -241,18 +241,18 @@ func (c *Client) CreateContainer(
return "", fmt.Errorf("failed to create container: %w", err)
}
return ContainerID(resp.ID), nil
return resp.ID, nil
}
// StartContainer starts a container.
func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) error {
func (c *Client) StartContainer(ctx context.Context, containerID string) error {
if c.docker == nil {
return ErrNotConnected
}
c.log.Info("starting container", "id", containerID)
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{})
err := c.docker.ContainerStart(ctx, containerID, container.StartOptions{})
if err != nil {
return fmt.Errorf("failed to start container: %w", err)
}
@@ -261,7 +261,7 @@ func (c *Client) StartContainer(ctx context.Context, containerID ContainerID) er
}
// StopContainer stops a container.
func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) error {
func (c *Client) StopContainer(ctx context.Context, containerID string) error {
if c.docker == nil {
return ErrNotConnected
}
@@ -270,7 +270,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, container.StopOptions{Timeout: &timeout})
if err != nil {
return fmt.Errorf("failed to stop container: %w", err)
}
@@ -281,7 +281,7 @@ func (c *Client) StopContainer(ctx context.Context, containerID ContainerID) err
// RemoveContainer removes a container.
func (c *Client) RemoveContainer(
ctx context.Context,
containerID ContainerID,
containerID string,
force bool,
) error {
if c.docker == nil {
@@ -290,7 +290,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, container.RemoveOptions{Force: force})
if err != nil {
return fmt.Errorf("failed to remove container: %w", err)
}
@@ -301,7 +301,7 @@ func (c *Client) RemoveContainer(
// ContainerLogs returns the logs for a container.
func (c *Client) ContainerLogs(
ctx context.Context,
containerID ContainerID,
containerID string,
tail string,
) (string, error) {
if c.docker == nil {
@@ -314,7 +314,7 @@ func (c *Client) ContainerLogs(
Tail: tail,
}
reader, err := c.docker.ContainerLogs(ctx, string(containerID), opts)
reader, err := c.docker.ContainerLogs(ctx, containerID, opts)
if err != nil {
return "", fmt.Errorf("failed to get container logs: %w", err)
}
@@ -337,13 +337,13 @@ func (c *Client) ContainerLogs(
// IsContainerRunning checks if a container is running.
func (c *Client) IsContainerRunning(
ctx context.Context,
containerID ContainerID,
containerID string,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, string(containerID))
inspect, err := c.docker.ContainerInspect(ctx, containerID)
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@@ -354,13 +354,13 @@ func (c *Client) IsContainerRunning(
// IsContainerHealthy checks if a container is healthy.
func (c *Client) IsContainerHealthy(
ctx context.Context,
containerID ContainerID,
containerID string,
) (bool, error) {
if c.docker == nil {
return false, ErrNotConnected
}
inspect, err := c.docker.ContainerInspect(ctx, string(containerID))
inspect, err := c.docker.ContainerInspect(ctx, containerID)
if err != nil {
return false, fmt.Errorf("failed to inspect container: %w", err)
}
@@ -378,7 +378,7 @@ const LabelUpaasID = "upaas.id"
// ContainerInfo contains basic information about a container.
type ContainerInfo struct {
ID ContainerID
ID string
Running bool
}
@@ -413,7 +413,7 @@ func (c *Client) FindContainerByAppID(
ctr := containers[0]
return &ContainerInfo{
ID: ContainerID(ctr.ID),
ID: ctr.ID,
Running: ctr.State == "running",
}, nil
}
@@ -482,8 +482,8 @@ 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{
func (c *Client) RemoveImage(ctx context.Context, imageID string) error {
_, err := c.docker.ImageRemove(ctx, imageID, image.RemoveOptions{
Force: true,
PruneChildren: true,
})
@@ -497,7 +497,7 @@ func (c *Client) RemoveImage(ctx context.Context, imageID ImageID) error {
func (c *Client) performBuild(
ctx context.Context,
opts BuildImageOptions,
) (ImageID, error) {
) (string, error) {
// Create tar archive of build context
tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{})
if err != nil {
@@ -512,7 +512,7 @@ func (c *Client) performBuild(
}()
// Build image
resp, err := c.docker.ImageBuild(ctx, tarArchive, dockertypes.ImageBuildOptions{
resp, err := c.docker.ImageBuild(ctx, tarArchive, types.ImageBuildOptions{
Dockerfile: opts.DockerfilePath,
Tags: opts.Tags,
Remove: true,
@@ -542,7 +542,7 @@ func (c *Client) performBuild(
return "", fmt.Errorf("failed to inspect image: %w", inspectErr)
}
return ImageID(inspect.ID), nil
return inspect.ID, nil
}
return "", nil
@@ -603,22 +603,22 @@ func (c *Client) performClone(ctx context.Context, cfg *cloneConfig) (*CloneResu
}
}()
gitContainerID, err := c.createGitContainer(ctx, cfg)
containerID, err := c.createGitContainer(ctx, cfg)
if err != nil {
return nil, err
}
defer func() {
_ = c.docker.ContainerRemove(ctx, string(gitContainerID), container.RemoveOptions{Force: true})
_ = c.docker.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true})
}()
return c.runGitClone(ctx, gitContainerID)
return c.runGitClone(ctx, containerID)
}
func (c *Client) createGitContainer(
ctx context.Context,
cfg *cloneConfig,
) (ContainerID, error) {
) (string, error) {
gitSSHCmd := "ssh -i /keys/deploy_key -o StrictHostKeyChecking=no"
// Build the git command using environment variables to avoid shell injection.
@@ -675,16 +675,16 @@ func (c *Client) createGitContainer(
return "", fmt.Errorf("failed to create git container: %w", err)
}
return ContainerID(resp.ID), nil
return resp.ID, nil
}
func (c *Client) runGitClone(ctx context.Context, containerID ContainerID) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, string(containerID), container.StartOptions{})
func (c *Client) runGitClone(ctx context.Context, containerID string) (*CloneResult, error) {
err := c.docker.ContainerStart(ctx, containerID, 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, container.WaitConditionNotRunning)
select {
case err := <-errCh:

View File

@@ -1,7 +0,0 @@
package docker
// ImageID is a Docker image identifier (ID or tag).
type ImageID string
// ContainerID is a Docker container identifier.
type ContainerID string

View File

@@ -72,7 +72,7 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
nameErr := validateAppName(name)
if nameErr != nil {
data["Error"] = "Invalid app name: " + nameErr.Error()
h.renderTemplate(writer, tmpl, "app_new.html", data)
_ = tmpl.ExecuteTemplate(writer, "app_new.html", data)
return
}
@@ -228,7 +228,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
"App": application,
"Error": "Invalid app name: " + nameErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}
@@ -239,7 +239,7 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
"App": application,
"Error": "Invalid repository URL: " + repoURLErr.Error(),
}, request)
h.renderTemplate(writer, tmpl, "app_edit.html", data)
_ = tmpl.ExecuteTemplate(writer, "app_edit.html", data)
return
}

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

@@ -5,7 +5,6 @@ import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
"git.eeqj.de/sneak/upaas/internal/database"
@@ -77,11 +76,7 @@ func (d *Deployment) Reload(ctx context.Context) error {
return d.scan(row)
}
// maxLogSize is the maximum size of deployment logs stored in the database (1MB).
const maxLogSize = 1 << 20
// AppendLog appends a log line to the deployment logs.
// If the total log size exceeds maxLogSize, the oldest lines are truncated.
func (d *Deployment) AppendLog(ctx context.Context, line string) error {
var currentLogs string
@@ -89,22 +84,7 @@ func (d *Deployment) AppendLog(ctx context.Context, line string) error {
currentLogs = d.Logs.String
}
newLogs := currentLogs + line + "\n"
if len(newLogs) > maxLogSize {
// Keep the most recent logs that fit within the limit.
// Find a newline after the truncation point to avoid partial lines.
truncateAt := len(newLogs) - maxLogSize
idx := strings.Index(newLogs[truncateAt:], "\n")
if idx >= 0 {
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt+idx+1:]
} else {
newLogs = "[earlier logs truncated]\n" + newLogs[truncateAt:]
}
}
d.Logs = sql.NullString{String: newLogs, Valid: true}
d.Logs = sql.NullString{String: currentLogs + line + "\n", Valid: true}
return d.Save(ctx)
}

View File

@@ -251,8 +251,8 @@ func New(lc fx.Lifecycle, params ServiceParams) (*Service, error) {
}
// GetBuildDir returns the build directory path for an app.
func (svc *Service) GetBuildDir(appName string) string {
return filepath.Join(svc.config.DataDir, "builds", appName)
func (svc *Service) GetBuildDir(appID string) string {
return filepath.Join(svc.config.DataDir, "builds", appID)
}
// GetLogFilePath returns the path to the log file for a deployment.
@@ -417,7 +417,7 @@ func (svc *Service) executeRollback(
svc.removeOldContainer(ctx, app, deployment)
rollbackOpts, err := svc.buildContainerOptions(ctx, app, docker.ImageID(previousImageID))
rollbackOpts, err := svc.buildContainerOptions(ctx, app, previousImageID)
if err != nil {
svc.failDeployment(bgCtx, app, deployment, err)
@@ -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, Valid: true}
_ = deployment.AppendLog(bgCtx, "Rollback container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@@ -514,7 +514,7 @@ func (svc *Service) buildImageWithTimeout(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (docker.ImageID, error) {
) (string, error) {
buildCtx, cancel := context.WithTimeout(ctx, buildTimeout)
defer cancel()
@@ -539,7 +539,7 @@ func (svc *Service) deployContainerWithTimeout(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
imageID string,
) error {
deployCtx, cancel := context.WithTimeout(ctx, deployTimeout)
defer cancel()
@@ -667,7 +667,7 @@ func (svc *Service) checkCancelled(
bgCtx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
imageID string,
) error {
if !errors.Is(deployCtx.Err(), context.Canceled) {
return nil
@@ -687,7 +687,7 @@ func (svc *Service) cleanupCancelledDeploy(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
imageID string,
) {
// Clean up the intermediate Docker image if one was built
if imageID != "" {
@@ -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+": "+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)
}
}
@@ -816,7 +816,7 @@ func (svc *Service) buildImage(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
) (docker.ImageID, error) {
) (string, error) {
workDir, cleanup, err := svc.cloneRepository(ctx, app, deployment)
if err != nil {
return "", err
@@ -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, Valid: true}
_ = deployment.AppendLog(ctx, "Image built: "+imageID)
return imageID, nil
}
@@ -1009,15 +1009,15 @@ func (svc *Service) removeOldContainer(
svc.log.Warn("failed to remove old container", "error", removeErr)
}
_ = deployment.AppendLog(ctx, "Old container removed: "+string(containerInfo.ID[:12]))
_ = deployment.AppendLog(ctx, "Old container removed: "+containerInfo.ID[:12])
}
func (svc *Service) createAndStartContainer(
ctx context.Context,
app *models.App,
deployment *models.Deployment,
imageID docker.ImageID,
) (docker.ContainerID, error) {
imageID string,
) (string, error) {
containerOpts, err := svc.buildContainerOptions(ctx, app, imageID)
if err != nil {
svc.failDeployment(ctx, app, deployment, err)
@@ -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, Valid: true}
_ = deployment.AppendLog(ctx, "Container created: "+containerID)
startErr := svc.docker.StartContainer(ctx, containerID)
if startErr != nil {
@@ -1062,7 +1062,7 @@ func (svc *Service) createAndStartContainer(
func (svc *Service) buildContainerOptions(
ctx context.Context,
app *models.App,
imageID docker.ImageID,
imageID string,
) (docker.CreateContainerOptions, error) {
envVars, err := app.GetEnvVars(ctx)
if err != nil {
@@ -1096,7 +1096,7 @@ func (svc *Service) buildContainerOptions(
return docker.CreateContainerOptions{
Name: "upaas-" + app.Name,
Image: string(imageID),
Image: imageID,
Env: envMap,
Labels: buildLabelMap(app, labels),
Volumes: buildVolumeMounts(volumes),
@@ -1146,9 +1146,9 @@ func buildPortMappings(ports []*models.Port) []docker.PortMapping {
func (svc *Service) updateAppRunning(
ctx context.Context,
app *models.App,
imageID docker.ImageID,
imageID string,
) error {
app.ImageID = sql.NullString{String: string(imageID), Valid: true}
app.ImageID = sql.NullString{String: imageID, Valid: true}
app.Status = models.AppStatusRunning
saveErr := app.Save(ctx)

View File

@@ -0,0 +1,44 @@
package deploy_test
import (
"context"
"log/slog"
"os"
"testing"
"git.eeqj.de/sneak/upaas/internal/database"
"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 = "sha256:abc123def456"
opts, err := svc.BuildContainerOptionsExported(context.Background(), app, expectedImageID)
if err != nil {
t.Fatalf("buildContainerOptions returned error: %v", err)
}
if opts.Image != expectedImageID {
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 string,
) (docker.CreateContainerOptions, error) {
return svc.buildContainerOptions(ctx, app, imageID)
}

View File

@@ -1,7 +0,0 @@
package webhook
// UnparsedURL is a URL stored as a plain string without parsing.
// Use this instead of string when the value is known to be a URL
// but should not be parsed into a net/url.URL (e.g. webhook URLs,
// compare URLs from external payloads).
type UnparsedURL string

View File

@@ -47,24 +47,24 @@ func New(_ fx.Lifecycle, params ServiceParams) (*Service, error) {
//
//nolint:tagliatelle // Field names match Gitea API (snake_case)
type GiteaPushPayload struct {
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL UnparsedURL `json:"compare_url"`
Ref string `json:"ref"`
Before string `json:"before"`
After string `json:"after"`
CompareURL string `json:"compare_url"`
Repository struct {
FullName string `json:"full_name"`
CloneURL UnparsedURL `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL UnparsedURL `json:"html_url"`
FullName string `json:"full_name"`
CloneURL string `json:"clone_url"`
SSHURL string `json:"ssh_url"`
HTMLURL string `json:"html_url"`
} `json:"repository"`
Pusher struct {
Username string `json:"username"`
Email string `json:"email"`
} `json:"pusher"`
Commits []struct {
ID string `json:"id"`
URL UnparsedURL `json:"url"`
Message string `json:"message"`
ID string `json:"id"`
URL string `json:"url"`
Message string `json:"message"`
Author struct {
Name string `json:"name"`
Email string `json:"email"`
@@ -104,7 +104,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, Valid: commitURL != ""}
event.Payload = sql.NullString{String: string(payload), Valid: true}
event.Matched = matched
event.Processed = false
@@ -168,7 +168,7 @@ func extractBranch(ref string) string {
// extractCommitURL extracts the commit URL from the webhook payload.
// Prefers the URL from the head commit, falls back to constructing from repo URL.
func extractCommitURL(payload GiteaPushPayload) UnparsedURL {
func extractCommitURL(payload GiteaPushPayload) string {
// Try to find the URL from the head commit (matching After SHA)
for _, commit := range payload.Commits {
if commit.ID == payload.After && commit.URL != "" {
@@ -178,7 +178,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 payload.Repository.HTMLURL + "/commit/" + payload.After
}
return ""

3047
static/js/alpine.min.js vendored

File diff suppressed because one or more lines are too long

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}}