Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 30 additions & 17 deletions apps/api/src/routes/apps.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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, {
Expand Down
49 changes: 45 additions & 4 deletions apps/api/src/services/app-status-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,15 +155,56 @@ async function persistStatus(
}
}

async function persistContainerId(
db: Db,
appId: string,
next: string,
prev: string | null
): Promise<void> {
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<T extends ReconcilableApp>(
db: Db,
app: T,
index: AppContainerIndex
): Promise<T> {
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<T extends ReconcilableApp>(
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/lib/app-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 88 additions & 28 deletions apps/web/src/lib/events-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
// <EventsProvider> ← monté dans _authed.tsx (1 instance par session authed)
// <App />
Expand All @@ -20,69 +24,122 @@ 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<Subscribe | null>(null)
const ConnectedContext = React.createContext<boolean>(false)
const StatusContext = React.createContext<EventsStatus>("connecting")

const MAX_BACKOFF_MS = 30_000
const BASE_BACKOFF_MS = 1_000

export function EventsProvider({
children,
}: {
children: React.ReactNode
}): React.JSX.Element {
const backendUnavailable = useBackendUnavailable()
const [connected, setConnected] = React.useState(false)
const [status, setStatus] = React.useState<EventsStatus>("connecting")
const listenersRef = React.useRef(new Map<string, Set<Listener>>())
const abortRef = React.useRef<AbortController | null>(null)
const attachedRef = React.useRef(new Set<string>())
const esRef = React.useRef<EventSource | null>(null)
const attemptRef = React.useRef(0)
const reconnectTimerRef = React.useRef<ReturnType<typeof setTimeout> | 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<string>()
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<string>()
}

async function openConnection(currentAbort: AbortController): Promise<void> {
// 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<string>()

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<string>()
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])

Expand Down Expand Up @@ -115,9 +172,7 @@ export function EventsProvider({

return (
<SubscribeContext.Provider value={subscribe}>
<ConnectedContext.Provider value={connected}>
{children}
</ConnectedContext.Provider>
<StatusContext.Provider value={status}>{children}</StatusContext.Provider>
</SubscribeContext.Provider>
)
}
Expand Down Expand Up @@ -174,8 +229,13 @@ export function useEventsSubscription<T>(
}, [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"
}

/**
Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/lib/hooks/use-log-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,7 @@ export function useLogStream({

let ws: WebSocket
let fallbackTriggered = false
let fallbackTimer: ReturnType<typeof setTimeout> | null = null

const triggerFallback = (): void => {
setError(
Expand Down Expand Up @@ -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) {
Expand All @@ -525,6 +530,7 @@ export function useLogStream({
}

return () => {
if (fallbackTimer) clearTimeout(fallbackTimer)
ws?.close()
}
}, [appId, buildId, archiveOnly, appendLine, flushPendingLines])
Expand Down
Loading