Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
drjones
2026-07-04 09:31:23 +00:00
commit 3678b199d0
154 changed files with 21714 additions and 0 deletions

60
web/src/App.test.tsx Normal file
View File

@@ -0,0 +1,60 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import App from "@/App";
import { renderWithProviders } from "@/test/test-utils";
import {
installMockWebSocket,
uninstallMockWebSocket,
} from "@/test/mockWebSocket";
const routes = [
{ path: "/login", heading: "AetherForge Command Deck", auth: false },
{ path: "/dashboard", heading: "Fleet Dashboard", auth: true },
{ path: "/forge", heading: "Forge Pipeline", auth: true },
{ path: "/calibrate", heading: "Calibrate", auth: true },
{ path: "/crucible", heading: "Crucible", auth: true },
{ path: "/seer", heading: "Seer", auth: true },
] as const;
describe("App routes", () => {
beforeEach(() => {
installMockWebSocket();
});
afterEach(() => {
uninstallMockWebSocket();
});
it.each(routes)("renders $path without crash", async ({ path, heading, auth }) => {
renderWithProviders(<App />, {
routerProps: { initialEntries: [path] },
authenticated: auth,
});
if (auth) {
await waitFor(() => {
expect(screen.getByRole("heading", { name: heading })).toBeInTheDocument();
});
} else {
expect(screen.getByText(heading)).toBeInTheDocument();
}
});
it("redirects unauthenticated dashboard access to login", () => {
renderWithProviders(<App />, {
routerProps: { initialEntries: ["/dashboard"] },
authenticated: false,
});
expect(screen.getByText("AetherForge Command Deck")).toBeInTheDocument();
});
it("redirects authenticated login to dashboard", async () => {
renderWithProviders(<App />, {
routerProps: { initialEntries: ["/login"] },
authenticated: true,
});
await waitFor(() => {
expect(screen.getByRole("heading", { name: "Fleet Dashboard" })).toBeInTheDocument();
});
});
});

40
web/src/App.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { CommandDeckLayout } from "@/components/layout/CommandDeckLayout";
import { ProtectedRoute } from "@/components/layout/ProtectedRoute";
import { LoginPage } from "@/pages/LoginPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { ForgePage } from "@/pages/ForgePage";
import { CalibratePage } from "@/pages/CalibratePage";
import { CruciblePage } from "@/pages/CruciblePage";
import { SeerPage } from "@/pages/SeerPage";
import { isAuthenticated } from "@/lib/auth";
export default function App() {
return (
<Routes>
<Route
path="/login"
element={
isAuthenticated() ? (
<Navigate to="/dashboard" replace />
) : (
<LoginPage />
)
}
/>
<Route element={<ProtectedRoute />}>
<Route element={<CommandDeckLayout />}>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/forge" element={<ForgePage />} />
<Route path="/calibrate" element={<CalibratePage />} />
<Route path="/crucible" element={<CruciblePage />} />
<Route path="/seer" element={<SeerPage />} />
</Route>
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
);
}

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { HostCard } from "./HostCard";
import type { FleetHost } from "@/types/fleet";
const onlineHost: FleetHost = {
id: "host-001",
hostname: "forge-node-alpha",
ip: "10.0.1.12",
arch: "linux/amd64",
hashrate: 18_200_000,
tier: 2,
tierName: "Bundled xmrig",
tierState: "active",
algo: "rx/0",
uptimeSec: 86400,
lastSeen: new Date().toISOString(),
clearance: 2,
online: true,
};
const offlineHost: FleetHost = {
...onlineHost,
id: "host-004",
hostname: "offline-staging",
online: false,
hashrate: 0,
tierState: "idle",
tierName: "—",
};
describe("HostCard", () => {
it("renders online host with hashrate and tier badge", () => {
render(<HostCard host={onlineHost} />);
expect(screen.getByText("forge-node-alpha")).toBeInTheDocument();
expect(screen.getByText("18.20 MH/s")).toBeInTheDocument();
expect(screen.getByText("ONLINE")).toBeInTheDocument();
expect(screen.getByText("ACTIVE")).toBeInTheDocument();
expect(screen.getByText("Bundled xmrig")).toBeInTheDocument();
});
it("renders offline host without hashrate", () => {
render(<HostCard host={offlineHost} />);
expect(screen.getByText("offline-staging")).toBeInTheDocument();
expect(screen.getByText("OFFLINE")).toBeInTheDocument();
expect(screen.getAllByText("—").length).toBeGreaterThan(0);
expect(screen.getByText("IDLE")).toBeInTheDocument();
});
it("shows tier and clearance metadata", () => {
render(<HostCard host={onlineHost} />);
expect(screen.getByText("T2")).toBeInTheDocument();
expect(screen.getByText("L2")).toBeInTheDocument();
expect(screen.getByText("rx/0")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,111 @@
import {
Cpu,
HardDrive,
Network,
Shield,
Zap,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { cn, formatHashrate, formatUptime } from "@/lib/utils";
import type { FleetHost, TierState } from "@/types/fleet";
function tierBadgeVariant(state: TierState) {
switch (state) {
case "active":
return "active" as const;
case "probing":
return "probing" as const;
case "failed":
return "failed" as const;
case "paused":
return "paused" as const;
default:
return "idle" as const;
}
}
interface HostCardProps {
host: FleetHost;
}
export function HostCard({ host }: HostCardProps) {
return (
<Card
className={cn(
"transition-all hover:border-deck-accent/30",
host.online && "shadow-glow",
!host.online && "opacity-60",
)}
>
<CardHeader className="pb-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="truncate font-mono text-base">
{host.hostname}
</CardTitle>
<CardDescription className="font-mono text-xs">
{host.id} · {host.ip}
</CardDescription>
</div>
<Badge variant={host.online ? "online" : "offline"}>
{host.online ? "ONLINE" : "OFFLINE"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-baseline gap-2">
<Zap
className={cn(
"h-5 w-5",
host.online ? "text-deck-accent" : "text-deck-muted",
)}
/>
<span className="font-mono text-2xl font-semibold text-deck-accent">
{host.online ? formatHashrate(host.hashrate) : "—"}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="flex items-center gap-1.5 text-deck-muted">
<Cpu className="h-3.5 w-3.5" />
<span className="truncate">{host.arch}</span>
</div>
<div className="flex items-center gap-1.5 text-deck-muted">
<HardDrive className="h-3.5 w-3.5" />
<span>{host.algo}</span>
</div>
<div className="flex items-center gap-1.5 text-deck-muted">
<Network className="h-3.5 w-3.5" />
<span>T{host.tier}</span>
</div>
<div className="flex items-center gap-1.5 text-deck-muted">
<Shield className="h-3.5 w-3.5" />
<span>L{host.clearance}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-2 border-t border-deck-border pt-3">
<Badge variant={tierBadgeVariant(host.tierState)}>
{host.tierState.toUpperCase()}
</Badge>
<span className="font-mono text-xs text-deck-muted">
{host.tierName}
</span>
{host.online && (
<span className="ml-auto font-mono text-xs text-deck-muted">
↑ {formatUptime(host.uptimeSec)}
</span>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { screen } from "@testing-library/react";
import { Routes, Route } from "react-router-dom";
import { CommandDeckLayout } from "./CommandDeckLayout";
import { renderWithProviders } from "@/test/test-utils";
import {
installMockWebSocket,
uninstallMockWebSocket,
} from "@/test/mockWebSocket";
describe("CommandDeckLayout", () => {
it("renders sidebar and outlet frame", () => {
installMockWebSocket();
renderWithProviders(
<Routes>
<Route element={<CommandDeckLayout />}>
<Route path="/dashboard" element={<div>Dashboard outlet</div>} />
</Route>
</Routes>,
{ routerProps: { initialEntries: ["/dashboard"] } },
);
expect(screen.getByText("AetherForge")).toBeInTheDocument();
expect(screen.getByRole("link", { name: /Dashboard/i })).toBeInTheDocument();
expect(screen.getByText("Dashboard outlet")).toBeInTheDocument();
uninstallMockWebSocket();
});
});

View File

@@ -0,0 +1,24 @@
import { Outlet } from "react-router-dom";
import { Sidebar } from "./Sidebar";
import { FleetWsProvider, useFleetWs } from "@/context/FleetWsContext";
function CommandDeckShell() {
const { connectionState } = useFleetWs();
return (
<div className="flex h-screen overflow-hidden">
<Sidebar wsState={connectionState} />
<main className="flex-1 overflow-auto deck-grid-bg">
<Outlet />
</main>
</div>
);
}
export function CommandDeckLayout() {
return (
<FleetWsProvider>
<CommandDeckShell />
</FleetWsProvider>
);
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { Routes, Route } from "react-router-dom";
import { ProtectedRoute } from "./ProtectedRoute";
import { renderWithProviders } from "@/test/test-utils";
function SecretPage() {
return <div>Fleet Dashboard</div>;
}
describe("ProtectedRoute", () => {
it("redirects unauthenticated users to login", () => {
renderWithProviders(
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<SecretPage />} />
</Route>
<Route path="/login" element={<div>Login Gate</div>} />
</Routes>,
{ routerProps: { initialEntries: ["/dashboard"] }, authenticated: false },
);
expect(screen.getByText("Login Gate")).toBeInTheDocument();
expect(screen.queryByText("Fleet Dashboard")).not.toBeInTheDocument();
});
it("allows authenticated users through", () => {
renderWithProviders(
<Routes>
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<SecretPage />} />
</Route>
<Route path="/login" element={<div>Login Gate</div>} />
</Routes>,
{ routerProps: { initialEntries: ["/dashboard"] }, authenticated: true },
);
expect(screen.getByText("Fleet Dashboard")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,12 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { isAuthenticated } from "@/lib/auth";
export function ProtectedRoute() {
const location = useLocation();
if (!isAuthenticated()) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <Outlet />;
}

View File

@@ -0,0 +1,104 @@
import { NavLink } from "react-router-dom";
import {
Activity,
Anvil,
Eye,
Gauge,
Hammer,
LogOut,
Terminal,
Wifi,
WifiOff,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { clearStoredAuth } from "@/lib/auth";
import { useNavigate } from "react-router-dom";
import type { WsConnectionState } from "@/hooks/useFleetWebSocket";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
const navItems = [
{ to: "/dashboard", label: "Dashboard", icon: Gauge },
{ to: "/forge", label: "Forge", icon: Hammer },
{ to: "/calibrate", label: "Calibrate", icon: Activity },
{ to: "/crucible", label: "Crucible", icon: Terminal },
{ to: "/seer", label: "Seer", icon: Eye },
];
interface SidebarProps {
wsState?: WsConnectionState;
}
export function Sidebar({ wsState = "disconnected" }: SidebarProps) {
const navigate = useNavigate();
const handleLogout = () => {
clearStoredAuth();
navigate("/login");
};
const wsBadge =
wsState === "connected"
? { variant: "online" as const, label: "WS LIVE", icon: Wifi }
: wsState === "connecting"
? { variant: "probing" as const, label: "WS CONNECTING", icon: Wifi }
: { variant: "offline" as const, label: "WS OFFLINE", icon: WifiOff };
const WsIcon = wsBadge.icon;
return (
<aside className="flex h-full w-56 flex-col border-r border-deck-border bg-deck-surface/80 backdrop-blur-sm">
<div className="border-b border-deck-border p-4">
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md border border-deck-accent/40 bg-deck-accent/10">
<Anvil className="h-4 w-4 text-deck-accent" />
</div>
<div>
<p className="text-sm font-semibold tracking-wide text-deck-text">
AetherForge
</p>
<p className="font-mono text-[10px] uppercase text-deck-muted">
Command Deck
</p>
</div>
</div>
</div>
<nav className="flex-1 space-y-1 p-3">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-deck-accent/10 text-deck-accent border border-deck-accent/30"
: "text-deck-muted hover:bg-deck-panel hover:text-deck-text",
)
}
>
<Icon className="h-4 w-4" />
{label}
</NavLink>
))}
</nav>
<div className="space-y-3 border-t border-deck-border p-4">
<Badge variant={wsBadge.variant} className="w-full justify-center gap-1.5">
<WsIcon className="h-3 w-3" />
{wsBadge.label}
</Badge>
<Button
variant="ghost"
size="sm"
className="w-full justify-start text-deck-muted"
onClick={handleLogout}
>
<LogOut className="h-4 w-4" />
Sign out
</Button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import { Badge } from "./badge";
describe("Badge tier variants", () => {
it.each([
["active", "ACTIVE"],
["probing", "PROBING"],
["failed", "FAILED"],
["idle", "IDLE"],
["paused", "PAUSED"],
["online", "ONLINE"],
["offline", "OFFLINE"],
] as const)("renders %s variant", (variant, label) => {
render(<Badge variant={variant}>{label}</Badge>);
expect(screen.getByText(label)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-mono font-medium transition-colors",
{
variants: {
variant: {
default:
"border-deck-accent/40 bg-deck-accent/10 text-deck-accent",
secondary:
"border-deck-border bg-deck-panel text-deck-muted",
active:
"border-deck-accent/60 bg-deck-accent/20 text-deck-accent shadow-glow",
probing:
"border-deck-amber/60 bg-deck-amber/10 text-deck-amber animate-pulse-slow",
failed:
"border-deck-danger/60 bg-deck-danger/10 text-deck-danger",
idle:
"border-deck-border bg-deck-surface text-deck-muted",
paused:
"border-deck-cyan/40 bg-deck-cyan/10 text-deck-cyan",
online: "border-deck-accent/40 bg-deck-accent/10 text-deck-accent",
offline: "border-deck-border bg-deck-surface text-deck-muted",
warning:
"border-deck-amber/60 bg-deck-amber/10 text-deck-amber",
},
},
defaultVariants: {
variant: "default",
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,49 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-deck-accent focus-visible:ring-offset-2 focus-visible:ring-offset-deck-bg disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-deck-accent text-deck-bg hover:bg-deck-accent/90 shadow-glow",
secondary:
"bg-deck-panel text-deck-text border border-deck-border hover:bg-deck-surface",
ghost: "hover:bg-deck-panel hover:text-deck-accent",
destructive:
"bg-deck-danger/20 text-deck-danger border border-deck-danger/40 hover:bg-deck-danger/30",
outline:
"border border-deck-border bg-transparent hover:bg-deck-panel hover:text-deck-accent",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-deck-border bg-deck-panel text-deck-text shadow-sm",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-4", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"font-semibold leading-none tracking-tight text-deck-text",
className,
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-deck-muted", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
export { Card, CardHeader, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,20 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<
HTMLInputElement,
React.InputHTMLAttributes<HTMLInputElement>
>(({ className, type, ...props }, ref) => (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-deck-border bg-deck-surface px-3 py-2 font-mono text-sm text-deck-text placeholder:text-deck-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-deck-accent focus-visible:ring-offset-2 focus-visible:ring-offset-deck-bg disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
));
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
HTMLLabelElement,
React.LabelHTMLAttributes<HTMLLabelElement>
>(({ className, ...props }, ref) => (
<label
ref={ref}
className={cn(
"text-xs font-medium uppercase tracking-wider text-deck-muted",
className,
)}
{...props}
/>
));
Label.displayName = "Label";
export { Label };

View File

@@ -0,0 +1,31 @@
import {
createContext,
useContext,
type ReactNode,
} from "react";
import { useFleetWebSocket } from "@/hooks/useFleetWebSocket";
import type { WsFleetMessage } from "@/types/fleet";
import type { WsConnectionState } from "@/hooks/useFleetWebSocket";
interface FleetWsContextValue {
connectionState: WsConnectionState;
lastMessage: WsFleetMessage | null;
}
const FleetWsContext = createContext<FleetWsContextValue>({
connectionState: "disconnected",
lastMessage: null,
});
export function FleetWsProvider({ children }: { children: ReactNode }) {
const { connectionState, lastMessage } = useFleetWebSocket({ enabled: true });
return (
<FleetWsContext.Provider value={{ connectionState, lastMessage }}>
{children}
</FleetWsContext.Provider>
);
}
export function useFleetWs() {
return useContext(FleetWsContext);
}

View File

@@ -0,0 +1,67 @@
import { useCallback, useEffect, useState } from "react";
import { authFetch } from "@/lib/auth";
import { useFleetWs } from "@/context/FleetWsContext";
import { MOCK_FLEET } from "@/types/fleet";
import type { FleetHost, FleetResponse } from "@/types/fleet";
export function useFleetData() {
const [fleet, setFleet] = useState<FleetResponse>(MOCK_FLEET);
const [loading, setLoading] = useState(true);
const [source, setSource] = useState<"mock" | "api">("mock");
const fetchFleet = useCallback(async () => {
setLoading(true);
try {
const res = await authFetch("/api/v1/fleet");
if (res.ok) {
const data = (await res.json()) as FleetResponse;
setFleet(data);
setSource("api");
return;
}
} catch {
/* backend unavailable — keep mock */
}
setFleet(MOCK_FLEET);
setSource("mock");
}, []);
const { connectionState, lastMessage } = useFleetWs();
useEffect(() => {
if (lastMessage?.type !== "host_update" || !lastMessage.host?.id) return;
const update = lastMessage.host;
setFleet((prev) => ({
...prev,
hosts: prev.hosts.map((h) =>
h.id === update.id ? { ...h, ...update } : h,
),
totalHashrate: prev.hosts.reduce(
(sum, h) =>
sum + (h.id === update.id ? (update.hashrate ?? h.hashrate) : h.hashrate),
0,
),
}));
setSource("api");
}, [lastMessage]);
useEffect(() => {
void fetchFleet().finally(() => setLoading(false));
const interval = setInterval(() => void fetchFleet(), 30_000);
return () => clearInterval(interval);
}, [fetchFleet]);
const onlineHosts = fleet.hosts.filter((h) => h.online);
return {
fleet,
hosts: fleet.hosts,
onlineHosts,
loading,
source,
connectionState,
refetch: fetchFleet,
};
}
export type { FleetHost, FleetResponse };

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { useFleetWebSocket } from "./useFleetWebSocket";
import { setStoredAuth } from "@/lib/auth";
import {
installMockWebSocket,
MockWebSocket,
uninstallMockWebSocket,
} from "@/test/mockWebSocket";
describe("useFleetWebSocket", () => {
beforeEach(() => {
sessionStorage.clear();
setStoredAuth({ username: "operator", password: "test-pass" });
installMockWebSocket();
});
afterEach(() => {
uninstallMockWebSocket();
});
it("connects and receives heartbeat updates", async () => {
const onMessage = vi.fn();
const { result } = renderHook(() =>
useFleetWebSocket({ enabled: true, onMessage, reconnectIntervalMs: 60_000 }),
);
await waitFor(() => {
expect(result.current.connectionState).toBe("connected");
});
const ws = MockWebSocket.latest();
expect(ws?.url).toContain("ticket=test-ws-ticket");
const heartbeat = {
type: "heartbeat" as const,
timestamp: new Date().toISOString(),
payload: { online: 2 },
};
ws?.simulateMessage(heartbeat);
await waitFor(() => {
expect(result.current.lastMessage).toEqual(heartbeat);
});
expect(onMessage).toHaveBeenCalledWith(heartbeat);
});
it("stays disconnected when unauthenticated", async () => {
sessionStorage.clear();
const { result } = renderHook(() => useFleetWebSocket({ enabled: true }));
await waitFor(() => {
expect(result.current.connectionState).toBe("disconnected");
});
expect(MockWebSocket.instances).toHaveLength(0);
});
});

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
buildWsUrl,
fetchWsTicket,
getBasicAuthHeader,
isAuthenticated,
} from "@/lib/auth";
import type { WsFleetMessage } from "@/types/fleet";
export type WsConnectionState =
| "disconnected"
| "connecting"
| "connected"
| "error";
interface UseFleetWebSocketOptions {
enabled?: boolean;
onMessage?: (message: WsFleetMessage) => void;
reconnectIntervalMs?: number;
}
export function useFleetWebSocket(options: UseFleetWebSocketOptions = {}) {
const {
enabled = true,
onMessage,
reconnectIntervalMs = 5000,
} = options;
const [connectionState, setConnectionState] =
useState<WsConnectionState>("disconnected");
const [lastMessage, setLastMessage] = useState<WsFleetMessage | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimer = useRef<ReturnType<typeof setTimeout>>();
const onMessageRef = useRef(onMessage);
onMessageRef.current = onMessage;
const disconnect = useCallback(() => {
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
setConnectionState("disconnected");
}, []);
const connect = useCallback(async () => {
if (!enabled || !isAuthenticated()) {
disconnect();
return;
}
disconnect();
setConnectionState("connecting");
let ticket: string | null = null;
if (getBasicAuthHeader()) {
ticket = await fetchWsTicket();
}
const url = buildWsUrl(ticket);
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setConnectionState("connected");
ws.onmessage = (event) => {
try {
const parsed = JSON.parse(event.data as string) as WsFleetMessage;
setLastMessage(parsed);
onMessageRef.current?.(parsed);
} catch {
/* ignore malformed frames */
}
};
ws.onerror = () => setConnectionState("error");
ws.onclose = () => {
wsRef.current = null;
setConnectionState("disconnected");
if (enabled && isAuthenticated()) {
reconnectTimer.current = setTimeout(() => {
void connect();
}, reconnectIntervalMs);
}
};
}, [enabled, disconnect, reconnectIntervalMs]);
useEffect(() => {
if (enabled && isAuthenticated()) {
void connect();
} else {
disconnect();
}
return disconnect;
}, [enabled, connect, disconnect]);
const send = useCallback((payload: unknown) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(payload));
}
}, []);
return {
connectionState,
lastMessage,
connect,
disconnect,
send,
};
}

42
web/src/index.css Normal file
View File

@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
* {
@apply border-deck-border;
}
body {
@apply min-h-screen bg-deck-bg text-deck-text;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(0, 230, 118, 0.08), transparent),
linear-gradient(180deg, #0a0e0f 0%, #0d1214 100%);
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
@apply bg-deck-surface;
}
::-webkit-scrollbar-thumb {
@apply rounded bg-deck-border;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-deck-muted;
}
}
@layer utilities {
.deck-grid-bg {
background-image:
linear-gradient(rgba(36, 48, 56, 0.4) 1px, transparent 1px),
linear-gradient(90deg, rgba(36, 48, 56, 0.4) 1px, transparent 1px);
background-size: 24px 24px;
}
}

43
web/src/lib/auth.test.ts Normal file
View File

@@ -0,0 +1,43 @@
import { describe, expect, it, beforeEach } from "vitest";
import {
clearStoredAuth,
getBasicAuthHeader,
getStoredAuth,
isAuthenticated,
setStoredAuth,
} from "./auth";
describe("auth session", () => {
beforeEach(() => {
sessionStorage.clear();
});
it("stores and retrieves credentials from sessionStorage", () => {
setStoredAuth({ username: "operator", password: "secret" });
expect(getStoredAuth()).toEqual({ username: "operator", password: "secret" });
expect(isAuthenticated()).toBe(true);
});
it("returns null for missing or invalid auth", () => {
expect(getStoredAuth()).toBeNull();
expect(isAuthenticated()).toBe(false);
sessionStorage.setItem("aetherforge_auth", "not-json");
expect(getStoredAuth()).toBeNull();
});
it("clears stored auth on logout", () => {
setStoredAuth({ username: "operator", password: "secret" });
clearStoredAuth();
expect(isAuthenticated()).toBe(false);
});
it("builds Basic auth header from stored credentials", () => {
setStoredAuth({ username: "operator", password: "secret" });
const header = getBasicAuthHeader();
expect(header).toBe(`Basic ${btoa("operator:secret")}`);
});
it("returns null auth header when unauthenticated", () => {
expect(getBasicAuthHeader()).toBeNull();
});
});

66
web/src/lib/auth.ts Normal file
View File

@@ -0,0 +1,66 @@
const AUTH_KEY = "aetherforge_auth";
export interface AuthCredentials {
username: string;
password: string;
}
export function getStoredAuth(): AuthCredentials | null {
const raw = sessionStorage.getItem(AUTH_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as AuthCredentials;
if (parsed.username && parsed.password) return parsed;
} catch {
sessionStorage.removeItem(AUTH_KEY);
}
return null;
}
export function setStoredAuth(creds: AuthCredentials): void {
sessionStorage.setItem(AUTH_KEY, JSON.stringify(creds));
}
export function clearStoredAuth(): void {
sessionStorage.removeItem(AUTH_KEY);
}
export function isAuthenticated(): boolean {
return getStoredAuth() !== null;
}
export function getBasicAuthHeader(): string | null {
const creds = getStoredAuth();
if (!creds) return null;
const encoded = btoa(`${creds.username}:${creds.password}`);
return `Basic ${encoded}`;
}
export async function authFetch(
input: RequestInfo | URL,
init: RequestInit = {},
): Promise<Response> {
const auth = getBasicAuthHeader();
const headers = new Headers(init.headers);
if (auth) headers.set("Authorization", auth);
return fetch(input, { ...init, headers });
}
export async function fetchWsTicket(): Promise<string | null> {
try {
const res = await authFetch("/api/v1/ws/ticket", { method: "POST" });
if (!res.ok) return null;
const data = (await res.json()) as { ticket?: string };
return data.ticket ?? null;
} catch {
return null;
}
}
export function buildWsUrl(ticket: string | null): string {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const host = window.location.host;
const base = `${protocol}//${host}/api/v1/ws/fleet`;
if (ticket) return `${base}?ticket=${encodeURIComponent(ticket)}`;
return base;
}

34
web/src/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { formatHashrate, formatUptime } from "./utils";
describe("formatHashrate", () => {
it("formats H/s for small values", () => {
expect(formatHashrate(500)).toBe("500 H/s");
});
it("formats KH/s", () => {
expect(formatHashrate(12_500)).toBe("12.50 KH/s");
});
it("formats MH/s", () => {
expect(formatHashrate(18_200_000)).toBe("18.20 MH/s");
});
it("formats GH/s", () => {
expect(formatHashrate(2_500_000_000)).toBe("2.50 GH/s");
});
it("formats TH/s", () => {
expect(formatHashrate(1_200_000_000_000)).toBe("1.20 TH/s");
});
});
describe("formatUptime", () => {
it("formats minutes only", () => {
expect(formatUptime(900)).toBe("15m");
});
it("formats hours and minutes", () => {
expect(formatUptime(86400)).toBe("24h 0m");
});
});

21
web/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,21 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatHashrate(hs: number): string {
if (hs >= 1e12) return `${(hs / 1e12).toFixed(2)} TH/s`;
if (hs >= 1e9) return `${(hs / 1e9).toFixed(2)} GH/s`;
if (hs >= 1e6) return `${(hs / 1e6).toFixed(2)} MH/s`;
if (hs >= 1e3) return `${(hs / 1e3).toFixed(2)} KH/s`;
return `${hs.toFixed(0)} H/s`;
}
export function formatUptime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

13
web/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CalibratePage } from "./CalibratePage";
import { renderWithProviders } from "@/test/test-utils";
describe("CalibratePage", () => {
it("renders tier chain and wallet policy", async () => {
renderWithProviders(<CalibratePage />);
await waitFor(() => {
expect(screen.getByText("T1 · OCI podman")).toBeInTheDocument();
});
expect(screen.getByRole("heading", { name: "Calibrate" })).toBeInTheDocument();
expect(screen.getAllByText("ENABLED").length).toBeGreaterThan(0);
expect(screen.getByLabelText("XMR wallet")).toBeInTheDocument();
});
it("submits mining profile to server", async () => {
const user = userEvent.setup();
renderWithProviders(<CalibratePage />);
await waitFor(() => {
expect(screen.getByRole("button", { name: /Save Policy/i })).toBeEnabled();
});
await user.click(screen.getByRole("button", { name: /Save Policy/i }));
await waitFor(() => {
expect(screen.getByText("Policy saved to server.")).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,196 @@
import { useCallback, useEffect, useState } from "react";
import { Activity, ChevronDown, ChevronUp, Layers, Loader2, Save, Wallet } from "lucide-react";
import { authFetch } from "@/lib/auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface TierEntry {
tier: number;
name: string;
enabled: boolean;
}
interface MiningProfile {
wallet_address: string;
tiers: TierEntry[];
}
export function CalibratePage() {
const [profile, setProfile] = useState<MiningProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await authFetch("/api/v1/policy/mining-profile");
if (res.ok) {
setProfile((await res.json()) as MiningProfile);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const moveTier = (index: number, dir: -1 | 1) => {
if (!profile) return;
const next = index + dir;
if (next < 0 || next >= profile.tiers.length) return;
const tiers = [...profile.tiers];
[tiers[index], tiers[next]] = [tiers[next], tiers[index]];
setProfile({ ...profile, tiers: tiers.map((t, i) => ({ ...t, tier: i + 1 })) });
setSaved(false);
};
const toggleTier = (index: number) => {
if (!profile) return;
const tiers = profile.tiers.map((t, i) =>
i === index ? { ...t, enabled: !t.enabled } : t,
);
setProfile({ ...profile, tiers });
setSaved(false);
};
const save = async () => {
if (!profile) return;
setSaving(true);
try {
await authFetch("/api/v1/policy/mining-profile", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(profile),
});
await authFetch("/api/v1/policy/wallet", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
default_wallet: profile.wallet_address,
currency: "XMR",
}),
});
setSaved(true);
} finally {
setSaving(false);
}
};
if (loading || !profile) {
return (
<div className="p-6 text-sm text-deck-muted flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" /> Loading policy…
</div>
);
}
return (
<div className="p-6 space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Calibrate</h1>
<p className="text-sm text-deck-muted">
Mining policy — tier order, wallet pin, earn-before-burn gates
</p>
</div>
<Button onClick={() => void save()} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
Save Policy
</Button>
</header>
{saved && (
<p className="text-sm text-deck-accent">Policy saved to server.</p>
)}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Layers className="h-5 w-5 text-deck-accent" />
<CardTitle className="text-base">Tier Chain</CardTitle>
</div>
<CardDescription>Reorder with arrows — pushed to agents with policy_from_server</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{profile.tiers.map((t, i) => (
<div
key={t.tier}
className="flex items-center justify-between rounded-md border border-deck-border bg-deck-surface px-3 py-2"
>
<span className="font-mono text-sm">
T{t.tier} · {t.name}
</span>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => moveTier(i, -1)}>
<ChevronUp className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => moveTier(i, 1)}>
<ChevronDown className="h-4 w-4" />
</Button>
<Badge
variant={t.enabled ? "active" : "idle"}
className="cursor-pointer"
onClick={() => toggleTier(i)}
>
{t.enabled ? "ENABLED" : "DISABLED"}
</Badge>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Wallet className="h-5 w-5 text-deck-amber" />
<CardTitle className="text-base">Wallet Policy</CardTitle>
</div>
<CardDescription>Pinned destination address (server-push)</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<Label htmlFor="wallet">XMR wallet</Label>
<Input
id="wallet"
className="font-mono text-xs"
placeholder="48edfHu7V9…"
value={profile.wallet_address}
onChange={(e) => {
setProfile({ ...profile, wallet_address: e.target.value });
setSaved(false);
}}
/>
</div>
</CardContent>
</Card>
</div>
<Card className="border-dashed">
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-deck-cyan" />
<CardTitle className="text-base">Per-host override</CardTitle>
</div>
<CardDescription>
Push profile to a host via{" "}
<code className="font-mono text-deck-cyan">POST /api/v1/fleet/&#123;id&#125;/mining-profile</code>
</CardDescription>
</CardHeader>
</Card>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CruciblePage } from "./CruciblePage";
import { renderWithProviders } from "@/test/test-utils";
describe("CruciblePage", () => {
it("renders batch terminal UI", async () => {
renderWithProviders(<CruciblePage />);
expect(screen.getByRole("heading", { name: "Crucible" })).toBeInTheDocument();
expect(screen.getByText("Batch command terminal")).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText("OPERATOR L4")).toBeInTheDocument();
});
});
it("sends command via dispatch form", async () => {
const user = userEvent.setup();
renderWithProviders(<CruciblePage />);
const input = screen.getByPlaceholderText("status");
await user.clear(input);
await user.type(input, "health-check");
await user.click(screen.getByRole("button", { name: "Run" }));
await waitFor(() => {
expect(screen.getByText("DISPATCHED")).toBeInTheDocument();
});
expect(screen.getByText("forge-node-alpha")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,174 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Send, Terminal } from "lucide-react";
import { authFetch } from "@/lib/auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface BatchResult {
host_id: string;
hostname?: string;
status: string;
message?: string;
command_id?: string;
}
interface BatchJob {
id: string;
command: string;
status: string;
results: BatchResult[];
}
export function CruciblePage() {
const [command, setCommand] = useState("status");
const [job, setJob] = useState<BatchJob | null>(null);
const [history, setHistory] = useState<BatchJob[]>([]);
const [running, setRunning] = useState(false);
const [clearance, setClearance] = useState<number | null>(null);
const terminalRef = useRef<HTMLDivElement>(null);
const loadHistory = useCallback(async () => {
const res = await authFetch("/api/v1/crucible/history");
if (res.ok) {
const data = (await res.json()) as { jobs: BatchJob[] };
setHistory(data.jobs ?? []);
}
}, []);
useEffect(() => {
void loadHistory();
void authFetch("/api/v1/operator/me").then(async (res) => {
if (res.ok) {
const data = (await res.json()) as { clearance_level: number };
setClearance(data.clearance_level);
}
});
}, [loadHistory]);
useEffect(() => {
terminalRef.current?.scrollTo({ top: terminalRef.current.scrollHeight });
}, [job, history]);
const dispatch = async (all: boolean) => {
setRunning(true);
try {
const res = await authFetch("/api/v1/crucible/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command, all }),
});
if (res.ok) {
const data = (await res.json()) as BatchJob;
setJob(data);
await loadHistory();
} else if (res.status === 403) {
setJob({
id: "denied",
command,
status: "forbidden",
results: [{ host_id: "—", status: "denied", message: "Requires clearance L4" }],
});
}
} finally {
setRunning(false);
}
};
const lines = job?.results ?? history[0]?.results ?? [];
return (
<div className="p-6 space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Crucible</h1>
<p className="text-sm text-deck-muted">
Batch terminal — dispatch commands across fleet hosts (L4)
</p>
</div>
{clearance !== null && (
<Badge variant={clearance >= 4 ? "online" : "failed"}>
OPERATOR L{clearance}
</Badge>
)}
</header>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-deck-accent" />
<CardTitle className="text-base">Batch command terminal</CardTitle>
</div>
<CardDescription>
Maps to pause · resume · reboot · screenshot · shell · status
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
className="font-mono"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="status"
onKeyDown={(e) => e.key === "Enter" && void dispatch(true)}
/>
<Button onClick={() => void dispatch(false)} disabled={running}>
{running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
Run
</Button>
<Button variant="outline" onClick={() => void dispatch(true)} disabled={running}>
All online
</Button>
</div>
<div
ref={terminalRef}
className="max-h-[420px] overflow-y-auto rounded-lg border border-deck-border bg-deck-bg p-4 font-mono text-sm"
>
<p className="text-deck-muted mb-3">
$ crucible dispatch --cmd {command || "status"}
</p>
{lines.length === 0 && (
<p className="text-deck-muted text-xs">No output yet — run a batch command.</p>
)}
{lines.map((line, i) => (
<div key={i} className="flex flex-wrap items-center gap-2 text-xs py-1 border-b border-deck-border/40">
<span className="text-deck-cyan">{line.hostname ?? line.host_id}</span>
<span className="text-deck-text">{command}</span>
<Badge
variant={
line.status === "dispatched" || line.status === "ok"
? "active"
: line.status === "denied"
? "failed"
: "probing"
}
className="ml-auto"
>
{line.status.toUpperCase()}
</Badge>
</div>
))}
{running && <p className="mt-3 text-deck-accent animate-pulse">▌ dispatching…</p>}
{!running && lines.length > 0 && (
<p className="mt-3 text-deck-accent">▌</p>
)}
</div>
{job && (
<p className="font-mono text-[10px] text-deck-muted">
job {job.id} · {job.status}
</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { DashboardPage } from "./DashboardPage";
import { FleetWsProvider } from "@/context/FleetWsContext";
import { renderWithProviders } from "@/test/test-utils";
import { MOCK_API_FLEET } from "@/test/mocks/fleet";
import {
installMockWebSocket,
MockWebSocket,
uninstallMockWebSocket,
} from "@/test/mockWebSocket";
function renderDashboard() {
return renderWithProviders(
<FleetWsProvider>
<DashboardPage />
</FleetWsProvider>,
{ routerProps: { initialEntries: ["/dashboard"] } },
);
}
describe("DashboardPage", () => {
it("renders host cards from mock fleet API", async () => {
installMockWebSocket();
renderDashboard();
await waitFor(() => {
expect(screen.getByText("forge-node-alpha")).toBeInTheDocument();
});
expect(screen.getByText("mesh-worker-beta")).toBeInTheDocument();
expect(screen.getByText("Fleet Dashboard")).toBeInTheDocument();
expect(screen.getByText("55.00 MH/s")).toBeInTheDocument();
expect(screen.getByText(`2 / ${MOCK_API_FLEET.hosts.length}`)).toBeInTheDocument();
uninstallMockWebSocket();
});
it("shows API LIVE badge when fleet fetch succeeds", async () => {
installMockWebSocket();
renderDashboard();
await waitFor(() => {
expect(screen.getByText("API LIVE")).toBeInTheDocument();
});
uninstallMockWebSocket();
});
it("connects websocket and shows connection state", async () => {
installMockWebSocket();
renderDashboard();
await waitFor(() => {
expect(screen.getByText("WS CONNECTED")).toBeInTheDocument();
});
expect(MockWebSocket.latest()?.url).toContain("/api/v1/ws/fleet");
uninstallMockWebSocket();
});
});

View File

@@ -0,0 +1,95 @@
import type { ComponentType } from "react";
import { RefreshCw, Server, Zap } from "lucide-react";
import { HostCard } from "@/components/dashboard/HostCard";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { formatHashrate } from "@/lib/utils";
import { useFleetData } from "@/hooks/useFleetData";
export function DashboardPage() {
const { fleet, hosts, loading, source, connectionState, refetch } =
useFleetData();
return (
<div className="p-6 space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Fleet Dashboard</h1>
<p className="text-sm text-deck-muted">
Live host telemetry · tier state · aggregate hashrate
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={source === "api" ? "online" : "secondary"}>
{source === "api" ? "API LIVE" : "MOCK DATA"}
</Badge>
<Badge variant={connectionState === "connected" ? "online" : "offline"}>
WS {connectionState.toUpperCase()}
</Badge>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
</header>
<div className="grid gap-4 sm:grid-cols-3">
<StatPanel
icon={Zap}
label="Total Hashrate"
value={formatHashrate(fleet.totalHashrate)}
accent
/>
<StatPanel
icon={Server}
label="Online Hosts"
value={`${fleet.onlineCount} / ${hosts.length}`}
/>
<StatPanel
icon={RefreshCw}
label="Status"
value={loading ? "Syncing…" : "Ready"}
/>
</div>
<section>
<h2 className="mb-4 text-sm font-medium uppercase tracking-wider text-deck-muted">
Fleet Hosts
</h2>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{hosts.map((host) => (
<HostCard key={host.id} host={host} />
))}
</div>
</section>
</div>
);
}
function StatPanel({
icon: Icon,
label,
value,
accent,
}: {
icon: ComponentType<{ className?: string }>;
label: string;
value: string;
accent?: boolean;
}) {
return (
<div className="rounded-lg border border-deck-border bg-deck-panel p-4">
<div className="flex items-center gap-2 text-deck-muted">
<Icon className="h-4 w-4" />
<span className="text-xs uppercase tracking-wider">{label}</span>
</div>
<p
className={`mt-2 font-mono text-2xl font-semibold ${
accent ? "text-deck-accent" : "text-deck-text"
}`}
>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ForgePage } from "./ForgePage";
import { renderWithProviders } from "@/test/test-utils";
describe("ForgePage", () => {
it("renders forge & deploy page without crash", async () => {
renderWithProviders(<ForgePage />);
expect(
screen.getByRole("heading", { name: /Forge & Deploy/i }),
).toBeInTheDocument();
// Dropper panel loads
await waitFor(() => {
expect(screen.getByText(/Install command/i)).toBeInTheDocument();
});
// Build list loads
await waitFor(() => {
expect(screen.getByText(/linux\/amd64/i)).toBeInTheDocument();
});
expect(screen.getAllByText("SIGNED").length).toBeGreaterThan(0);
expect(screen.getByText("PUBLIC")).toBeInTheDocument();
});
it("shows the dropper one-liner", async () => {
renderWithProviders(<ForgePage />);
await waitFor(() => {
expect(
screen.getByText(/FORGE_MESH_FLEET_SECRET/),
).toBeInTheDocument();
});
});
it("can trigger a build", async () => {
const user = userEvent.setup();
renderWithProviders(<ForgePage />);
// Wait for page to settle
await waitFor(() => {
expect(screen.getByText(/linux\/amd64/i)).toBeInTheDocument();
});
await user.click(
screen.getByRole("button", { name: /Build & Publish/i }),
);
await waitFor(() => {
expect(
screen.getByText(/Build complete — artifacts signed and published/i),
).toBeInTheDocument();
});
});
});

324
web/src/pages/ForgePage.tsx Normal file
View File

@@ -0,0 +1,324 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
CheckCircle2,
ClipboardCopy,
Hammer,
Link2,
Loader2,
Package,
Play,
ShieldCheck,
TriangleAlert,
} from "lucide-react";
import { authFetch } from "@/lib/auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface BuildRow {
id: string;
os: string;
arch: string;
version: string;
checksum: string;
signature?: string;
public: boolean;
created_at: string;
}
interface DropperInfo {
dropper_url: string;
install_url: string;
one_liner: string;
fleet_secret: string;
public_key: string;
has_build: boolean;
version: string;
}
export function ForgePage() {
const [builds, setBuilds] = useState<BuildRow[]>([]);
const [dropper, setDropper] = useState<DropperInfo | null>(null);
const [loadingBuilds, setLoadingBuilds] = useState(true);
const [triggering, setTriggering] = useState(false);
const [buildMessage, setBuildMessage] = useState<{
text: string;
ok: boolean;
} | null>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadBuilds = useCallback(async () => {
setLoadingBuilds(true);
try {
const res = await authFetch("/api/v1/forge/builds");
if (res.ok) {
const data = (await res.json()) as { builds: BuildRow[] };
setBuilds(data.builds ?? []);
}
} finally {
setLoadingBuilds(false);
}
}, []);
const loadDropper = useCallback(async () => {
try {
const res = await authFetch("/api/v1/dropper");
if (res.ok) {
setDropper((await res.json()) as DropperInfo);
}
} catch {
// non-fatal
}
}, []);
useEffect(() => {
void loadBuilds();
void loadDropper();
}, [loadBuilds, loadDropper]);
const triggerBuild = async () => {
setTriggering(true);
setBuildMessage(null);
try {
const res = await authFetch("/api/v1/forge/builds/trigger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ public: true }),
});
const data = (await res.json()) as { ok?: boolean; message?: string };
setBuildMessage({
text: data.ok
? "Build complete — artifacts signed and published."
: (data.message ?? "Build failed."),
ok: data.ok ?? false,
});
// Reload both builds list and dropper (has_build may have changed)
await Promise.all([loadBuilds(), loadDropper()]);
} catch {
setBuildMessage({ text: "Failed to reach the server.", ok: false });
} finally {
setTriggering(false);
}
};
const copyOneLiner = () => {
if (!dropper) return;
void navigator.clipboard.writeText(dropper.one_liner).then(() => {
setCopied(true);
if (copyTimer.current) clearTimeout(copyTimer.current);
copyTimer.current = setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
Forge &amp; Deploy
</h1>
<p className="text-sm text-deck-muted">
Build agent binaries, grab the dropper URL, paste one command on any
machine
</p>
</div>
<Button onClick={() => void triggerBuild()} disabled={triggering}>
{triggering ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
Build &amp; Publish
</Button>
</header>
{/* Build result banner */}
{buildMessage && (
<Card
className={
buildMessage.ok
? "border-deck-accent/30 bg-deck-accent/5"
: "border-deck-danger/30 bg-deck-danger/5"
}
>
<CardContent className="flex items-center gap-2 p-4 text-sm">
{buildMessage.ok ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-deck-accent" />
) : (
<TriangleAlert className="h-4 w-4 shrink-0 text-deck-danger" />
)}
{buildMessage.text}
</CardContent>
</Card>
)}
{/* ── DROPPER PANEL ── */}
<Card className="border-deck-cyan/30 bg-deck-cyan/5">
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Link2 className="h-5 w-5 text-deck-cyan" />
<CardTitle className="text-base">Dropper URL</CardTitle>
{dropper && !dropper.has_build && (
<Badge variant="warning" className="ml-auto text-xs">
No build yet — trigger one first
</Badge>
)}
</div>
<CardDescription>
Paste this one-liner into any target machine to fetch, verify, and
install the agent as a systemd service.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dropper ? (
<>
{/* One-liner */}
<div className="space-y-1.5">
<p className="text-xs font-medium uppercase tracking-wider text-deck-muted">
Install command
</p>
<div className="flex items-stretch gap-2">
<pre className="flex-1 overflow-x-auto rounded-md border border-deck-border bg-deck-surface px-3 py-2 font-mono text-xs text-deck-text">
{dropper.one_liner}
</pre>
<Button
variant="secondary"
size="sm"
onClick={copyOneLiner}
className="shrink-0 self-stretch"
aria-label="Copy one-liner"
>
{copied ? (
<CheckCircle2 className="h-4 w-4 text-deck-accent" />
) : (
<ClipboardCopy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Dropper meta row */}
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 text-xs">
<MetaField label="Install script URL" value={dropper.install_url} mono />
<MetaField label="Fleet secret" value={dropper.fleet_secret} mono secret />
<MetaField
label="Signing public key"
value={
dropper.public_key
? dropper.public_key.slice(0, 16) + "…"
: "—"
}
mono
/>
<MetaField label="Agent version" value={dropper.version} />
</div>
</>
) : (
<p className="flex items-center gap-2 text-sm text-deck-muted">
<Loader2 className="h-4 w-4 animate-spin" /> Loading dropper
info…
</p>
)}
</CardContent>
</Card>
{/* ── BUILD LIST ── */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-sm font-medium uppercase tracking-wider text-deck-muted">
<Hammer className="h-4 w-4" />
Published artifacts
</h2>
{loadingBuilds && (
<Loader2 className="h-4 w-4 animate-spin text-deck-muted" />
)}
</div>
{!loadingBuilds && builds.length === 0 ? (
<Card className="border-dashed border-deck-border">
<CardContent className="p-6 text-center text-sm text-deck-muted">
No builds yet — click <strong>Build &amp; Publish</strong> above.
</CardContent>
</Card>
) : (
<div className="space-y-2">
{builds.map((build) => (
<Card key={build.id}>
<CardContent className="flex flex-wrap items-center gap-4 p-4">
<Package className="h-8 w-8 shrink-0 text-deck-accent" />
<div className="flex-1 min-w-0">
<p className="font-mono text-sm font-medium truncate">
{build.os}/{build.arch} · v{build.version}
</p>
<p className="font-mono text-[10px] text-deck-muted truncate">
sha256:{build.checksum.slice(0, 24)}…
</p>
<p className="text-[10px] text-deck-muted">
{new Date(build.created_at).toLocaleString()}
</p>
</div>
<div className="flex gap-2 shrink-0">
{build.signature && build.signature !== "UNSIGNED" && (
<Badge variant="active">
<ShieldCheck className="mr-1 h-3 w-3" />
SIGNED
</Badge>
)}
<Badge variant={build.public ? "online" : "secondary"}>
{build.public ? "PUBLIC" : "PRIVATE"}
</Badge>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
);
}
function MetaField({
label,
value,
mono = false,
secret = false,
}: {
label: string;
value: string;
mono?: boolean;
secret?: boolean;
}) {
const [revealed, setRevealed] = useState(false);
const display = secret && !revealed ? "••••••••••••••••" : value;
return (
<div className="space-y-0.5">
<p className="text-deck-muted uppercase tracking-wider text-[10px] font-medium">
{label}
</p>
<div className="flex items-center gap-1.5">
<span
className={`truncate ${mono ? "font-mono" : ""} text-deck-text`}
>
{display}
</span>
{secret && (
<button
onClick={() => setRevealed((v) => !v)}
className="shrink-0 text-deck-muted hover:text-deck-text text-[10px] underline"
>
{revealed ? "hide" : "show"}
</button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginPage } from "./LoginPage";
import { renderWithProviders } from "@/test/test-utils";
describe("LoginPage", () => {
it("renders login form", () => {
renderWithProviders(<LoginPage />, {
routerProps: { initialEntries: ["/login"] },
authenticated: false,
});
expect(screen.getByText("AetherForge Command Deck")).toBeInTheDocument();
expect(screen.getByLabelText("Username")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
});
it("shows validation error for empty submit", async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />, {
routerProps: { initialEntries: ["/login"] },
authenticated: false,
});
await user.click(screen.getByRole("button", { name: "Authenticate" }));
expect(screen.getByText("Username and password are required.")).toBeInTheDocument();
});
it("authenticates and navigates to dashboard", async () => {
const user = userEvent.setup();
renderWithProviders(<LoginPage />, {
routerProps: { initialEntries: ["/login"] },
authenticated: false,
});
await user.type(screen.getByLabelText("Username"), "operator");
await user.type(screen.getByLabelText("Password"), "secret");
await user.click(screen.getByRole("button", { name: "Authenticate" }));
await waitFor(() => {
expect(sessionStorage.getItem("aetherforge_auth")).toContain("operator");
});
});
});

101
web/src/pages/LoginPage.tsx Normal file
View File

@@ -0,0 +1,101 @@
import { useState, type FormEvent } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { Anvil, Lock, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { setStoredAuth } from "@/lib/auth";
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const from =
(location.state as { from?: { pathname: string } })?.from?.pathname ??
"/dashboard";
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (!username.trim() || !password) {
setError("Username and password are required.");
return;
}
setStoredAuth({ username: username.trim(), password });
navigate(from, { replace: true });
};
return (
<div className="flex min-h-screen items-center justify-center p-4 deck-grid-bg">
<div className="absolute inset-0 bg-gradient-to-b from-deck-accent/5 via-transparent to-transparent pointer-events-none" />
<Card className="relative w-full max-w-md border-deck-accent/20 shadow-glow">
<CardHeader className="text-center">
<div className="mx-auto mb-2 flex h-12 w-12 items-center justify-center rounded-lg border border-deck-accent/40 bg-deck-accent/10">
<Anvil className="h-6 w-6 text-deck-accent" />
</div>
<CardTitle className="text-xl">AetherForge Command Deck</CardTitle>
<CardDescription>
HTTP Basic authentication — credentials stored in session only
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-deck-muted" />
<Input
id="username"
autoComplete="username"
placeholder="operator"
className="pl-10"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-deck-muted" />
<Input
id="password"
type="password"
autoComplete="current-password"
placeholder="••••••••"
className="pl-10"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
</div>
{error && (
<p className="text-sm text-deck-danger">{error}</p>
)}
<Button type="submit" className="w-full">
Authenticate
</Button>
</form>
<p className="mt-4 text-center font-mono text-[10px] text-deck-muted">
sessionStorage · cleared on tab close
</p>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SeerPage } from "./SeerPage";
import { renderWithProviders } from "@/test/test-utils";
import {
installMockEventSource,
MockEventSource,
uninstallMockEventSource,
} from "@/test/mockEventSource";
describe("SeerPage", () => {
beforeEach(() => {
installMockEventSource();
});
afterEach(() => {
uninstallMockEventSource();
});
it("renders event stream viewer", () => {
renderWithProviders(<SeerPage />);
expect(screen.getByRole("heading", { name: "Seer" })).toBeInTheDocument();
expect(screen.getByText("Waiting for events…")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /Connect SSE/i })).toBeInTheDocument();
});
it("connects SSE viewer on button click", async () => {
const user = userEvent.setup();
renderWithProviders(<SeerPage />);
await user.click(screen.getByRole("button", { name: /Connect SSE/i }));
const es = MockEventSource.latest();
expect(es?.url).toContain("/seer");
es?.simulateOpen();
await waitFor(() => {
expect(screen.getByText("LIVE")).toBeInTheDocument();
});
});
it("receives SSE messages into event list", async () => {
const user = userEvent.setup();
renderWithProviders(<SeerPage />);
await user.click(screen.getByRole("button", { name: /Connect SSE/i }));
const es = MockEventSource.latest();
es?.simulateOpen();
es?.simulateMessage(
JSON.stringify({
id: "evt-1",
event_type: "court",
payload_json: '{"verdict":"approved"}',
created_at: new Date().toISOString(),
}),
);
await waitFor(() => {
expect(screen.getByText(/verdict/)).toBeInTheDocument();
});
});
});

176
web/src/pages/SeerPage.tsx Normal file
View File

@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Eye, Loader2, Radio } from "lucide-react";
import { authFetch, getBasicAuthHeader } from "@/lib/auth";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface SeerEvent {
id: string;
event_type: string;
payload_json: string;
host_id?: string;
created_at: string;
}
export function SeerPage() {
const [events, setEvents] = useState<SeerEvent[]>([]);
const [connected, setConnected] = useState(false);
const [status, setStatus] = useState<"idle" | "connecting" | "live" | "error">("idle");
const sourceRef = useRef<EventSource | null>(null);
const streamRef = useRef<HTMLDivElement>(null);
const parseEvent = (raw: string): SeerEvent | null => {
try {
return JSON.parse(raw) as SeerEvent;
} catch {
return {
id: String(Date.now()),
event_type: "sse",
payload_json: raw,
created_at: new Date().toISOString(),
};
}
};
const connect = useCallback(() => {
sourceRef.current?.close();
setStatus("connecting");
setEvents([]);
const auth = getBasicAuthHeader();
const url = auth
? `/seer?authorization=${encodeURIComponent(auth)}`
: "/seer";
const es = new EventSource(url);
sourceRef.current = es;
es.onopen = () => {
setConnected(true);
setStatus("live");
};
es.onmessage = (e) => {
const entry = parseEvent(e.data);
if (entry) {
setEvents((prev) => [entry, ...prev].slice(0, 200));
}
};
es.onerror = () => {
setConnected(false);
setStatus("error");
es.close();
};
}, []);
const disconnect = () => {
sourceRef.current?.close();
sourceRef.current = null;
setConnected(false);
setStatus("idle");
};
useEffect(() => () => sourceRef.current?.close(), []);
useEffect(() => {
streamRef.current?.scrollTo({ top: 0 });
}, [events]);
const loadSnapshot = async () => {
setStatus("connecting");
try {
const res = await authFetch("/api/v1/crucible/history");
if (res.ok) {
setStatus("live");
setEvents([
{
id: "snapshot",
event_type: "system",
payload_json: '{"message":"Seer snapshot — connect SSE for live stream"}',
created_at: new Date().toISOString(),
},
]);
}
} catch {
setStatus("error");
}
};
return (
<div className="p-6 space-y-6">
<header className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Seer</h1>
<p className="text-sm text-deck-muted">
Court transcripts · LOTL timeline · live SSE stream
</p>
</div>
<div className="flex gap-2">
{!connected ? (
<>
<Button size="sm" onClick={connect}>
<Radio className="h-4 w-4" />
Connect SSE
</Button>
<Button size="sm" variant="outline" onClick={() => void loadSnapshot()}>
Snapshot
</Button>
</>
) : (
<Button variant="outline" size="sm" onClick={disconnect}>
Disconnect
</Button>
)}
<Badge variant={status === "live" ? "online" : status === "error" ? "failed" : "secondary"}>
{status === "connecting" && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}
{status.toUpperCase()}
</Badge>
</div>
</header>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Eye className="h-5 w-5 text-deck-cyan" />
<CardTitle className="text-base">Event Stream</CardTitle>
</div>
<CardDescription>
SSE endpoint: <code className="font-mono text-deck-cyan">GET /seer</code>
</CardDescription>
</CardHeader>
<CardContent>
<div
ref={streamRef}
className="max-h-[480px] overflow-y-auto rounded-lg border border-deck-border bg-deck-bg p-3 font-mono text-xs space-y-2"
>
{events.length === 0 && (
<p className="text-deck-muted">Waiting for events…</p>
)}
{events.map((ev) => (
<div key={ev.id} className="flex gap-2 border-b border-deck-border/50 pb-2">
<span className="text-deck-muted shrink-0">
{new Date(ev.created_at).toLocaleTimeString()}
</span>
<Badge variant="secondary" className="shrink-0">
{ev.event_type}
</Badge>
{ev.host_id && (
<span className="text-deck-cyan shrink-0">{ev.host_id.slice(0, 8)}</span>
)}
<span className="text-deck-text break-all">{ev.payload_json}</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { vi } from "vitest";
export class MockEventSource {
static instances: MockEventSource[] = [];
url: string;
onopen: (() => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
onerror: (() => void) | null = null;
closed = false;
constructor(url: string) {
this.url = url;
MockEventSource.instances.push(this);
}
close() {
this.closed = true;
}
/** Test helper: simulate successful connection */
simulateOpen() {
this.onopen?.();
}
/** Test helper: push SSE data */
simulateMessage(data: string) {
this.onmessage?.({ data });
}
/** Test helper: simulate connection error */
simulateError() {
this.onerror?.();
}
static reset() {
MockEventSource.instances = [];
}
static latest(): MockEventSource | undefined {
return MockEventSource.instances.at(-1);
}
}
export function installMockEventSource() {
MockEventSource.reset();
vi.stubGlobal("EventSource", MockEventSource);
}
export function uninstallMockEventSource() {
vi.unstubAllGlobals();
MockEventSource.reset();
}

View File

@@ -0,0 +1,57 @@
import { vi } from "vitest";
type MessageHandler = (event: { data: string }) => void;
export class MockWebSocket {
static instances: MockWebSocket[] = [];
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
url: string;
readyState = MockWebSocket.CONNECTING;
onopen: (() => void) | null = null;
onmessage: MessageHandler | null = null;
onerror: (() => void) | null = null;
onclose: (() => void) | null = null;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
queueMicrotask(() => {
this.readyState = MockWebSocket.OPEN;
this.onopen?.();
});
}
send = vi.fn();
close() {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
/** Test helper: push a JSON message to listeners */
simulateMessage(data: unknown) {
this.onmessage?.({ data: JSON.stringify(data) });
}
static reset() {
MockWebSocket.instances = [];
}
static latest(): MockWebSocket | undefined {
return MockWebSocket.instances.at(-1);
}
}
export function installMockWebSocket() {
MockWebSocket.reset();
vi.stubGlobal("WebSocket", MockWebSocket);
}
export function uninstallMockWebSocket() {
vi.unstubAllGlobals();
MockWebSocket.reset();
}

View File

@@ -0,0 +1,38 @@
import type { FleetResponse } from "@/types/fleet";
export const MOCK_API_FLEET: FleetResponse = {
totalHashrate: 55_000_000,
onlineCount: 2,
hosts: [
{
id: "host-001",
hostname: "forge-node-alpha",
ip: "10.0.1.12",
arch: "linux/amd64",
hashrate: 30_000_000,
tier: 2,
tierName: "Bundled xmrig",
tierState: "active",
algo: "rx/0",
uptimeSec: 7200,
lastSeen: new Date().toISOString(),
clearance: 2,
online: true,
},
{
id: "host-002",
hostname: "mesh-worker-beta",
ip: "10.0.1.47",
arch: "linux/amd64",
hashrate: 25_000_000,
tier: 3,
tierName: "GPU lolMiner",
tierState: "probing",
algo: "kawpow",
uptimeSec: 1800,
lastSeen: new Date().toISOString(),
clearance: 1,
online: true,
},
],
};

View File

@@ -0,0 +1,89 @@
import { http, HttpResponse } from "msw";
import { MOCK_API_FLEET } from "./fleet";
const MOCK_MINING_PROFILE = {
wallet_address: "48edfHu7V9Z3N9z6M7x8K2pL4qR5sT6uV7wX8yZ9aB0cD1eF2gH3jK4mN5oP6qR",
tiers: [
{ tier: 1, name: "OCI podman", enabled: true },
{ tier: 2, name: "Bundled xmrig", enabled: true },
{ tier: 3, name: "GPU lolMiner", enabled: true },
{ tier: 4, name: "Stratum-direct fallback", enabled: false },
],
};
const MOCK_BUILDS = {
builds: [
{
id: "build-001",
os: "linux",
arch: "amd64",
version: "0.1.0-dev",
checksum: "abc123def4567890abcdef1234567890",
signature: "sig-ed25519",
public: true,
created_at: "2026-06-07T12:00:00Z",
},
{
id: "build-002",
os: "linux",
arch: "arm64",
version: "0.1.0-dev",
checksum: "fedcba0987654321fedcba0987654321",
signature: "sig-ed25519",
public: false,
created_at: "2026-06-06T18:30:00Z",
},
],
};
const MOCK_DROPPER = {
dropper_url: "http://localhost:8989/get",
install_url: "http://localhost:8989/install.sh",
one_liner:
"FORGE_MESH_FLEET_SECRET=change-me-fleet-secret bash <(curl -fsSL http://localhost:8989/install.sh)",
fleet_secret: "change-me-fleet-secret",
public_key: "abcdef1234567890abcdef1234567890",
has_build: true,
version: "0.1.0-dev",
};
export const handlers = [
http.get("/api/v1/fleet", () => HttpResponse.json(MOCK_API_FLEET)),
http.post("/api/v1/ws/ticket", () => HttpResponse.json({ ticket: "test-ws-ticket" })),
http.get("/api/v1/dropper", () => HttpResponse.json(MOCK_DROPPER)),
http.get("/api/v1/forge/builds", () => HttpResponse.json(MOCK_BUILDS)),
http.post("/api/v1/forge/builds/trigger", () => HttpResponse.json({ ok: true, message: "Build queued" })),
http.get("/api/v1/policy/mining-profile", () => HttpResponse.json(MOCK_MINING_PROFILE)),
http.put("/api/v1/policy/mining-profile", () => HttpResponse.json({ ok: true })),
http.put("/api/v1/policy/wallet", () => HttpResponse.json({ ok: true })),
http.post("/api/v1/fleet/:hostId/mining-profile", async ({ request, params }) => {
const body = (await request.json()) as { wallet_address?: string };
return HttpResponse.json({
id: `profile-${params.hostId}`,
wallet_address: body.wallet_address ?? MOCK_MINING_PROFILE.wallet_address,
});
}),
http.get("/api/v1/crucible/history", () => HttpResponse.json({ jobs: [] })),
http.get("/api/v1/operator/me", () => HttpResponse.json({ clearance_level: 4 })),
http.post("/api/v1/crucible/batch", async ({ request }) => {
const body = (await request.json()) as { command?: string; all?: boolean };
if (!body.command) {
return HttpResponse.json({ error: "command required" }, { status: 400 });
}
return HttpResponse.json(
{
id: "batch-test-001",
command: body.command,
status: "completed",
results: [
{
host_id: "host-001",
hostname: "forge-node-alpha",
status: "dispatched",
},
],
},
{ status: 202 },
);
}),
];

View File

@@ -0,0 +1,4 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);

View File

@@ -0,0 +1,20 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterAll, afterEach, beforeAll } from "vitest";
import { server } from "./mocks/server";
if (!HTMLElement.prototype.scrollTo) {
HTMLElement.prototype.scrollTo = function scrollTo() {
/* jsdom polyfill */
};
}
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
cleanup();
server.resetHandlers();
sessionStorage.clear();
});
afterAll(() => server.close());

View File

@@ -0,0 +1,47 @@
import { render, type RenderOptions } from "@testing-library/react";
import { MemoryRouter, type MemoryRouterProps } from "react-router-dom";
import type { ReactElement, ReactNode } from "react";
import { setStoredAuth } from "@/lib/auth";
interface TestProvidersProps {
children: ReactNode;
routerProps?: MemoryRouterProps;
authenticated?: boolean;
}
export function TestProviders({
children,
routerProps = { initialEntries: ["/dashboard"] },
authenticated = true,
}: TestProvidersProps) {
if (authenticated) {
setStoredAuth({ username: "operator", password: "test-pass" });
}
return <MemoryRouter {...routerProps}>{children}</MemoryRouter>;
}
export function renderWithProviders(
ui: ReactElement,
options: RenderOptions & { routerProps?: MemoryRouterProps; authenticated?: boolean } = {},
) {
const { routerProps, authenticated, ...renderOptions } = options;
return render(ui, {
wrapper: ({ children }) => (
<TestProviders routerProps={routerProps} authenticated={authenticated}>
{children}
</TestProviders>
),
...renderOptions,
});
}
export function renderAppRoutes(initialPath: string, authenticated = true) {
// Lazy import to avoid circular deps in some test setups
// eslint-disable-next-line @typescript-eslint/no-require-imports
const App = require("@/App").default as () => ReactElement;
return renderWithProviders(<App />, {
routerProps: { initialEntries: [initialPath] },
authenticated,
});
}

97
web/src/types/fleet.ts Normal file
View File

@@ -0,0 +1,97 @@
export type TierState = "active" | "probing" | "failed" | "idle" | "paused";
export interface FleetHost {
id: string;
hostname: string;
ip: string;
arch: string;
hashrate: number;
tier: number;
tierName: string;
tierState: TierState;
algo: string;
uptimeSec: number;
lastSeen: string;
clearance: number;
online: boolean;
}
export interface FleetResponse {
hosts: FleetHost[];
totalHashrate: number;
onlineCount: number;
}
export interface WsFleetMessage {
type: "heartbeat" | "host_update" | "command_ack" | "ping";
host?: Partial<FleetHost> & { id: string };
payload?: unknown;
timestamp?: string;
}
export const MOCK_FLEET: FleetResponse = {
totalHashrate: 42_500_000,
onlineCount: 3,
hosts: [
{
id: "host-001",
hostname: "forge-node-alpha",
ip: "10.0.1.12",
arch: "linux/amd64",
hashrate: 18_200_000,
tier: 2,
tierName: "Bundled xmrig",
tierState: "active",
algo: "rx/0",
uptimeSec: 86400,
lastSeen: new Date().toISOString(),
clearance: 2,
online: true,
},
{
id: "host-002",
hostname: "mesh-worker-beta",
ip: "10.0.1.47",
arch: "linux/amd64",
hashrate: 12_800_000,
tier: 3,
tierName: "GPU lolMiner",
tierState: "probing",
algo: "kawpow",
uptimeSec: 3600,
lastSeen: new Date().toISOString(),
clearance: 1,
online: true,
},
{
id: "host-003",
hostname: "edge-probe-gamma",
ip: "10.0.2.8",
arch: "linux/arm64",
hashrate: 11_500_000,
tier: 1,
tierName: "OCI podman",
tierState: "active",
algo: "rx/0",
uptimeSec: 172800,
lastSeen: new Date().toISOString(),
clearance: 3,
online: true,
},
{
id: "host-004",
hostname: "offline-staging",
ip: "10.0.3.99",
arch: "linux/amd64",
hashrate: 0,
tier: 0,
tierName: "—",
tierState: "idle",
algo: "—",
uptimeSec: 0,
lastSeen: new Date(Date.now() - 3600_000).toISOString(),
clearance: 0,
online: false,
},
],
};

2
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vitest/globals" />