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
This commit is contained in:
Claude
2026-05-14 23:50:03 +00:00
parent f0717624eb
commit 38e76d33de
5 changed files with 169 additions and 6 deletions

View File

@@ -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<HTMLDivElement | null>(null);
@@ -87,8 +90,12 @@ export function Sidebar() {
title={mask(active.baseUrl)}
>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium truncate" style={{ color: "var(--text-2)" }}>
{active.name}
<p
className="text-xs font-medium truncate flex items-center gap-1.5"
style={{ color: "var(--text-2)" }}
>
<HealthDot status={health?.status} message={health?.message} />
<span className="truncate">{active.name}</span>
</p>
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}

View File

@@ -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 (
<div
className="rounded-2xl p-6 space-y-3"
@@ -116,6 +133,35 @@ function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps)
</Muted>
</div>
<AnimatePresence>
{localhostDetected && (
<motion.button
type="button"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
onClick={() => 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}`,
}}
>
<Sparkles
className="w-4 h-4 shrink-0"
style={{ color: COLOR.success }}
strokeWidth={1.5}
/>
<div className="min-w-0 flex-1">
<p className="text-xs font-medium" style={{ color: COLOR.success }}>
Detected Honcho at {LOCALHOST_PROBE_URL.replace(/^https?:\/\//, "")}
</p>
<Muted className="text-xs mt-0.5">Tap to connect to it</Muted>
</div>
</motion.button>
)}
</AnimatePresence>
<ConnectionTypeButton
icon={Cloud}
title="Honcho Cloud"

View File

@@ -0,0 +1,48 @@
import { motion } from "framer-motion";
import type { HealthStatus } from "@/lib/config";
import { COLOR } from "@/lib/constants";
interface HealthDotProps {
status: HealthStatus | undefined;
message?: string;
size?: number;
}
const COLORS: Record<HealthStatus, string> = {
ok: COLOR.success,
"auth-required": COLOR.warning,
unreachable: COLOR.destructive,
checking: COLOR.accentText,
};
const LABELS: Record<HealthStatus, string> = {
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 (
<motion.span
aria-label={`Connection status: ${label}`}
title={title}
animate={pulsing ? { opacity: [0.4, 1, 0.4] } : { opacity: 1 }}
transition={pulsing ? { duration: 1.2, repeat: Number.POSITIVE_INFINITY } : undefined}
style={{
display: "inline-block",
width: size,
height: size,
borderRadius: "50%",
background: color,
boxShadow: status === "ok" ? `0 0 6px ${color}80` : undefined,
flexShrink: 0,
}}
/>
);
}

View File

@@ -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,
});
}

View File

@@ -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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
}
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"));
});
});