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,
|
||||
} 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?:\/\//, ""))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
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