Initial commit: AetherForge Linux (forge-mesh) v0.1.0-dev
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
This commit is contained in:
60
web/src/App.test.tsx
Normal file
60
web/src/App.test.tsx
Normal 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
40
web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
web/src/components/dashboard/HostCard.test.tsx
Normal file
56
web/src/components/dashboard/HostCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
111
web/src/components/dashboard/HostCard.tsx
Normal file
111
web/src/components/dashboard/HostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
web/src/components/layout/CommandDeckLayout.test.tsx
Normal file
29
web/src/components/layout/CommandDeckLayout.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
24
web/src/components/layout/CommandDeckLayout.tsx
Normal file
24
web/src/components/layout/CommandDeckLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
web/src/components/layout/ProtectedRoute.test.tsx
Normal file
40
web/src/components/layout/ProtectedRoute.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
12
web/src/components/layout/ProtectedRoute.tsx
Normal file
12
web/src/components/layout/ProtectedRoute.tsx
Normal 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 />;
|
||||
}
|
||||
104
web/src/components/layout/Sidebar.tsx
Normal file
104
web/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
web/src/components/ui/badge.test.tsx
Normal file
18
web/src/components/ui/badge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
46
web/src/components/ui/badge.tsx
Normal file
46
web/src/components/ui/badge.tsx
Normal 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 };
|
||||
49
web/src/components/ui/button.tsx
Normal file
49
web/src/components/ui/button.tsx
Normal 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 };
|
||||
66
web/src/components/ui/card.tsx
Normal file
66
web/src/components/ui/card.tsx
Normal 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 };
|
||||
20
web/src/components/ui/input.tsx
Normal file
20
web/src/components/ui/input.tsx
Normal 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 };
|
||||
19
web/src/components/ui/label.tsx
Normal file
19
web/src/components/ui/label.tsx
Normal 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 };
|
||||
31
web/src/context/FleetWsContext.tsx
Normal file
31
web/src/context/FleetWsContext.tsx
Normal 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);
|
||||
}
|
||||
67
web/src/hooks/useFleetData.ts
Normal file
67
web/src/hooks/useFleetData.ts
Normal 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 };
|
||||
57
web/src/hooks/useFleetWebSocket.test.ts
Normal file
57
web/src/hooks/useFleetWebSocket.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
112
web/src/hooks/useFleetWebSocket.ts
Normal file
112
web/src/hooks/useFleetWebSocket.ts
Normal 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
42
web/src/index.css
Normal 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
43
web/src/lib/auth.test.ts
Normal 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
66
web/src/lib/auth.ts
Normal 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
34
web/src/lib/utils.test.ts
Normal 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
21
web/src/lib/utils.ts
Normal 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
13
web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
34
web/src/pages/CalibratePage.test.tsx
Normal file
34
web/src/pages/CalibratePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
196
web/src/pages/CalibratePage.tsx
Normal file
196
web/src/pages/CalibratePage.tsx
Normal 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/{id}/mining-profile</code>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
web/src/pages/CruciblePage.test.tsx
Normal file
32
web/src/pages/CruciblePage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
174
web/src/pages/CruciblePage.tsx
Normal file
174
web/src/pages/CruciblePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
web/src/pages/DashboardPage.test.tsx
Normal file
61
web/src/pages/DashboardPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
95
web/src/pages/DashboardPage.tsx
Normal file
95
web/src/pages/DashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
web/src/pages/ForgePage.test.tsx
Normal file
55
web/src/pages/ForgePage.test.tsx
Normal 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
324
web/src/pages/ForgePage.tsx
Normal 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 & 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 & 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 & 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>
|
||||
);
|
||||
}
|
||||
42
web/src/pages/LoginPage.test.tsx
Normal file
42
web/src/pages/LoginPage.test.tsx
Normal 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
101
web/src/pages/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
web/src/pages/SeerPage.test.tsx
Normal file
63
web/src/pages/SeerPage.test.tsx
Normal 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
176
web/src/pages/SeerPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
web/src/test/mockEventSource.ts
Normal file
52
web/src/test/mockEventSource.ts
Normal 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();
|
||||
}
|
||||
57
web/src/test/mockWebSocket.ts
Normal file
57
web/src/test/mockWebSocket.ts
Normal 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();
|
||||
}
|
||||
38
web/src/test/mocks/fleet.ts
Normal file
38
web/src/test/mocks/fleet.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
89
web/src/test/mocks/handlers.ts
Normal file
89
web/src/test/mocks/handlers.ts
Normal 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 },
|
||||
);
|
||||
}),
|
||||
];
|
||||
4
web/src/test/mocks/server.ts
Normal file
4
web/src/test/mocks/server.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { setupServer } from "msw/node";
|
||||
import { handlers } from "./handlers";
|
||||
|
||||
export const server = setupServer(...handlers);
|
||||
20
web/src/test/setupTests.ts
Normal file
20
web/src/test/setupTests.ts
Normal 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());
|
||||
47
web/src/test/test-utils.tsx
Normal file
47
web/src/test/test-utils.tsx
Normal 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
97
web/src/types/fleet.ts
Normal 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
2
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vitest/globals" />
|
||||
Reference in New Issue
Block a user