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