Compare commits
1 Commits
fix/js-for
...
30f81078bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30f81078bd |
14
Makefile
14
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt fmt-check test check clean docker hooks
|
.PHONY: all build lint fmt fmt-check test check clean
|
||||||
|
|
||||||
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,22 +22,12 @@ 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 -timeout 30s ./...
|
go test -v -race -cover ./...
|
||||||
|
|
||||||
# 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/
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -110,14 +110,11 @@ chi Router ──► Middleware Stack ──► Handler
|
|||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make fmt # Format code
|
make fmt # Format code
|
||||||
make fmt-check # Check formatting (read-only, fails if unformatted)
|
make lint # Run comprehensive linting
|
||||||
make lint # Run comprehensive linting
|
make test # Run tests with race detection
|
||||||
make test # Run tests with race detection (30s timeout)
|
make check # Verify everything passes (lint, test, build, format)
|
||||||
make check # Verify everything passes (fmt-check, lint, test)
|
make build # Build binary
|
||||||
make build # Build binary
|
|
||||||
make docker # Build Docker image
|
|
||||||
make hooks # Install pre-commit hook (runs make check)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Commit Requirements
|
### Commit Requirements
|
||||||
|
|||||||
@@ -54,18 +54,12 @@ 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 == "" {
|
||||||
@@ -106,9 +100,6 @@ 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 {
|
||||||
|
|||||||
@@ -732,11 +732,11 @@ func TestHandleEnvVarDeleteUsesCorrectRouteParam(t *testing.T) {
|
|||||||
|
|
||||||
// Use chi router with the real route pattern to test param name
|
// Use chi router with the real route pattern to test param name
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Post("/apps/{id}/env-vars/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
|
r.Post("/apps/{id}/env/{varID}/delete", testCtx.handlers.HandleEnvVarDelete())
|
||||||
|
|
||||||
request := httptest.NewRequest(
|
request := httptest.NewRequest(
|
||||||
http.MethodPost,
|
http.MethodPost,
|
||||||
"/apps/"+createdApp.ID+"/env-vars/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
|
"/apps/"+createdApp.ID+"/env/"+strconv.FormatInt(envVar.ID, 10)+"/delete",
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
recorder := httptest.NewRecorder()
|
recorder := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,20 +52,6 @@ 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 (
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ 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())
|
||||||
@@ -83,9 +82,9 @@ func (s *Server) SetupRoutes() {
|
|||||||
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
|
||||||
|
|
||||||
// Environment variables
|
// Environment variables
|
||||||
r.Post("/apps/{id}/env-vars", s.handlers.HandleEnvVarAdd())
|
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarAdd())
|
||||||
r.Post("/apps/{id}/env-vars/{varID}/edit", s.handlers.HandleEnvVarEdit())
|
r.Post("/apps/{id}/env/{varID}/edit", s.handlers.HandleEnvVarEdit())
|
||||||
r.Post("/apps/{id}/env-vars/{varID}/delete", s.handlers.HandleEnvVarDelete())
|
r.Post("/apps/{id}/env/{varID}/delete", s.handlers.HandleEnvVarDelete())
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
|
||||||
|
|||||||
@@ -6,215 +6,189 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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._initScrollTracking(this.$refs.containerLogsWrapper, '_containerAutoScroll');
|
||||||
this.$refs.containerLogsWrapper,
|
this._initScrollTracking(this.$refs.buildLogsWrapper, '_buildAutoScroll');
|
||||||
"_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)
|
const interval = Alpine.store("utils").isDeploying(this.appStatus) ? 1000 : 10000;
|
||||||
? 1000
|
this._pollTimer = setTimeout(() => {
|
||||||
: 10000;
|
this.fetchAll();
|
||||||
this._pollTimer = setTimeout(() => {
|
this._schedulePoll();
|
||||||
this.fetchAll();
|
}, interval);
|
||||||
this._schedulePoll();
|
},
|
||||||
}, interval);
|
|
||||||
},
|
|
||||||
|
|
||||||
_initScrollTracking(el, flag) {
|
_initScrollTracking(el, flag) {
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.addEventListener(
|
el.addEventListener('scroll', () => {
|
||||||
"scroll",
|
this[flag] = Alpine.store("utils").isScrolledToBottom(el);
|
||||||
() => {
|
}, { 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 (
|
if (this.$refs.containerLogsWrapper && this._isElementVisible(this.$refs.containerLogsWrapper)) {
|
||||||
this.$refs.containerLogsWrapper &&
|
this.fetchContainerLogs();
|
||||||
this._isElementVisible(this.$refs.containerLogsWrapper)
|
}
|
||||||
) {
|
if (this.showBuildLogs && this.$refs.buildLogsWrapper && this._isElementVisible(this.$refs.buildLogsWrapper)) {
|
||||||
this.fetchContainerLogs();
|
this.fetchBuildLogs();
|
||||||
}
|
}
|
||||||
if (
|
this.fetchRecentDeployments();
|
||||||
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(
|
Alpine.store("utils").scrollToBottom(this.$refs.containerLogsWrapper);
|
||||||
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(
|
Alpine.store("utils").scrollToBottom(this.$refs.buildLogsWrapper);
|
||||||
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(
|
const res = await fetch(`/apps/${this.appId}/recent-deployments`);
|
||||||
`/apps/${this.appId}/recent-deployments`,
|
const data = await res.json();
|
||||||
);
|
this.deployments = data.deployments || [];
|
||||||
const data = await res.json();
|
} catch (err) {
|
||||||
this.deployments = data.deployments || [];
|
console.error("Deployments fetch error:", err);
|
||||||
} 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) +
|
Alpine.store("utils").statusBadgeClass(this.buildStatus) + " text-xs"
|
||||||
" 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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,18 +5,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 =
|
el.textContent = Alpine.store("utils").formatRelativeTime(time);
|
||||||
Alpine.store("utils").formatRelativeTime(time);
|
}
|
||||||
}
|
});
|
||||||
});
|
}, 60000);
|
||||||
}, 60000);
|
},
|
||||||
},
|
}));
|
||||||
}));
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,180 +6,171 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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(
|
wrapper.addEventListener('scroll', () => {
|
||||||
"scroll",
|
this._autoScroll = Alpine.store("utils").isScrolledToBottom(wrapper);
|
||||||
() => {
|
}, { 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(
|
Alpine.store("utils").scrollToBottom(this.$refs.logsWrapper);
|
||||||
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);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,144 +5,139 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
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 (
|
return diffMin + (diffMin === 1 ? " minute ago" : " minutes ago");
|
||||||
diffMin + (diffMin === 1 ? " minute ago" : " minutes ago")
|
if (diffHour < 24)
|
||||||
);
|
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
|
||||||
if (diffHour < 24)
|
if (diffDay < 7)
|
||||||
return diffHour + (diffHour === 1 ? " hour ago" : " hours ago");
|
return diffDay + (diffDay === 1 ? " day ago" : " days ago");
|
||||||
if (diffDay < 7)
|
return date.toLocaleDateString();
|
||||||
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")
|
if (status === "running" || status === "success") return "badge-success";
|
||||||
return "badge-success";
|
if (status === "building" || status === "deploying")
|
||||||
if (status === "building" || status === "deploying")
|
return "badge-warning";
|
||||||
return "badge-warning";
|
if (status === "failed" || status === "error") return "badge-error";
|
||||||
if (status === "failed" || status === "error") return "badge-error";
|
return "badge-neutral";
|
||||||
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 (
|
return el.scrollHeight - el.scrollTop - el.clientHeight <= tolerance;
|
||||||
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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -77,10 +77,7 @@
|
|||||||
|
|
||||||
<!-- Webhook URL -->
|
<!-- Webhook URL -->
|
||||||
<div class="card p-6 mb-6">
|
<div class="card p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<h2 class="section-title mb-4">Webhook URL</h2>
|
||||||
<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>
|
||||||
@@ -125,7 +122,7 @@
|
|||||||
<template x-if="!editing">
|
<template x-if="!editing">
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<button @click="editing = true" class="text-primary-600 hover:text-primary-800 text-sm mr-2">Edit</button>
|
<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-vars/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/delete" class="inline" x-data="confirmAction('Delete this environment variable?')" @submit="confirm($event)">
|
||||||
{{ $.CSRFField }}
|
{{ $.CSRFField }}
|
||||||
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
<button type="submit" class="text-error-500 hover:text-error-700 text-sm">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -133,7 +130,7 @@
|
|||||||
</template>
|
</template>
|
||||||
<template x-if="editing">
|
<template x-if="editing">
|
||||||
<td colspan="3">
|
<td colspan="3">
|
||||||
<form method="POST" action="/apps/{{$.App.ID}}/env-vars/{{.ID}}/edit" class="flex gap-2 items-center">
|
<form method="POST" action="/apps/{{$.App.ID}}/env/{{.ID}}/edit" class="flex gap-2 items-center">
|
||||||
{{ $.CSRFField }}
|
{{ $.CSRFField }}
|
||||||
<input type="text" name="key" value="{{.Key}}" required class="input flex-1 font-mono text-sm">
|
<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">
|
<input type="text" name="value" value="{{.Value}}" required class="input flex-1 font-mono text-sm">
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ 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)
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
{{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}}
|
|
||||||
Reference in New Issue
Block a user