diff --git a/apps/api/src/routes/apps.ts b/apps/api/src/routes/apps.ts index 42e78ac1..c82b56c4 100644 --- a/apps/api/src/routes/apps.ts +++ b/apps/api/src/routes/apps.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import { readFile } from "node:fs/promises" +import * as nodePath from "node:path" import { randomBytes } from "node:crypto" import { Hono } from "hono" import { z } from "zod" @@ -1451,26 +1452,38 @@ export function createAppsRouter(db: Db): Hono { ) } - if (!build.log_path) { - return c.json( - { - error: { - code: "NOT_FOUND", - message: "No archived log file for this build", - }, - }, - 404 - ) - } + // log_path is normally persisted at build start (deploy.ts line 516). + // If it's missing — race with an in-flight build, killed worker before + // the early persist, or legacy row predating that change — fall back to + // the convention path. Returns 200 with an explanatory body when truly + // empty so the UI doesn't show a misleading "Failed to load logs (404)". + const conventionPath = nodePath.join( + env.PLOYDOK_BUILD_DIR, + appId, + `${buildId}.log` + ) + const candidatePath = build.log_path ?? conventionPath - let content: Buffer + let content: Buffer | null = null try { - content = await readFile(build.log_path) + content = await readFile(candidatePath) } catch { - return c.json( - { error: { code: "NOT_FOUND", message: "Log file not found on disk" } }, - 404 - ) + if (build.log_path && build.log_path !== conventionPath) { + try { + content = await readFile(conventionPath) + } catch { + // both paths missing — fall through to empty body + } + } + } + + if (!content) { + return new Response("(no logs captured)", { + status: 200, + headers: { + "content-type": "text/plain; charset=utf-8", + }, + }) } return new Response(content, { diff --git a/apps/api/src/services/app-status-reconciler.ts b/apps/api/src/services/app-status-reconciler.ts index b9a8f756..4452e7d3 100644 --- a/apps/api/src/services/app-status-reconciler.ts +++ b/apps/api/src/services/app-status-reconciler.ts @@ -155,15 +155,56 @@ async function persistStatus( } } +async function persistContainerId( + db: Db, + appId: string, + next: string, + prev: string | null +): Promise { + try { + await db + .update(apps) + .set({ container_id: next, updated_at: new Date() }) + .where(eq(apps.id, appId)) + log.info({ appId, from: prev, to: next }, "reconciled apps.container_id") + } catch (err) { + log.warn({ err, appId }, "failed to persist reconciled container_id") + } +} + export async function reconcileAppStatusFromIndex( db: Db, app: T, index: AppContainerIndex ): Promise { - const next = deriveLiveStatus(app, index) - if (!next || next === app.status) return app - await persistStatus(db, app.id, next, app.status) - return { ...app, status: next } + let mutated: T = app + const live = resolveLiveContainer(app, index) + if (live && live.name !== app.container_id && live.id !== app.container_id) { + // Container was recreated (blue/green swap, restart, etc.) — refresh the + // canonical reference so the UI strict match keeps working. + await persistContainerId(db, app.id, live.name, app.container_id) + mutated = { ...mutated, container_id: live.name } + } + + const next = deriveLiveStatus(mutated, index) + if (next && next !== mutated.status) { + await persistStatus(db, mutated.id, next, mutated.status) + mutated = { ...mutated, status: next } + } + return mutated +} + +function resolveLiveContainer( + app: ReconcilableApp, + index: AppContainerIndex +): ContainerLite | null { + if (app.container_id) { + const direct = + index.byContainerId.get(app.container_id) ?? + index.byContainerName.get(app.container_id) + if (direct) return direct + } + return index.bestByAppId.get(app.id) ?? null } export async function reconcileAppStatus( diff --git a/apps/web/src/lib/app-runtime.ts b/apps/web/src/lib/app-runtime.ts index ad8387f1..872553c5 100644 --- a/apps/web/src/lib/app-runtime.ts +++ b/apps/web/src/lib/app-runtime.ts @@ -37,7 +37,14 @@ export function selectAppSnapshot( (!c.kind || c.kind === "app") && (c.id === expectedRef || c.name === expectedRef) ) - return match ?? null + if (match) return match + // Fallback: the canonical container_id stored in the DB is stale (the + // container was recreated server-side — blue/green swap, restart, host + // reboot). Pick the highest-priority alive container with the right + // app_id label so the badge stops lying about "Stopped". The API + // reconciler refreshes apps.container_id on the next /apps fetch, so + // this fallback is only used during the brief window between recreation + // and the next API poll. } let selected: ContainerSnapshot | null = null diff --git a/apps/web/src/lib/events-provider.tsx b/apps/web/src/lib/events-provider.tsx index 45711104..6d7c667e 100644 --- a/apps/web/src/lib/events-provider.tsx +++ b/apps/web/src/lib/events-provider.tsx @@ -4,6 +4,10 @@ // events nommés auprès des consumers via Context. Remplace les EventSource // multiples ouvertes par useNotifications + useMonitoringEvents. // +// Reconnexion: la spec EventSource arrête le retry natif sur réponse non-2xx +// (typiquement 401 cookie expiré). On gère donc nous-mêmes le reconnect avec +// backoff exponentiel + refresh token avant chaque tentative. +// // Usage: // ← monté dans _authed.tsx (1 instance par session authed) // @@ -20,8 +24,13 @@ import { useBackendUnavailable } from "./backend-status" type Listener = (data: unknown) => void type Subscribe = (eventType: string, cb: Listener) => () => void +export type EventsStatus = "connecting" | "open" | "reconnecting" | "offline" + const SubscribeContext = React.createContext(null) -const ConnectedContext = React.createContext(false) +const StatusContext = React.createContext("connecting") + +const MAX_BACKOFF_MS = 30_000 +const BASE_BACKOFF_MS = 1_000 export function EventsProvider({ children, @@ -29,60 +38,108 @@ export function EventsProvider({ children: React.ReactNode }): React.JSX.Element { const backendUnavailable = useBackendUnavailable() - const [connected, setConnected] = React.useState(false) + const [status, setStatus] = React.useState("connecting") const listenersRef = React.useRef(new Map>()) const abortRef = React.useRef(null) const attachedRef = React.useRef(new Set()) const esRef = React.useRef(null) + const attemptRef = React.useRef(0) + const reconnectTimerRef = React.useRef | null>( + null + ) React.useEffect(() => { if (typeof window === "undefined") return if (backendUnavailable.active) { - abortRef.current?.abort() - abortRef.current = null - esRef.current?.close() - esRef.current = null - attachedRef.current = new Set() - setConnected(false) + teardown() + setStatus("offline") return } const abort = new AbortController() abortRef.current = abort + attemptRef.current = 0 + setStatus("connecting") - void (async () => { - // EventSource cannot retry through apiFetch's 401 refresh path. Refresh - // once before opening the stream so a page reload with an expired access - // cookie does not create a noisy failed SSE connection first. + void openConnection(abort) + + return () => { + teardown() + } + + function teardown(): void { + abort.abort() + if (abortRef.current === abort) abortRef.current = null + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = null + } + esRef.current?.close() + esRef.current = null + attachedRef.current = new Set() + } + + async function openConnection(currentAbort: AbortController): Promise { + // EventSource cannot retry through apiFetch's 401 refresh path. + // Always refresh the access cookie before (re)opening the stream so + // the SSE handshake doesn't get rejected on stale tokens. await triggerRefresh().catch(() => undefined) - if (abort.signal.aborted) return + if (currentAbort.signal.aborted) return + esRef.current?.close() const es = new EventSource(`${apiBaseUrl()}/events`, { withCredentials: true, }) esRef.current = es attachedRef.current = new Set() - es.onopen = () => setConnected(true) - es.onerror = () => setConnected(false) + es.onopen = () => { + if (currentAbort.signal.aborted) return + attemptRef.current = 0 + setStatus("open") + } + + es.onerror = () => { + if (currentAbort.signal.aborted) return + // Browser native retry will fire onopen again on transient errors. + // We only schedule our own reconnect when the EventSource has been + // permanently closed by the browser (CLOSED == non-2xx response). + if (es.readyState === EventSource.CLOSED) { + esRef.current?.close() + esRef.current = null + scheduleReconnect(currentAbort) + } else { + // Transient — surface the reconnecting state but let the browser + // handle the retry. onopen will reset us to "open". + setStatus("reconnecting") + } + } // Re-attach every event type already subscribed at the time of mount. // Needed if a consumer subscribed before the EventSource was opened. for (const eventType of listenersRef.current.keys()) { if (!attachedRef.current.has(eventType)) { - attachListener(es, eventType, listenersRef.current, abort.signal) + attachListener(es, eventType, listenersRef.current, currentAbort.signal) attachedRef.current.add(eventType) } } - })() + } - return () => { - abort.abort() - if (abortRef.current === abort) abortRef.current = null - esRef.current?.close() - esRef.current = null - attachedRef.current = new Set() - setConnected(false) + function scheduleReconnect(currentAbort: AbortController): void { + if (currentAbort.signal.aborted) return + const delay = Math.min( + BASE_BACKOFF_MS * Math.pow(2, attemptRef.current), + MAX_BACKOFF_MS + ) + attemptRef.current += 1 + setStatus("reconnecting") + + if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current) + reconnectTimerRef.current = setTimeout(() => { + reconnectTimerRef.current = null + if (currentAbort.signal.aborted) return + void openConnection(currentAbort) + }, delay) } }, [backendUnavailable.active]) @@ -115,9 +172,7 @@ export function EventsProvider({ return ( - - {children} - + {children} ) } @@ -174,8 +229,13 @@ export function useEventsSubscription( }, [subscribe, eventType, enabled]) } +export function useEventsStatus(): EventsStatus { + return React.useContext(StatusContext) +} + +/** Backward-compat boolean: true only when the stream is fully open. */ export function useEventsConnected(): boolean { - return React.useContext(ConnectedContext) + return React.useContext(StatusContext) === "open" } /** diff --git a/apps/web/src/lib/hooks/use-log-stream.ts b/apps/web/src/lib/hooks/use-log-stream.ts index 4e12e6a2..1f67b076 100644 --- a/apps/web/src/lib/hooks/use-log-stream.ts +++ b/apps/web/src/lib/hooks/use-log-stream.ts @@ -453,6 +453,7 @@ export function useLogStream({ let ws: WebSocket let fallbackTriggered = false + let fallbackTimer: ReturnType | null = null const triggerFallback = (): void => { setError( @@ -502,20 +503,24 @@ export function useLogStream({ appendLine(text) } - ws.onerror = () => { - if (!fallbackTriggered) { - fallbackTriggered = true + // Defer fallback by 1s so the API has a chance to persist log_path + // for builds that just finished (race between WS close and DB write). + const scheduleFallback = (): void => { + if (fallbackTriggered) return + fallbackTriggered = true + if (fallbackTimer) clearTimeout(fallbackTimer) + fallbackTimer = setTimeout(() => { + fallbackTimer = null triggerFallback() - } + }, 1_000) } + ws.onerror = scheduleFallback + ws.onclose = (ev) => { setConnected(false) flushPendingLines() - if (!ev.wasClean && !fallbackTriggered) { - fallbackTriggered = true - triggerFallback() - } + if (!ev.wasClean) scheduleFallback() } } catch { if (!fallbackTriggered) { @@ -525,6 +530,7 @@ export function useLogStream({ } return () => { + if (fallbackTimer) clearTimeout(fallbackTimer) ws?.close() } }, [appId, buildId, archiveOnly, appendLine, flushPendingLines]) diff --git a/apps/web/src/routes/_authed/orgs/$orgSlug/apps/$id/advanced.tsx b/apps/web/src/routes/_authed/orgs/$orgSlug/apps/$id/advanced.tsx index 4e4f9812..79ae66f7 100644 --- a/apps/web/src/routes/_authed/orgs/$orgSlug/apps/$id/advanced.tsx +++ b/apps/web/src/routes/_authed/orgs/$orgSlug/apps/$id/advanced.tsx @@ -121,11 +121,17 @@ function AdvancedSettingsPage() {

- - - ⚠️ Warning: An invalid config can break your app - routing. The config is automatically validated server-side, but manual - rollback may be necessary if Caddy refuses the JSON. + + + ⚠️ How it works: the JSON below is merged into the + Caddy route handler chain for this app, in front of the reverse + proxy. The Caddy Admin API rejects invalid JSON server-side — your + previous handler chain stays in place if validation fails, so a bad + paste never takes the app offline. Save again with a corrected + config to re-apply. @@ -146,6 +152,48 @@ function AdvancedSettingsPage() { )} +
+ + Examples — copy & paste + +
+
+

+ 1. Add a security header to every response +

+
{`[
+  {
+    "handler": "headers",
+    "response": {
+      "headers": {
+        "Strict-Transport-Security": ["max-age=63072000; includeSubDomains"]
+      }
+    }
+  }
+]`}
+
+
+

2. Permanent redirect to /docs

+
{`[
+  {
+    "handler": "static_response",
+    "status_code": 301,
+    "headers": { "Location": ["/docs"] }
+  }
+]`}
+
+
+

3. Rewrite /api/* to /v1/*

+
{`[
+  {
+    "handler": "rewrite",
+    "uri": "/v1{http.request.uri.path}"
+  }
+]`}
+
+
+
+