304 lines
8.7 KiB
TypeScript

import { Alert, Badge, Button, Dropdown, Input, Table } from "react-daisyui";
import { Node } from "../types";
import { cn, handleError, readableBytes } from "@/lib/utils";
import {
Check,
CheckCircle,
Cylinder,
EllipsisVertical,
Info,
Network,
RouteIcon,
Share2,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
import ConnectNodeDialog from "./connect-node-dialog";
import AssignNodeDialog from "./assign-node-dialog";
import { assignNodeDialog } from "../stores";
import {
useApplyChanges,
useClusterLayout,
useRevertChanges,
useUnassignNode,
} from "../hooks";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
type NodeListProps = {
nodes: Node[];
};
const NodesList = ({ nodes }: NodeListProps) => {
const { data, refetch } = useClusterLayout();
const [filter, setFilter] = useState({
search: "",
});
const queryClient = useQueryClient();
const unassignNode = useUnassignNode({
onSuccess: () => {
toast.success("Node unassigned!");
refetch();
},
onError: handleError,
});
const revertChanges = useRevertChanges({
onSuccess: () => {
toast.success("Layout reverted!");
refetch();
},
onError: handleError,
});
const applyChanges = useApplyChanges({
onSuccess: () => {
toast.success("Layout applied!");
setTimeout(refetch, 100);
queryClient.invalidateQueries({ queryKey: ["status"] });
},
onError: handleError,
});
const items = useMemo(() => {
return nodes
.filter((item) => {
if (filter.search) {
const q = filter.search.toLowerCase();
return (
item.hostname.toLowerCase().includes(q) ||
item.id.includes(q) ||
item.addr.includes(q) ||
item.role?.zone?.includes(q) ||
item.role?.tags?.find((tag) => tag.toLowerCase().includes(q))
);
}
return true;
})
.map((item) => {
const role = data?.roles?.find((r) => r.id === item.role?.id);
const stagedChanges = data?.stagedRoleChanges?.find(
(i) => i.id === item.id
);
return {
...item,
role: stagedChanges || role || item.role,
isStaged: !!stagedChanges,
};
});
}, [nodes, data, filter]);
const onAssign = (node: Node) => {
assignNodeDialog.open({
nodeId: node.id,
zone: node.role?.zone,
capacity: node.role?.capacity,
tags: node.role?.tags,
});
};
const onUnassign = (id: string) => {
if (window.confirm("Are you sure you want to unassign this node?")) {
unassignNode.mutate(id);
}
};
const onRevert = () => {
if (
window.confirm("Are you sure you want to revert layout changes?") &&
data?.version != null
) {
revertChanges.mutate(data?.version + 1);
}
};
const onApply = () => {
if (
window.confirm("Are you sure you want to revert layout changes?") &&
data?.version != null
) {
applyChanges.mutate(data?.version + 1);
}
};
const hasStagedChanges = data && data.stagedRoleChanges?.length > 0;
return (
<>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center my-2 gap-x-4 gap-y-2">
<Input
placeholder="Search..."
value={filter.search}
onChange={(e) => {
setFilter((state) => ({ ...state, search: e.target.value }));
}}
/>
<div className="flex-1" />
{hasStagedChanges ? (
<>
<Button
onClick={onRevert}
disabled={revertChanges.isPending || applyChanges.isPending}
>
Revert
</Button>
<Button
color="primary"
onClick={onApply}
disabled={revertChanges.isPending || applyChanges.isPending}
>
<Check />
Apply
</Button>
</>
) : (
<ConnectNodeDialog />
)}
</div>
{hasStagedChanges && (
<Alert icon={<Info />}>
There are staged layout changes that need to be applied. Press Apply
to apply them, or Revert to discard them.
</Alert>
)}
{applyChanges.data?.message ? (
<Alert
icon={<CheckCircle />}
className="items-start overflow-x-auto relative text-sm"
>
<pre>{applyChanges.data.message.join("\n")}</pre>
<Button
onClick={applyChanges.reset}
className="absolute right-2 top-2"
shape="circle"
size="sm"
>
<X />
</Button>
</Alert>
) : null}
<div className="w-full overflow-x-auto overflow-y-hidden min-h-[400px]">
<Table size="sm" className="min-w-[800px]">
<Table.Head>
<span>#</span>
<span>ID</span>
<span>Hostname</span>
<span>Zone</span>
<span>Capacity</span>
<span>Status</span>
<span />
</Table.Head>
<Table.Body>
{items.map((item, idx) => (
<Table.Row
key={item.id}
className={cn(
item.isStaged && "bg-warning/10",
item.role && "remove" in item.role ? "bg-error/10" : null
)}
>
<span>{idx + 1}</span>
<p className="max-w-[80px] truncate" title={item.id}>
{item.id}
</p>
<>
<p className="font-medium">{item.hostname}</p>
<div className="flex flex-row items-center gap-1">
<Share2 size={12} />
<p className="text-base-content/80 text-xs">{item.addr}</p>
</div>
</>
<>
<p>{item.role?.zone || "-"}</p>
<div className="flex flex-row items-center flex-wrap gap-1">
{item.role?.tags?.map((tag: any) => (
<Badge key={tag} color="primary">
{tag}
</Badge>
))}
</div>
</>
<>
<p>
{item.role?.capacity === null ? (
<>
<Network className="inline mr-1" size={18} />
Gateway
</>
) : (
readableBytes(item.role?.capacity, 1000)
)}
</p>
{item.role?.capacity !== null && item.dataPartition ? (
<div className="flex flex-row items-center gap-1">
<Cylinder size={12} />
<p className="text-xs text-base-content/80">
{readableBytes(item.dataPartition?.available) +
` (${Math.round(
(item.dataPartition.available /
item.dataPartition.total) *
100
)}%)`}
</p>
</div>
) : null}
</>
<Badge
color={
item.draining ? "warning" : item.isUp ? "success" : "error"
}
>
{item.draining
? "Draining"
: item.isUp
? "Active"
: "Inactive"}
</Badge>
<Dropdown
end
vertical={idx >= items.length - 2 ? "top" : "bottom"}
>
<Dropdown.Toggle button={false}>
<Button shape="circle" color="ghost">
<EllipsisVertical />
</Button>
</Dropdown.Toggle>
<Dropdown.Menu className="min-w-40 gap-y-1">
<Dropdown.Item onClick={() => onAssign(item)}>
<RouteIcon size={20} /> Assign
</Dropdown.Item>
{item.role != null && (
<Dropdown.Item
className="text-error bg-error/10"
onClick={() => onUnassign(item.id)}
>
<Trash2 size={20} /> Remove
</Dropdown.Item>
)}
</Dropdown.Menu>
</Dropdown>
</Table.Row>
))}
</Table.Body>
</Table>
</div>
<AssignNodeDialog />
</>
);
};
export default NodesList;