Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ export default function App() {
</div>

<div style={{ padding: "12px 8px", display: "flex", flexDirection: "column", gap: 2, flex: 1 }}>
<NavLink to="/topology" style={({ isActive }) => navStyle(isActive)}>
<TopologyIcon /> Topology
</NavLink>
<NavLink to="/fleet" style={({ isActive }) => navStyle(isActive)}>
<FleetIcon /> Fleet
</NavLink>
<NavLink to="/topology" style={({ isActive }) => navStyle(isActive)}>
<TopologyIcon /> Topology
</NavLink>
<NavLink to="/activity" style={({ isActive }) => navStyle(isActive)}>
<ActivityIcon /> Activity
</NavLink>
Expand Down
48 changes: 33 additions & 15 deletions web/src/components/GroupPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ interface Props {

const UNGROUPED_LABEL = "ungrouped";

const DROPDOWN_MAX_HEIGHT = 240;

const inputStyle: React.CSSProperties = {
width: "100%",
padding: "8px 10px",
Expand All @@ -26,20 +28,22 @@ const inputStyle: React.CSSProperties = {
boxSizing: "border-box",
};

const dropdownStyle: React.CSSProperties = {
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
minWidth: 200,
background: "#161b22",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 6,
maxHeight: 240,
overflowY: "auto",
zIndex: 50,
boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
};
function dropdownStyle(openUp: boolean): React.CSSProperties {
return {
position: "absolute",
[openUp ? "bottom" : "top"]: "calc(100% + 4px)",
left: 0,
right: 0,
minWidth: 200,
background: "#161b22",
border: "1px solid rgba(255,255,255,0.12)",
borderRadius: 6,
maxHeight: DROPDOWN_MAX_HEIGHT,
overflowY: "auto",
zIndex: 50,
boxShadow: openUp ? "0 -4px 16px rgba(0,0,0,0.4)" : "0 4px 16px rgba(0,0,0,0.4)",
};
}

const itemStyle = (active: boolean): React.CSSProperties => ({
padding: "8px 12px",
Expand All @@ -63,8 +67,22 @@ export default function GroupPicker({
}: Props) {
const [text, setText] = useState(value ?? "");
const [highlight, setHighlight] = useState(0);
const [openUp, setOpenUp] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);

// Auto-detect whether the dropdown should open upward — if there's not
// enough vertical room below the input (e.g. picker rendered near the
// bottom of the viewport, like inside SelectionActionBar), flip up.
useEffect(() => {
if (!rootRef.current) return;
const rect = rootRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
if (spaceBelow < DROPDOWN_MAX_HEIGHT + 20 && spaceAbove > spaceBelow) {
setOpenUp(true);
}
}, []);

// Filter out the synthetic "ungrouped" entry — it's not a real group.
const realGroups = useMemo(
() => groups.filter(g => g.name.toLowerCase() !== UNGROUPED_LABEL),
Expand Down Expand Up @@ -144,7 +162,7 @@ export default function GroupPicker({
style={inputStyle}
aria-label="Group name"
/>
<div style={dropdownStyle} role="listbox">
<div style={dropdownStyle(openUp)} role="listbox">
{filtered.length === 0 && !showCreate && !showUngroup && (
<div style={{ padding: "10px 12px", fontSize: 12, color: "rgba(255,255,255,0.4)" }}>
No groups yet — type a name to create one.
Expand Down
121 changes: 121 additions & 0 deletions web/src/components/SelectionActionBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { useState } from "react";
import type { MachineGroup } from "../types";
import GroupPicker from "./GroupPicker";

interface Props {
count: number;
groups: MachineGroup[];
onAssign: (groupName: string | null) => Promise<void> | void;
onClear: () => void;
busy?: boolean;
error?: string | null;
}

export default function SelectionActionBar({ count, groups, onAssign, onClear, busy, error }: Props) {
const [pickerOpen, setPickerOpen] = useState(false);

const containerStyle: React.CSSProperties = {
position: "absolute",
bottom: 24,
left: "50%",
transform: "translateX(-50%)",
background: "rgba(22,27,34,0.95)",
border: "1px solid rgba(88,166,255,0.4)",
borderRadius: 10,
padding: "10px 14px",
display: "flex",
alignItems: "center",
gap: 12,
boxShadow: "0 8px 24px rgba(0,0,0,0.5)",
color: "#e6edf3",
fontSize: 13,
zIndex: 30,
backdropFilter: "blur(12px)",
};

const pillStyle: React.CSSProperties = {
padding: "4px 10px",
background: "rgba(88,166,255,0.18)",
border: "1px solid rgba(88,166,255,0.4)",
borderRadius: 12,
fontSize: 12,
fontWeight: 600,
color: "#58a6ff",
};

const buttonStyle = (variant: "primary" | "secondary" | "ghost"): React.CSSProperties => ({
padding: "6px 12px",
borderRadius: 6,
fontSize: 12,
fontWeight: 600,
border: variant === "primary" ? "none" : "1px solid rgba(255,255,255,0.15)",
background:
variant === "primary" ? "#1f6feb" :
variant === "ghost" ? "transparent" : "rgba(255,255,255,0.05)",
color:
variant === "primary" ? "#fff" :
variant === "ghost" ? "rgba(255,255,255,0.6)" : "#e6edf3",
cursor: busy ? "wait" : "pointer",
opacity: busy ? 0.6 : 1,
});

return (
<div style={containerStyle} onMouseDown={e => e.stopPropagation()}>
<span style={pillStyle}>{count} selected</span>

<div style={{ position: "relative" }}>
<button
type="button"
disabled={busy}
onClick={() => setPickerOpen(v => !v)}
style={buttonStyle("primary")}
>
Assign group ▾
</button>
{pickerOpen && (
<div style={{
position: "absolute",
bottom: "calc(100% + 6px)",
left: 0,
minWidth: 240,
}}>
<GroupPicker
value={null}
groups={groups}
onChange={async (next) => {
setPickerOpen(false);
if (next) await onAssign(next);
}}
onClose={() => setPickerOpen(false)}
/>
</div>
)}
</div>

<button
type="button"
disabled={busy}
onClick={() => onAssign(null)}
style={buttonStyle("secondary")}
title="Remove all selected machines from any group"
>
Ungroup
</button>

<button
type="button"
onClick={onClear}
style={buttonStyle("ghost")}
title="Clear selection (Esc)"
>
</button>

{error && (
<span style={{ color: "#f85149", fontSize: 12, marginLeft: 4 }}>
{error}
</span>
)}
</div>
);
}
2 changes: 1 addition & 1 deletion web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Navigate to="/topology" replace />} />
<Route index element={<Navigate to="/fleet" replace />} />
<Route path="topology" element={<TopologyView />} />
<Route path="fleet" element={<FleetOverview />} />
<Route path="activity" element={<Activity />} />
Expand Down
83 changes: 75 additions & 8 deletions web/src/pages/FleetOverview.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { getMachines, getMachine } from "../api";
import type { MachineDetail, MachineListItem } from "../types";
import { getGroups, getMachine, getMachines } from "../api";
import type { MachineDetail, MachineGroup, MachineListItem } from "../types";
import StatusBadge from "../components/StatusBadge";
import PieChart from "../components/PieChart";
import HealthGauge from "../components/HealthGauge";
import CommandBlock from "../components/CommandBlock";
import DiagnosticCard from "../components/DiagnosticCard";

const UNGROUPED_LABEL = "ungrouped";

function groupHue(name: string): number {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) & 0xffffffff;
return Math.abs(h) % 360;
}

const REFRESH_MS = 30_000;

/* ─── Issue → env-doctor command mapping ─── */
Expand Down Expand Up @@ -76,9 +84,11 @@ const tdS: React.CSSProperties = {

export default function FleetOverview() {
const [machines, setMachines] = useState<MachineListItem[]>([]);
const [groups, setGroups] = useState<MachineGroup[]>([]);
const [filter, setFilter] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [gpuFilter, setGpuFilter] = useState<string>("");
const [groupFilter, setGroupFilter] = useState<string>(""); // "" = all, name = that group, "ungrouped" = NULL
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailCache, setDetailCache] = useState<Map<string, MachineDetail>>(new Map());
Expand All @@ -91,6 +101,7 @@ export default function FleetOverview() {
.then(setMachines)
.catch(console.error)
.finally(() => setLoading(false));
getGroups().then(setGroups).catch(() => {});
};

useEffect(() => {
Expand All @@ -99,6 +110,12 @@ export default function FleetOverview() {
return () => clearInterval(id);
}, [filter]);

const groupOptions = useMemo(
() => groups.filter(g => g.name !== UNGROUPED_LABEL).sort((a, b) => a.name.localeCompare(b.name)),
[groups]
);
const ungroupedCount = groups.find(g => g.name === UNGROUPED_LABEL)?.machine_count ?? 0;

const handleRowClick = async (id: string) => {
if (expandedId === id) { setExpandedId(null); return; }
setExpandedId(id);
Expand Down Expand Up @@ -130,6 +147,11 @@ export default function FleetOverview() {
return m.hostname.toLowerCase().includes(q);
};
const matchesGpu = (m: MachineListItem) => !gpuFilter || m.gpu_name === gpuFilter;
const matchesGroup = (m: MachineListItem) => {
if (!groupFilter) return true;
if (groupFilter === UNGROUPED_LABEL) return m.group_name == null;
return m.group_name === groupFilter;
};

// Issues-focused: show all that aren't passing or are stale (unless a filter is active)
const baseMachines = filter === "stale"
Expand All @@ -138,7 +160,7 @@ export default function FleetOverview() {
? machines
: machines.filter(m => m.latest_status !== "pass" || m.stale);

const issuesMachines = baseMachines.filter(m => matchesSearch(m) && matchesGpu(m));
const issuesMachines = baseMachines.filter(m => matchesSearch(m) && matchesGpu(m) && matchesGroup(m));

const filterBtnStyle = (active: boolean, color?: string): React.CSSProperties => ({
padding: "5px 14px",
Expand Down Expand Up @@ -226,9 +248,31 @@ export default function FleetOverview() {
<option key={g} value={g}>{g}</option>
))}
</select>
{(search || gpuFilter) && (
<select
value={groupFilter}
onChange={e => setGroupFilter(e.target.value)}
style={{
padding: "8px 12px",
background: "#0d1117",
border: groupFilter ? "1px solid rgba(88,166,255,0.5)" : "1px solid rgba(255,255,255,0.12)",
borderRadius: 8,
color: "#e6edf3",
fontSize: 13,
minWidth: 160,
}}
title="Filter by group"
>
<option value="">All groups</option>
{groupOptions.map(g => (
<option key={g.name} value={g.name}>{g.name} ({g.machine_count})</option>
))}
{ungroupedCount > 0 && (
<option value={UNGROUPED_LABEL}>Ungrouped ({ungroupedCount})</option>
)}
</select>
{(search || gpuFilter || groupFilter) && (
<button
onClick={() => { setSearch(""); setGpuFilter(""); }}
onClick={() => { setSearch(""); setGpuFilter(""); setGroupFilter(""); }}
style={{
padding: "8px 12px",
background: "transparent",
Expand Down Expand Up @@ -294,7 +338,7 @@ export default function FleetOverview() {
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
{["Machine", "GPU", "Status", "Issues", "Last Seen"].map(h => (
{["Machine", "Group", "GPU", "Status", "Issues", "Last Seen"].map(h => (
<th key={h} style={thS}>{h}</th>
))}
</tr>
Expand Down Expand Up @@ -358,6 +402,29 @@ export default function FleetOverview() {
)}
</div>
</td>
<td style={tdS}>
{m.group_name ? (
<button
type="button"
onClick={e => { e.stopPropagation(); setGroupFilter(m.group_name!); }}
title={`Filter to group "${m.group_name}"`}
style={{
padding: "2px 10px",
borderRadius: 12,
fontSize: 11,
fontWeight: 600,
background: `hsla(${groupHue(m.group_name)}, 60%, 55%, 0.18)`,
border: `1px solid hsla(${groupHue(m.group_name)}, 60%, 55%, 0.4)`,
color: `hsl(${groupHue(m.group_name)}, 60%, 75%)`,
cursor: "pointer",
}}
>
{m.group_name}
</button>
) : (
<span style={{ color: "rgba(255,255,255,0.25)", fontSize: 12 }}>—</span>
)}
</td>
<td style={{ ...tdS, color: "rgba(255,255,255,0.7)" }}>{m.gpu_name ?? "—"}</td>
<td style={tdS}><StatusBadge status={m.latest_status} /></td>
<td style={{ ...tdS, fontWeight: 600, color: m.latest_status === "fail" ? "#da3633" : "#d29922" }}>
Expand All @@ -368,7 +435,7 @@ export default function FleetOverview() {

{isExpanded && (
<tr key={`${m.id}-expand`}>
<td colSpan={5} style={{ padding: 0, borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<td colSpan={6} style={{ padding: 0, borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<div style={{
background: "rgba(13,17,23,0.8)",
borderTop: "1px solid rgba(255,255,255,0.06)",
Expand Down
Loading
Loading