feat: DWH connectivity probe at startup — modal alert within 5s if unreachable
Backend: GET /health/dwh (unauthenticated) calls tht db ping with a 5s timeout. Frontend: checkDwhHealth() races a 5s timer against the fetch; on failure a non-dismissable Dialog with Retry appears immediately. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -86,10 +86,11 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
|
|||||||
const authenticate = authPreHandler(config.authMode);
|
const authenticate = authPreHandler(config.authMode);
|
||||||
app.addHook("preHandler", async (req, reply) => {
|
app.addHook("preHandler", async (req, reply) => {
|
||||||
// Process readiness is intentionally unauthenticated for local container/proxy probes.
|
// Process readiness is intentionally unauthenticated for local container/proxy probes.
|
||||||
if (req.url === "/health") return;
|
if (req.url === "/health" || req.url === "/health/dwh") return;
|
||||||
return authenticate(req, reply);
|
return authenticate(req, reply);
|
||||||
});
|
});
|
||||||
app.get("/health", async () => ({ status: "ok" }));
|
app.get("/health", async () => ({ status: "ok" }));
|
||||||
|
app.get("/health/dwh", async () => tht.dbPing());
|
||||||
app.get("/me", async (req) => getPrincipal(req));
|
app.get("/me", async (req) => getPrincipal(req));
|
||||||
sessionRoutes(app, {
|
sessionRoutes(app, {
|
||||||
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, dwhPrecheck: config.dwhPrecheck,
|
mgr, tht: tht as ThtRunner, hub, getSettings, readiness, dwhPrecheck: config.dwhPrecheck,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const READINESS_FAILURE_MESSAGE =
|
|||||||
const RESUME_FAILURE_MESSAGE =
|
const RESUME_FAILURE_MESSAGE =
|
||||||
"Session could not be resumed. Check configuration and connectivity, then try again.";
|
"Session could not be resumed. Check configuration and connectivity, then try again.";
|
||||||
const DWH_UNREACHABLE_MESSAGE =
|
const DWH_UNREACHABLE_MESSAGE =
|
||||||
"Cannot start a session: the data warehouse is unreachable. Check the VPN connection and try again.";
|
"Cannot start a session: the database is unreachable. Check the VPN connection and try again.";
|
||||||
|
|
||||||
export function sessionRoutes(
|
export function sessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export class ThtRunner {
|
|||||||
* cleanly. Timed out to keep POST /sessions responsive when the host hangs.
|
* cleanly. Timed out to keep POST /sessions responsive when the host hangs.
|
||||||
*/
|
*/
|
||||||
async dbPing(workspace?: string): Promise<{ ok: boolean; detail: string }> {
|
async dbPing(workspace?: string): Promise<{ ok: boolean; detail: string }> {
|
||||||
const { code, stdout, stderr } = await this.run(["db", "ping"], workspace, 10_000);
|
const { code, stdout, stderr } = await this.run(["db", "ping"], workspace, 5_000);
|
||||||
if (code === 0) return { ok: true, detail: stdout.trim() };
|
if (code === 0) return { ok: true, detail: stdout.trim() };
|
||||||
return { ok: false, detail: (stderr || stdout).trim() };
|
return { ok: false, detail: (stderr || stdout).trim() };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,3 +53,15 @@ export const deleteSession = (id: string) =>
|
|||||||
|
|
||||||
export const getSessionDocuments = (id: string) =>
|
export const getSessionDocuments = (id: string) =>
|
||||||
apiFetch<SessionDocument[]>(`/sessions/${id}/documents`);
|
apiFetch<SessionDocument[]>(`/sessions/${id}/documents`);
|
||||||
|
|
||||||
|
export async function checkDwhHealth(): Promise<{ ok: boolean }> {
|
||||||
|
const timeout = new Promise<{ ok: false }>((r) => setTimeout(() => r({ ok: false }), 5000));
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
apiFetch<{ ok: boolean; detail: string }>("/health/dwh"),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
|
|||||||
http.post("http://localhost:8787/sessions", () =>
|
http.post("http://localhost:8787/sessions", () =>
|
||||||
HttpResponse.json(
|
HttpResponse.json(
|
||||||
{
|
{
|
||||||
error: "Cannot start a session: the data warehouse is unreachable. Check the VPN connection and try again.",
|
error: "Cannot start a session: the database is unreachable. Check the VPN connection and try again.",
|
||||||
code: "dwh_unreachable",
|
code: "dwh_unreachable",
|
||||||
},
|
},
|
||||||
{ status: 503 },
|
{ status: 503 },
|
||||||
@@ -113,7 +113,7 @@ test("a DWH-unreachable precheck shows a specific alert and preserves the questi
|
|||||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||||
|
|
||||||
// Specific alert, not the generic "failed to create session" hint.
|
// Specific alert, not the generic "failed to create session" hint.
|
||||||
expect(await screen.findByText(/data warehouse is unreachable/i)).toBeInTheDocument();
|
expect(await screen.findByText(/database is unreachable/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByText(/failed to create session/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/failed to create session/i)).not.toBeInTheDocument();
|
||||||
// No session was created: landing view stays and the question is kept for retry.
|
// No session was created: landing view stays and the question is kept for retry.
|
||||||
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import { toast } from "sonner";
|
|||||||
import {
|
import {
|
||||||
closeSession, getMe, listSessions, resumeSession, getSession,
|
closeSession, getMe, listSessions, resumeSession, getSession,
|
||||||
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
|
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
|
||||||
|
checkDwhHealth,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
|
import {
|
||||||
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
|
} from "../components/ui/dialog";
|
||||||
import type { Principal, SessionScope, SessionSummary } from "../api/types";
|
import type { Principal, SessionScope, SessionSummary } from "../api/types";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
@@ -89,6 +93,19 @@ export function AppShell() {
|
|||||||
const [stopConfirm, setStopConfirm] = useState(false);
|
const [stopConfirm, setStopConfirm] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
|
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
|
||||||
|
const [dwhDown, setDwhDown] = useState(false);
|
||||||
|
const [dwhChecking, setDwhChecking] = useState(true);
|
||||||
|
const [dwhCheckEpoch, setDwhCheckEpoch] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setDwhChecking(true);
|
||||||
|
checkDwhHealth().then((r) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setDwhDown(!r.ok);
|
||||||
|
setDwhChecking(false);
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [dwhCheckEpoch]);
|
||||||
|
|
||||||
const groups = useMemo(
|
const groups = useMemo(
|
||||||
() => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(),
|
() => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(),
|
||||||
@@ -759,6 +776,26 @@ export function AppShell() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{dwhDown && (
|
||||||
|
<Dialog open onOpenChange={() => {}}>
|
||||||
|
<DialogContent showCloseButton={false} className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Connection unavailable</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
The database is unreachable. Check the VPN connection and try again.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
disabled={dwhChecking}
|
||||||
|
onClick={() => setDwhCheckEpoch((e) => e + 1)}
|
||||||
|
>
|
||||||
|
{dwhChecking ? "Checking…" : "Retry"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -770,7 +807,7 @@ function EmptyState() {
|
|||||||
Thoth<span className="text-primary">II</span>
|
Thoth<span className="text-primary">II</span>
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
|
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
|
||||||
Build datamarts from your data warehouse through a guided,
|
Build datamarts from your database through a guided,
|
||||||
human-in-the-loop workflow. Type your question in the box below to begin
|
human-in-the-loop workflow. Type your question in the box below to begin
|
||||||
a session.
|
a session.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
import { setupServer } from "msw/node";
|
import { setupServer } from "msw/node";
|
||||||
export const server = setupServer();
|
import { http, HttpResponse } from "msw";
|
||||||
|
export const server = setupServer(
|
||||||
|
http.get("http://localhost:8787/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user