diff --git a/packages/web/src/components/layout/Sidebar.tsx b/packages/web/src/components/layout/Sidebar.tsx index 9305546..5f9a7b3 100644 --- a/packages/web/src/components/layout/Sidebar.tsx +++ b/packages/web/src/components/layout/Sidebar.tsx @@ -13,7 +13,9 @@ import { Sun, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { HealthDot } from "@/components/shared/HealthDot"; import { useDemo } from "@/hooks/useDemo"; +import { useHealthStatus } from "@/hooks/useHealthStatus"; import { useInstances } from "@/hooks/useInstances"; import { useTheme } from "@/hooks/useTheme"; import { COLOR } from "@/lib/constants"; @@ -29,6 +31,7 @@ export function Sidebar() { const { instances, active, activate } = useInstances(); const { theme, toggle } = useTheme(); const { demo, toggle: toggleDemo, mask } = useDemo(); + const { data: health } = useHealthStatus(); const [switcherOpen, setSwitcherOpen] = useState(false); const switcherRef = useRef(null); @@ -87,8 +90,12 @@ export function Sidebar() { title={mask(active.baseUrl)} >
-

- {active.name} +

+ + {active.name}

{mask(active.baseUrl.replace(/^https?:\/\//, ""))} diff --git a/packages/web/src/components/settings/InstancesManager.tsx b/packages/web/src/components/settings/InstancesManager.tsx index e85075c..c707694 100644 --- a/packages/web/src/components/settings/InstancesManager.tsx +++ b/packages/web/src/components/settings/InstancesManager.tsx @@ -1,13 +1,15 @@ -import { motion } from "framer-motion"; -import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Trash2 } from "lucide-react"; -import { useState } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react"; +import { useEffect, useState } from "react"; import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm"; import { Button } from "@/components/ui/button"; import { Muted } from "@/components/ui/typography"; import { useInstances } from "@/hooks/useInstances"; -import { HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config"; +import { checkConnection, HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config"; import { COLOR } from "@/lib/constants"; +const LOCALHOST_PROBE_URL = "http://localhost:8000"; + type Mode = | { kind: "list" } | { kind: "choose-type" } @@ -99,6 +101,21 @@ interface ConnectionTypeChooserProps { } function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps) { + const [localhostDetected, setLocalhostDetected] = useState(false); + + useEffect(() => { + let cancelled = false; + void checkConnection(LOCALHOST_PROBE_URL).then((result) => { + if (cancelled) return; + if (result.status === "ok" || result.status === "auth-required") { + setLocalhostDetected(true); + } + }); + return () => { + cancelled = true; + }; + }, []); + return (

+ + {localhostDetected && ( + onPick("self-hosted")} + className="w-full overflow-hidden rounded-xl p-3 flex items-center gap-2.5 text-left" + style={{ + background: COLOR.successDim, + border: `1px solid ${COLOR.successBorder}`, + }} + > + +
+

+ Detected Honcho at {LOCALHOST_PROBE_URL.replace(/^https?:\/\//, "")} +

+ Tap to connect to it +
+
+ )} +
+ = { + ok: COLOR.success, + "auth-required": COLOR.warning, + unreachable: COLOR.destructive, + checking: COLOR.accentText, +}; + +const LABELS: Record = { + ok: "Connected", + "auth-required": "Auth required", + unreachable: "Unreachable", + checking: "Checking...", +}; + +export function HealthDot({ status, message, size = 8 }: HealthDotProps) { + const color = status ? COLORS[status] : "var(--text-4)"; + const label = status ? LABELS[status] : "Unknown"; + const title = message ? `${label} — ${message}` : label; + const pulsing = status === "checking"; + + return ( + + ); +} diff --git a/packages/web/src/hooks/useHealthStatus.ts b/packages/web/src/hooks/useHealthStatus.ts new file mode 100644 index 0000000..f7f9ceb --- /dev/null +++ b/packages/web/src/hooks/useHealthStatus.ts @@ -0,0 +1,20 @@ +import { useQuery } from "@tanstack/react-query"; +import { useInstances } from "@/hooks/useInstances"; +import { checkConnection } from "@/lib/config"; + +const POLL_INTERVAL_MS = 30_000; + +export function useHealthStatus() { + const { active } = useInstances(); + return useQuery({ + queryKey: ["health", active?.id, active?.baseUrl, active?.token], + queryFn: async () => { + if (!active) throw new Error("No active instance"); + return checkConnection(active.baseUrl, active.token || undefined); + }, + enabled: !!active, + refetchInterval: POLL_INTERVAL_MS, + refetchOnWindowFocus: true, + staleTime: 0, + }); +} diff --git a/packages/web/src/test/health-status.test.tsx b/packages/web/src/test/health-status.test.tsx new file mode 100644 index 0000000..fd116ef --- /dev/null +++ b/packages/web/src/test/health-status.test.tsx @@ -0,0 +1,42 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useHealthStatus } from "@/hooks/useHealthStatus"; +import { saveStore } from "@/lib/config"; + +const httpFetch = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/http", () => ({ httpFetch })); + +function wrap(qc: QueryClient) { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +describe("useHealthStatus", () => { + beforeEach(() => { + httpFetch.mockReset(); + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("is disabled with no active instance", () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) }); + expect(result.current.fetchStatus).toBe("idle"); + }); + + it("reports ok when the active instance responds 200", async () => { + saveStore({ + instances: [{ id: "i1", name: "Local", baseUrl: "http://localhost:8000", token: "" }], + activeId: "i1", + }); + httpFetch.mockResolvedValue(new Response("{}", { status: 200 })); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) }); + await waitFor(() => expect(result.current.data?.status).toBe("ok")); + }); +});