10 Commits

Author SHA1 Message Date
d22daf1f0a Merge branch 'main' into fix/js-formatting
All checks were successful
Check / check (pull_request) Successful in 4s
2026-03-10 18:54:08 +01:00
e1dc865226 feat: add webhook event history UI page (#164)
All checks were successful
Check / check (push) Successful in 4s
## Summary

Adds a per-app webhook event history page at `/apps/{id}/webhooks` showing received webhook events with match/no-match status.

## Changes

- **New template** `webhook_events.html` — displays webhook events in a table with time, event type, branch, commit SHA (linked when URL available), and match status badges
- **New handler** `HandleAppWebhookEvents()` in `webhook_events.go` — fetches app and its webhook events (limit 100)
- **New route** `GET /apps/{id}/webhooks` — registered in protected routes group
- **Template registration** — added `webhook_events.html` to the template cache in `templates.go`
- **Model enhancement** — added `ShortCommit()` method to `WebhookEvent` for truncated SHA display
- **App detail link** — added "Event History" link in the Webhook URL card on the app detail page

## UI

Follows the existing UI patterns (Tailwind CSS classes, Alpine.js `relativeTime`, badge styles, empty state, back-navigation). The page mirrors the deployments history page layout.

closes [#85](#85)

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #164
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 18:53:58 +01:00
49ff625ac4 fix: add missing Makefile targets (docker, hooks) and test timeout (#159)
All checks were successful
Check / check (push) Successful in 4s
## Changes

- Add `docker` target (`docker build .`)
- Add `hooks` target (installs pre-commit hook running `make check`)
- Add 30-second timeout to `test` target (`-timeout 30s`)
- Update `.PHONY` to include new targets
- Update README to document all Makefile targets (`fmt-check`, `docker`, `hooks`)
- Run `make fmt` to fix JS formatting via prettier

`docker build .` passes 

closes #136, closes #137

<!-- session: agent:sdlc-manager:subagent:44375174-444b-43bf-a341-2def7ebb9fdf -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #159
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 01:09:15 +01:00
602046b329 Merge branch 'main' into fix/js-formatting
All checks were successful
Check / check (pull_request) Successful in 4s
2026-03-10 01:07:26 +01:00
ab63670043 fix: pass notification settings from create form to service (#160)
All checks were successful
Check / check (push) Successful in 3m49s
## Summary

`HandleAppCreate` was not reading `docker_network`, `ntfy_topic`, or `slack_webhook` form values from the create app form. These fields were silently dropped during app creation, even though:
- `app_new.html` had the form fields
- `CreateAppInput` had the corresponding struct fields
- `CreateApp` already handled them correctly

The edit/update flow was unaffected — the bug was exclusively in the create path.

## Changes

- Read `docker_network`, `ntfy_topic`, `slack_webhook` form values in `HandleAppCreate`
- Pass them to `CreateAppInput`
- Include them in template re-render data (preserves values on validation errors)

closes #157

<!-- session: agent:sdlc-manager:subagent:1fb3582d-1eff-4309-b166-df5046a1b885 -->

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #160
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 01:01:32 +01:00
clawbot
7920e723a6 style: run make fmt on JS static files
All checks were successful
Check / check (pull_request) Successful in 5s
2026-03-09 17:01:10 -07:00
1cd433b069 chore: add REPO_POLICIES compliance files (#155)
All checks were successful
Check / check (push) Successful in 6s
Add `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `.dockerignore` to bring the repository into compliance with REPO_POLICIES standards.

### Changes

- **`.gitignore`** ([#132](#132)): Standard template from `sneak/prompts` plus Go-specific entries (bin/, *.exe, *.test, etc.)
- **`.editorconfig`** ([#133](#133)): root=true, UTF-8, LF line endings, trim trailing whitespace, final newline. Go and Makefile use tabs, everything else 2 spaces.
- **`REPO_POLICIES.md`** ([#134](#134)): Copied as-is from `sneak/prompts` (last_modified: 2026-02-22)
- **`.dockerignore`** ([#135](#135)): Excludes `.git`, `bin/`, `.editorconfig`, `.vscode/`, `.idea/`, `*.test`, `LICENSE`, and documentation files. Keeps all files needed by the Dockerfile (source code, go.mod, go.sum, Makefile, .golangci.yml, static/, templates/).

`docker build .` passes with these changes.

closes #132, closes #133, closes #134, closes #135

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #155
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-03 18:07:44 +01:00
94639a47e9 fix: add COPY --from=lint to builder stage to force lint execution (#154)
All checks were successful
Check / check (push) Successful in 1m30s
BuildKit skips unreferenced stages silently. The lint stage (added in PR [#152](#152)) was never referenced by the builder stage via `COPY --from`, so it was being skipped entirely during `docker build .`. Linting was not actually running in CI.

This adds `COPY --from=lint /src/go.sum /dev/null` to the builder stage, creating a stage dependency that forces the lint stage to complete before the build proceeds.

Verified: `docker build .` now runs the lint stage (fmt-check + lint) and passes.

closes #153

Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #154
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 23:46:52 +01:00
12446f9f79 fix: change module path to sneak.berlin/go/upaas (closes #143) (#149)
All checks were successful
Check / check (push) Successful in 1m16s
Changes the Go module path from `git.eeqj.de/sneak/upaas` to `sneak.berlin/go/upaas`.

All import paths in Go files updated accordingly. `go mod tidy` and `make check` pass cleanly.

fixes #143

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: Jeffrey Paul <sneak@noreply.example.org>
Reviewed-on: #149
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 23:22:18 +01:00
877fb2c0c5 Split Dockerfile into lint + build stages for faster CI feedback (#152)
All checks were successful
Check / check (push) Successful in 1m4s
## Summary

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

### Changes

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

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

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

closes #151

Co-authored-by: clawbot <clawbot@eeqj.de>
Co-authored-by: clawbot <clawbot@noreply.git.eeqj.de>
Reviewed-on: #152
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-01 21:19:21 +01:00
64 changed files with 1133 additions and 680 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
bin/
.editorconfig
.vscode/
.idea/
*.test
LICENSE
CONVENTIONS.md
REPO_POLICIES.md
README.md

15
.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2
[*.go]
indent_style = tab
[Makefile]
indent_style = tab

31
.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Go
bin/
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out

View File

@@ -1,24 +1,6 @@
# Build stage # Lint stage — fast feedback on formatting and lint issues
# golang:1.25-alpine # golangci/golangci-lint:v2.10.1
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golangci/golangci-lint@sha256:ea84d14c2fef724411be7dc45e09e6ef721d748315252b02df19a7e3113ee763 AS lint
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)
RUN set -e; \
GOLANGCI_VERSION="2.10.1"; \
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 ./
@@ -26,10 +8,25 @@ RUN go mod download
COPY . . COPY . .
# Run all checks - build fails if any check fails RUN make fmt-check
RUN make check RUN make lint
# Build the binary # Build stage — tests and compilation
# golang:1.25-alpine
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
# Force BuildKit to run the lint stage by creating a stage dependency
COPY --from=lint /src/go.sum /dev/null
RUN apk add --no-cache git make gcc musl-dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
RUN make build RUN make build
# Runtime stage # Runtime stage

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt test check clean .PHONY: all build lint fmt fmt-check test check clean docker hooks
BINARY := upaasd BINARY := upaasd
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@@ -18,21 +18,26 @@ fmt:
goimports -w . goimports -w .
npx prettier --write --tab-width 4 static/js/*.js npx prettier --write --tab-width 4 static/js/*.js
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
test: test:
go test -v -race -cover ./... go test -v -race -cover -timeout 30s ./...
# Check runs all validation without making changes # Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong # Used by CI and Docker build - fails if anything is wrong
check: check: fmt-check lint test
@echo "==> Checking formatting..."
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1)
@echo "==> Running linter..."
golangci-lint run --config .golangci.yml ./...
@echo "==> Running tests..."
go test -v -race ./...
@echo "==> Building..."
go build -ldflags "$(LDFLAGS)" -o /dev/null ./cmd/upaasd
@echo "==> All checks passed!" @echo "==> All checks passed!"
docker:
docker build .
hooks:
@echo "Installing pre-commit hook..."
@mkdir -p .git/hooks
@printf '#!/bin/sh\nmake check\n' > .git/hooks/pre-commit
@chmod +x .git/hooks/pre-commit
@echo "Pre-commit hook installed."
clean: clean:
rm -rf bin/ rm -rf bin/

View File

@@ -110,11 +110,14 @@ chi Router ──► Middleware Stack ──► Handler
### Commands ### Commands
```bash ```bash
make fmt # Format code make fmt # Format code
make lint # Run comprehensive linting make fmt-check # Check formatting (read-only, fails if unformatted)
make test # Run tests with race detection make lint # Run comprehensive linting
make check # Verify everything passes (lint, test, build, format) make test # Run tests with race detection (30s timeout)
make build # Build binary make check # Verify everything passes (fmt-check, lint, test)
make build # Build binary
make docker # Build Docker image
make hooks # Install pre-commit hook (runs make check)
``` ```
### Commit Requirements ### Commit Requirements

188
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,188 @@
---
title: Repository Policies
last_modified: 2026-02-22
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make test`, `make lint`, `make fmt` (writes), `make fmt-check` (read-only),
`make check` (prereqs: `test`, `lint`, `fmt-check`), `make docker`, and
`make hooks` (installs pre-commit hook). A model Makefile is at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `docker build .` on push. Since the Dockerfile already runs `make check`,
a successful build implies all checks pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: `make check` if local testing is possible, otherwise
`make lint && make fmt-check`. The Makefile should provide a `make hooks`
target to install the pre-commit hook.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations tracking
table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.). There
is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

View File

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

2
go.mod
View File

@@ -1,4 +1,4 @@
module git.eeqj.de/sneak/upaas module sneak.berlin/go/upaas
go 1.25 go 1.25

View File

@@ -13,8 +13,8 @@ import (
"github.com/spf13/viper" "github.com/spf13/viper"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
func TestHashWebhookSecret(t *testing.T) { func TestHashWebhookSecret(t *testing.T) {

View File

@@ -5,8 +5,8 @@ import (
"os" "os"
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
) )
// NewTestDatabase creates an in-memory Database for testing. // NewTestDatabase creates an in-memory Database for testing.

View File

@@ -25,9 +25,9 @@ import (
"github.com/docker/go-connections/nat" "github.com/docker/go-connections/nat"
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/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.

View File

@@ -7,7 +7,7 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/upaas/templates"
) )
const ( const (
@@ -54,12 +54,18 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
repoURL := request.FormValue("repo_url") repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch") branch := request.FormValue("branch")
dockerfilePath := request.FormValue("dockerfile_path") dockerfilePath := request.FormValue("dockerfile_path")
dockerNetwork := request.FormValue("docker_network")
ntfyTopic := request.FormValue("ntfy_topic")
slackWebhook := request.FormValue("slack_webhook")
data := h.addGlobals(map[string]any{ data := h.addGlobals(map[string]any{
"Name": name, "Name": name,
"RepoURL": repoURL, "RepoURL": repoURL,
"Branch": branch, "Branch": branch,
"DockerfilePath": dockerfilePath, "DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook,
}, request) }, request)
if name == "" || repoURL == "" { if name == "" || repoURL == "" {
@@ -100,6 +106,9 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
RepoURL: repoURL, RepoURL: repoURL,
Branch: branch, Branch: branch,
DockerfilePath: dockerfilePath, DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook,
}, },
) )
if createErr != nil { if createErr != nil {

View File

@@ -3,7 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/webhook"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/healthcheck" "sneak.berlin/go/upaas/internal/healthcheck"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware" "sneak.berlin/go/upaas/internal/middleware"
"git.eeqj.de/sneak/upaas/internal/service/app" "sneak.berlin/go/upaas/internal/service/app"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/upaas/internal/service/auth"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
"git.eeqj.de/sneak/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/upaas/internal/service/webhook"
) )
type testContext struct { type testContext struct {

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/templates" "sneak.berlin/go/upaas/templates"
) )
const ( const (

View File

@@ -3,7 +3,7 @@ package handlers_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/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

@@ -0,0 +1,56 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
"sneak.berlin/go/upaas/internal/models"
"sneak.berlin/go/upaas/templates"
)
// webhookEventsLimit is the number of webhook events to show in history.
const webhookEventsLimit = 100
// HandleAppWebhookEvents returns the webhook event history handler.
func (h *Handlers) HandleAppWebhookEvents() http.HandlerFunc {
tmpl := templates.GetParsed()
return func(writer http.ResponseWriter, request *http.Request) {
appID := chi.URLParam(request, "id")
application, findErr := models.FindApp(request.Context(), h.db, appID)
if findErr != nil {
h.log.Error("failed to find app", "error", findErr)
http.Error(writer, "Internal Server Error", http.StatusInternalServerError)
return
}
if application == nil {
http.NotFound(writer, request)
return
}
events, eventsErr := application.GetWebhookEvents(
request.Context(),
webhookEventsLimit,
)
if eventsErr != nil {
h.log.Error("failed to get webhook events",
"error", eventsErr,
"app", appID,
)
events = []*models.WebhookEvent{}
}
data := h.addGlobals(map[string]any{
"App": application,
"Events": events,
}, request)
h.renderTemplate(writer, tmpl, "webhook_events.html", data)
}
}

View File

@@ -8,10 +8,10 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
) )
// Params contains dependencies for Logger. // Params contains dependencies for Logger.

View File

@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"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"
) )
// Test constants to satisfy goconst linter. // Test constants to satisfy goconst linter.

View File

@@ -6,7 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
) )
// WebhookEvent represents a received webhook event. // WebhookEvent represents a received webhook event.
@@ -52,6 +52,20 @@ func (w *WebhookEvent) Reload(ctx context.Context) error {
return w.scan(row) return w.scan(row)
} }
// ShortCommit returns a truncated commit SHA for display.
func (w *WebhookEvent) ShortCommit() string {
if !w.CommitSHA.Valid {
return ""
}
sha := w.CommitSHA.String
if len(sha) > shortCommitLength {
return sha[:shortCommitLength]
}
return sha
}
func (w *WebhookEvent) insert(ctx context.Context) error { func (w *WebhookEvent) insert(ctx context.Context) error {
query := ` query := `
INSERT INTO webhook_events ( INSERT INTO webhook_events (

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"
"git.eeqj.de/sneak/upaas/static" "sneak.berlin/go/upaas/static"
) )
// requestTimeout is the maximum duration for handling a request. // requestTimeout is the maximum duration for handling a request.
@@ -70,6 +70,7 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy()) r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy()) r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments()) r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI()) r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload()) r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs()) r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())

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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/handlers" "sneak.berlin/go/upaas/internal/handlers"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/middleware" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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/ssh" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"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/app" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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"
) )
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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"git.eeqj.de/sneak/upaas/internal/logger" "sneak.berlin/go/upaas/internal/logger"
"git.eeqj.de/sneak/upaas/internal/service/auth" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"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/notify" "sneak.berlin/go/upaas/internal/service/notify"
) )
// Time constants. // Time constants.

View File

@@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/service/deploy" "sneak.berlin/go/upaas/internal/service/deploy"
) )
func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) { func TestCleanupCancelledDeploy_RemovesBuildDir(t *testing.T) {

View File

@@ -6,10 +6,10 @@ import (
"os" "os"
"testing" "testing"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"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"
) )
func TestBuildContainerOptionsUsesImageID(t *testing.T) { func TestBuildContainerOptionsUsesImageID(t *testing.T) {

View File

@@ -8,9 +8,9 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/models" "sneak.berlin/go/upaas/internal/models"
) )
// NewTestService creates a Service with minimal dependencies for testing. // NewTestService creates a Service with minimal dependencies for testing.

View File

@@ -15,8 +15,8 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"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"
) )
// HTTP client timeout. // HTTP client timeout.

View File

@@ -10,11 +10,11 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/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.

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"
"git.eeqj.de/sneak/upaas/internal/config" "sneak.berlin/go/upaas/internal/config"
"git.eeqj.de/sneak/upaas/internal/database" "sneak.berlin/go/upaas/internal/database"
"git.eeqj.de/sneak/upaas/internal/docker" "sneak.berlin/go/upaas/internal/docker"
"git.eeqj.de/sneak/upaas/internal/globals" "sneak.berlin/go/upaas/internal/globals"
"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"
"git.eeqj.de/sneak/upaas/internal/service/notify" "sneak.berlin/go/upaas/internal/service/notify"
"git.eeqj.de/sneak/upaas/internal/service/webhook" "sneak.berlin/go/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

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

View File

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

View File

@@ -5,17 +5,18 @@
*/ */
document.addEventListener("alpine:init", () => { document.addEventListener("alpine:init", () => {
Alpine.data("dashboard", () => ({ Alpine.data("dashboard", () => ({
init() { init() {
// Update relative times every minute // Update relative times every minute
setInterval(() => { setInterval(() => {
this.$el.querySelectorAll("[data-time]").forEach((el) => { this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time"); const time = el.getAttribute("data-time");
if (time) { if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time); el.textContent =
} Alpine.store("utils").formatRelativeTime(time);
}); }
}, 60000); });
}, }, 60000);
})); },
}));
}); });

View File

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

View File

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

View File

@@ -77,7 +77,10 @@
<!-- Webhook URL --> <!-- Webhook URL -->
<div class="card p-6 mb-6"> <div class="card p-6 mb-6">
<h2 class="section-title mb-4">Webhook URL</h2> <div class="flex items-center justify-between mb-4">
<h2 class="section-title">Webhook URL</h2>
<a href="/apps/{{.App.ID}}/webhooks" class="text-primary-600 hover:text-primary-800 text-sm">Event History</a>
</div>
<p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p> <p class="text-sm text-gray-500 mb-3">Add this URL as a push webhook in your Gitea repository:</p>
<div class="copy-field" x-data="copyButton('webhook-url')"> <div class="copy-field" x-data="copyButton('webhook-url')">
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code> <code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>

View File

@@ -44,6 +44,7 @@ func initTemplates() {
"app_detail.html", "app_detail.html",
"app_edit.html", "app_edit.html",
"deployments.html", "deployments.html",
"webhook_events.html",
} }
pageTemplates = make(map[string]*template.Template) pageTemplates = make(map[string]*template.Template)

View File

@@ -0,0 +1,79 @@
{{template "base" .}}
{{define "title"}}Webhook Events - {{.App.Name}} - µPaaS{{end}}
{{define "content"}}
{{template "nav" .}}
<main class="max-w-4xl mx-auto px-4 py-8">
<div class="mb-6">
<a href="/apps/{{.App.ID}}" class="text-primary-600 hover:text-primary-800 inline-flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
Back to {{.App.Name}}
</a>
</div>
<div class="section-header">
<h1 class="text-2xl font-medium text-gray-900">Webhook Events</h1>
</div>
{{if .Events}}
<div class="card overflow-hidden">
<table class="table">
<thead class="table-header">
<tr>
<th>Time</th>
<th>Event</th>
<th>Branch</th>
<th>Commit</th>
<th>Status</th>
</tr>
</thead>
<tbody class="table-body">
{{range .Events}}
<tr>
<td class="text-gray-500 text-sm whitespace-nowrap">
<span x-data="relativeTime('{{.CreatedAt.Format `2006-01-02T15:04:05Z07:00`}}')" x-text="display" class="cursor-default" title="{{.CreatedAt.Format `2006-01-02 15:04:05`}}"></span>
</td>
<td class="text-gray-700 text-sm">{{.EventType}}</td>
<td class="font-mono text-gray-500 text-sm">{{.Branch}}</td>
<td class="font-mono text-gray-500 text-xs">
{{if and .CommitSHA.Valid .CommitURL.Valid}}
<a href="{{.CommitURL.String}}" target="_blank" rel="noopener noreferrer" class="text-primary-600 hover:text-primary-800">{{.ShortCommit}}</a>
{{else if .CommitSHA.Valid}}
{{.ShortCommit}}
{{else}}
<span class="text-gray-400">-</span>
{{end}}
</td>
<td>
{{if .Matched}}
{{if .Processed}}
<span class="badge-success">Matched</span>
{{else}}
<span class="badge-warning">Matched (pending)</span>
{{end}}
{{else}}
<span class="badge-neutral">No match</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
{{else}}
<div class="card">
<div class="empty-state">
<svg class="empty-state-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<h3 class="empty-state-title">No webhook events yet</h3>
<p class="empty-state-description">Webhook events will appear here once your repository sends push notifications.</p>
</div>
</div>
{{end}}
</main>
{{end}}