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:
@@ -13,7 +13,9 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { HealthDot } from "@/components/shared/HealthDot";
|
||||||
import { useDemo } from "@/hooks/useDemo";
|
import { useDemo } from "@/hooks/useDemo";
|
||||||
|
import { useHealthStatus } from "@/hooks/useHealthStatus";
|
||||||
import { useInstances } from "@/hooks/useInstances";
|
import { useInstances } from "@/hooks/useInstances";
|
||||||
import { useTheme } from "@/hooks/useTheme";
|
import { useTheme } from "@/hooks/useTheme";
|
||||||
import { COLOR } from "@/lib/constants";
|
import { COLOR } from "@/lib/constants";
|
||||||
@@ -29,6 +31,7 @@ export function Sidebar() {
|
|||||||
const { instances, active, activate } = useInstances();
|
const { instances, active, activate } = useInstances();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const { demo, toggle: toggleDemo, mask } = useDemo();
|
const { demo, toggle: toggleDemo, mask } = useDemo();
|
||||||
|
const { data: health } = useHealthStatus();
|
||||||
const [switcherOpen, setSwitcherOpen] = useState(false);
|
const [switcherOpen, setSwitcherOpen] = useState(false);
|
||||||
const switcherRef = useRef<HTMLDivElement | null>(null);
|
const switcherRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -87,8 +90,12 @@ export function Sidebar() {
|
|||||||
title={mask(active.baseUrl)}
|
title={mask(active.baseUrl)}
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-xs font-medium truncate" style={{ color: "var(--text-2)" }}>
|
<p
|
||||||
{active.name}
|
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>
|
||||||
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
|
<p className="text-xs font-mono truncate" style={{ color: "var(--text-4)" }}>
|
||||||
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
|
{mask(active.baseUrl.replace(/^https?:\/\//, ""))}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Trash2 } from "lucide-react";
|
import { Check, ChevronRight, Cloud, Pencil, Plus, Server, Sparkles, Trash2 } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
|
import { type ConnectionPreset, SettingsForm } from "@/components/settings/SettingsForm";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Muted } from "@/components/ui/typography";
|
import { Muted } from "@/components/ui/typography";
|
||||||
import { useInstances } from "@/hooks/useInstances";
|
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";
|
import { COLOR } from "@/lib/constants";
|
||||||
|
|
||||||
|
const LOCALHOST_PROBE_URL = "http://localhost:8000";
|
||||||
|
|
||||||
type Mode =
|
type Mode =
|
||||||
| { kind: "list" }
|
| { kind: "list" }
|
||||||
| { kind: "choose-type" }
|
| { kind: "choose-type" }
|
||||||
@@ -99,6 +101,21 @@ interface ConnectionTypeChooserProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionTypeChooser({ onPick, onCancel }: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-2xl p-6 space-y-3"
|
className="rounded-2xl p-6 space-y-3"
|
||||||
@@ -116,6 +133,35 @@ function ConnectionTypeChooser({ onPick, onCancel }: ConnectionTypeChooserProps)
|
|||||||
</Muted>
|
</Muted>
|
||||||
</div>
|
</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
|
<ConnectionTypeButton
|
||||||
icon={Cloud}
|
icon={Cloud}
|
||||||
title="Honcho Cloud"
|
title="Honcho Cloud"
|
||||||
|
|||||||
48
packages/web/src/components/shared/HealthDot.tsx
Normal file
48
packages/web/src/components/shared/HealthDot.tsx
Normal 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
packages/web/src/hooks/useHealthStatus.ts
Normal file
20
packages/web/src/hooks/useHealthStatus.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
42
packages/web/src/test/health-status.test.tsx
Normal file
42
packages/web/src/test/health-status.test.tsx
Normal 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"));
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user