6 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
14 changed files with 722 additions and 505 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt fmt-check 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")
@@ -22,12 +22,22 @@ fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files not formatted:" && gofmt -l . && exit 1) @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: fmt-check lint test check: fmt-check lint test
@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

View File

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

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

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

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

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