3 Commits

Author SHA1 Message Date
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
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
15 changed files with 729 additions and 512 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
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:
go test -v -race -cover ./...
go test -v -race -cover -timeout 30s ./...
# Check runs all validation without making changes
# Used by CI and Docker build - fails if anything is wrong
check: fmt-check lint test
@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:
rm -rf bin/

View File

@@ -111,10 +111,13 @@ chi Router ──► Middleware Stack ──► Handler
```bash
make fmt # Format code
make fmt-check # Check formatting (read-only, fails if unformatted)
make lint # Run comprehensive linting
make test # Run tests with race detection
make check # Verify everything passes (lint, test, build, format)
make test # Run tests with race detection (30s timeout)
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

View File

@@ -54,12 +54,18 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
repoURL := request.FormValue("repo_url")
branch := request.FormValue("branch")
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{
"Name": name,
"RepoURL": repoURL,
"Branch": branch,
"DockerfilePath": dockerfilePath,
"DockerNetwork": dockerNetwork,
"NtfyTopic": ntfyTopic,
"SlackWebhook": slackWebhook,
}, request)
if name == "" || repoURL == "" {
@@ -100,6 +106,9 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
RepoURL: repoURL,
Branch: branch,
DockerfilePath: dockerfilePath,
DockerNetwork: dockerNetwork,
NtfyTopic: ntfyTopic,
SlackWebhook: slackWebhook,
},
)
if createErr != nil {

View File

@@ -732,11 +732,11 @@ func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
// Use chi router with the real route pattern to test param name
r := chi.NewRouter()
r.Post("/apps/{id}/env/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
request := httptest.NewRequest(
http.MethodPost,
"/apps/"+createdApp.ID+"/env/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
nil,
)
recorder := httptest.NewRecorder()

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)
}
// 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 {
query := `
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}/deployments/cancel", s.handlers.HandleCancelDeploy())
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}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
@@ -82,9 +83,9 @@ func (s *Server) SetupRoutes() {
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env/{varID}/delete", s.handlers.HandleEnvVarDelete())
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
// Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())

View File

@@ -31,14 +31,22 @@ document.addEventListener("alpine:init", () => {
// Set up scroll listeners after DOM is ready
this.$nextTick(() => {
this._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
this._initScrollTracking(
this.$refs.containerLogsWrapper,
"_containerAutoScroll",
);
this._initScrollTracking(
this.$refs.buildLogsWrapper,
"_buildAutoScroll",
);
});
},
_schedulePoll() {
if (this._pollTimer) clearTimeout(this._pollTimer);
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
const interval = Alpine.store("utils").isDeploying(this.appStatus)
? 1000
: 10000;
this._pollTimer = setTimeout(() => {
this.fetchAll();
this._schedulePoll();
@@ -47,18 +55,29 @@ document.addEventListener("alpine:init", () => {
_initScrollTracking(el, flag) {
if (!el) return;
el.addEventListener('scroll', () => {
el.addEventListener(
"scroll",
() => {
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
}, { passive: true });
},
{ passive: true },
);
},
fetchAll() {
this.fetchAppStatus();
// Only fetch logs when the respective pane is visible
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
if (
this.$refs.containerLogsWrapper &&
this._isElementVisible(this.$refs.containerLogsWrapper)
) {
this.fetchContainerLogs();
}
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
if (
this.showBuildLogs &&
this.$refs.buildLogsWrapper &&
this._isElementVisible(this.$refs.buildLogsWrapper)
) {
this.fetchBuildLogs();
}
this.fetchRecentDeployments();
@@ -107,7 +126,9 @@ document.addEventListener("alpine:init", () => {
this.containerStatus = data.status;
if (changed && this._containerAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
Alpine.store("utils").scrollToBottom(
this.$refs.containerLogsWrapper,
);
});
}
} catch (err) {
@@ -128,7 +149,9 @@ document.addEventListener("alpine:init", () => {
this.buildStatus = data.status;
if (changed && this._buildAutoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
Alpine.store("utils").scrollToBottom(
this.$refs.buildLogsWrapper,
);
});
}
} catch (err) {
@@ -138,7 +161,9 @@ document.addEventListener("alpine:init", () => {
async fetchRecentDeployments() {
try {
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
const res = await fetch(
`/apps/${this.appId}/recent-deployments`,
);
const data = await res.json();
this.deployments = data.deployments || [];
} catch (err) {
@@ -171,7 +196,8 @@ document.addEventListener("alpine:init", () => {
get buildStatusBadgeClass() {
return (
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
Alpine.store("utils").statusBadgeClass(this.buildStatus) +
" text-xs"
);
},

View File

@@ -12,7 +12,8 @@ document.addEventListener("alpine:init", () => {
this.$el.querySelectorAll("[data-time]").forEach((el) => {
const time = el.getAttribute("data-time");
if (time) {
el.textContent = Alpine.store("utils").formatRelativeTime(time);
el.textContent =
Alpine.store("utils").formatRelativeTime(time);
}
});
}, 60000);

View File

@@ -26,9 +26,16 @@ document.addEventListener("alpine:init", () => {
this.$nextTick(() => {
const wrapper = this.$refs.logsWrapper;
if (wrapper) {
wrapper.addEventListener('scroll', () => {
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
}, { passive: true });
wrapper.addEventListener(
"scroll",
() => {
this._autoScroll =
Alpine.store("utils").isScrolledToBottom(
wrapper,
);
},
{ passive: true },
);
}
});
@@ -59,7 +66,9 @@ document.addEventListener("alpine:init", () => {
// Scroll to bottom only when content changes AND user hasn't scrolled up
if (logsChanged && this._autoScroll) {
this.$nextTick(() => {
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
Alpine.store("utils").scrollToBottom(
this.$refs.logsWrapper,
);
});
}

View File

@@ -21,7 +21,9 @@ document.addEventListener("alpine:init", () => {
if (diffSec < 60) return "just now";
if (diffMin < 60)
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
return (
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
);
if (diffHour < 24)
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
if (diffDay < 7)
@@ -33,7 +35,8 @@ document.addEventListener("alpine:init", () => {
* Get the badge class for a given status
*/
statusBadgeClass(status) {
if (status === "running" || status === "success") return "badge-success";
if (status === "running" || status === "success")
return "badge-success";
if (status === "building" || status === "deploying")
return "badge-warning";
if (status === "failed" || status === "error") return "badge-error";
@@ -72,7 +75,9 @@ document.addEventListener("alpine:init", () => {
*/
isScrolledToBottom(el, tolerance = 30) {
if (!el) return true;
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
return (
el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance
);
},
/**

View File

@@ -77,7 +77,10 @@
<!-- Webhook URL -->
<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>
<div class="copy-field" x-data="copyButton('webhook-url')">
<code id="webhook-url" class="copy-field-value text-xs">{{.WebhookURL}}</code>
@@ -122,7 +125,7 @@
<template x-if="!editing">
<td class="text-right">
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
{{ $.CSRFField }}
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
</form>
@@ -130,7 +133,7 @@
</template>
<template x-if="editing">
<td colspan="3">
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/edit" class="flex gap-2 items-center">
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
{{ $.CSRFField }}
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">

View File

@@ -44,6 +44,7 @@ func initTemplates() {
"app_detail.html",
"app_edit.html",
"deployments.html",
"webhook_events.html",
}
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}}