Skip to content

Commit 38e76d3

Browse files
committed
feat: add health indicator and localhost auto-detect
Surfaces live connection health for the active instance in the sidebar and probes localhost:8000 on the first-run choose-type screen so users running Honcho locally can connect in one tap. - useHealthStatus hook polls checkConnection every 30s via TanStack Query - HealthDot component renders a colored status dot with tooltip - choose-type screen silently probes http://localhost:8000 once; on success it surfaces a "Detected Honcho at localhost:8000 — tap to connect" banner that opens the self-hosted form
1 parent f071762 commit 38e76d3

5 files changed

Lines changed: 169 additions & 6 deletions

File tree

packages/web/src/components/layout/Sidebar.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ import {
1313
Sun,
1414
} from "lucide-react";
1515
import { useEffect, useRef, useState } from "react";
16+
import { HealthDot } from "@/components/shared/HealthDot";
1617
import { useDemo } from "@/hooks/useDemo";
18+
import { useHealthStatus } from "@/hooks/useHealthStatus";
1719
import { useInstances } from "@/hooks/useInstances";
1820
import { useTheme } from "@/hooks/useTheme";
1921
import { COLOR } from "@/lib/constants";
@@ -29,6 +31,7 @@ export function Sidebar() {
2931
const { instances, active, activate } = useInstances();
3032
const { theme, toggle } = useTheme();
3133
const { demo, toggle: toggleDemo, mask } = useDemo();
34+
const { data: health } = useHealthStatus();
3235
const [switcherOpen, setSwitcherOpen] = useState(false);
3336
const switcherRef = useRef<HTMLDivElement | null>(null);
3437

@@ -87,8 +90,12 @@ export function Sidebar() {
8790
title={mask(active.baseUrl)}
8891
>
8992
<div className="min-w-0 flex-1">
90-
<p className="text-xs font-medium truncate" style={{ color: "var(--text-2)" }}>
91-
{active.name}
93+
<p
94+
className="text-xs font-medium truncate flex items-center gap-1.5"
95+
style={{ color: "var(--text-2)" }}
96+
>
97+
<HealthDot status={health?.status} message={health?.message} />
98+
<span className="truncate">{active.name}</span>
9299
</p>
93100
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
94101
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}

packages/web/src/components/settings/InstancesManager.tsx

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
1-
import { motion } from "framer-motion";
2-
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Trash2 } from "lucide-react";
3-
import { useState } from "react";
1+
import { AnimatePresence, motion } from "framer-motion";
2+
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react";
3+
import { useEffect, useState } from "react";
44
import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
55
import { Button } from "@/components/ui/button";
66
import { Muted } from "@/components/ui/typography";
77
import { useInstances } from "@/hooks/useInstances";
8-
import { HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
8+
import { checkConnection, HONCHO_CLOUD_URL, type Instance, isCloudInstance } from "@/lib/config";
99
import { COLOR } from "@/lib/constants";
1010

11+
const LOCALHOST_PROBE_URL = "http://localhost:8000";
12+
1113
type Mode =
1214
| { kind: "list" }
1315
| { kind: "choose-type" }
@@ -99,6 +101,21 @@ interface ConnectionTypeChooserProps {
99101
}
100102

101103
function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps) {
104+
const [localhostDetected, setLocalhostDetected] = useState(false);
105+
106+
useEffect(() => {
107+
let cancelled = false;
108+
void checkConnection(LOCALHOST_PROBE_URL).then((result) => {
109+
if (cancelled) return;
110+
if (result.status === "ok" || result.status === "auth-required") {
111+
setLocalhostDetected(true);
112+
}
113+
});
114+
return () => {
115+
cancelled = true;
116+
};
117+
}, []);
118+
102119
return (
103120
<div
104121
className="rounded-2xl p-6 space-y-3"
@@ -116,6 +133,35 @@ function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps)
116133
</Muted>
117134
</div>
118135

136+
<AnimatePresence>
137+
{localhostDetected && (
138+
<motion.button
139+
type="button"
140+
initial={{ opacity: 0, height: 0 }}
141+
animate={{ opacity: 1, height: "auto" }}
142+
exit={{ opacity: 0, height: 0 }}
143+
onClick={() => onPick("self-hosted")}
144+
className="w-full overflow-hidden rounded-xl p-3 flex items-center gap-2.5 text-left"
145+
style={{
146+
background: COLOR.successDim,
147+
border: `1px solid ${COLOR.successBorder}`,
148+
}}
149+
>
150+
<Sparkles
151+
className="w-4 h-4 shrink-0"
152+
style={{ color: COLOR.success }}
153+
strokeWidth={1.5}
154+
/>
155+
<div className="min-w-0 flex-1">
156+
<p className="text-xs font-medium" style={{ color: COLOR.success }}>
157+
Detected Honcho at {LOCALHOST_PROBE_URL.replace(/^https?:\/\//, "")}
158+
</p>
159+
<Muted className="text-xs mt-0.5">Tap to connect to it</Muted>
160+
</div>
161+
</motion.button>
162+
)}
163+
</AnimatePresence>
164+
119165
<ConnectionTypeButton
120166
icon={Cloud}
121167
title="Honcho Cloud"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { motion } from "framer-motion";
2+
import type { HealthStatus } from "@/lib/config";
3+
import { COLOR } from "@/lib/constants";
4+
5+
interface HealthDotProps {
6+
status: HealthStatus | undefined;
7+
message?: string;
8+
size?: number;
9+
}
10+
11+
const COLORS: Record<HealthStatus, string> = {
12+
ok: COLOR.success,
13+
"auth-required": COLOR.warning,
14+
unreachable: COLOR.destructive,
15+
checking: COLOR.accentText,
16+
};
17+
18+
const LABELS: Record<HealthStatus, string> = {
19+
ok: "Connected",
20+
"auth-required": "Auth required",
21+
unreachable: "Unreachable",
22+
checking: "Checking...",
23+
};
24+
25+
export function HealthDot({ status, message, size = 8 }: HealthDotProps) {
26+
const color = status ? COLORS[status] : "var(--text-4)";
27+
const label = status ? LABELS[status] : "Unknown";
28+
const title = message ? `${label}${message}` : label;
29+
const pulsing = status === "checking";
30+
31+
return (
32+
<motion.span
33+
aria-label={`Connection status: ${label}`}
34+
title={title}
35+
animate={pulsing ? { opacity: [0.4, 1, 0.4] } : { opacity: 1 }}
36+
transition={pulsing ? { duration: 1.2, repeat: Number.POSITIVE_INFINITY } : undefined}
37+
style={{
38+
display: "inline-block",
39+
width: size,
40+
height: size,
41+
borderRadius: "50%",
42+
background: color,
43+
boxShadow: status === "ok" ? `0 0 6px ${color}80` : undefined,
44+
flexShrink: 0,
45+
}}
46+
/>
47+
);
48+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { useInstances } from "@/hooks/useInstances";
3+
import { checkConnection } from "@/lib/config";
4+
5+
const POLL_INTERVAL_MS = 30_000;
6+
7+
export function useHealthStatus() {
8+
const { active } = useInstances();
9+
return useQuery({
10+
queryKey: ["health", active?.id, active?.baseUrl, active?.token],
11+
queryFn: async () => {
12+
if (!active) throw new Error("No active instance");
13+
return checkConnection(active.baseUrl, active.token || undefined);
14+
},
15+
enabled: !!active,
16+
refetchInterval: POLL_INTERVAL_MS,
17+
refetchOnWindowFocus: true,
18+
staleTime: 0,
19+
});
20+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
2+
import { renderHook, waitFor } from "@testing-library/react";
3+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4+
import { useHealthStatus } from "@/hooks/useHealthStatus";
5+
import { saveStore } from "@/lib/config";
6+
7+
const httpFetch = vi.hoisted(() => vi.fn());
8+
vi.mock("@/lib/http", () => ({ httpFetch }));
9+
10+
function wrap(qc: QueryClient) {
11+
return ({ children }: { children: React.ReactNode }) => (
12+
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
13+
);
14+
}
15+
16+
describe("useHealthStatus", () => {
17+
beforeEach(() => {
18+
httpFetch.mockReset();
19+
localStorage.clear();
20+
});
21+
22+
afterEach(() => {
23+
localStorage.clear();
24+
});
25+
26+
it("is disabled with no active instance", () => {
27+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
28+
const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
29+
expect(result.current.fetchStatus).toBe("idle");
30+
});
31+
32+
it("reports ok when the active instance responds 200", async () => {
33+
saveStore({
34+
instances: [{ id: "i1", name: "Local", baseUrl: "http://localhost:8000", token: "" }],
35+
activeId: "i1",
36+
});
37+
httpFetch.mockResolvedValue(new Response("{}", { status: 200 }));
38+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
39+
const { result } = renderHook(() => useHealthStatus(), { wrapper: wrap(qc) });
40+
await waitFor(() => expect(result.current.data?.status).toBe("ok"));
41+
});
42+
});

0 commit comments

Comments
 (0)