mirror of
https://github.com/khairul169/garage-webui.git
synced 2025-04-28 14:59:31 +07:00
301 lines
8.6 KiB
TypeScript
301 lines
8.6 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 min-h-[400px] pb-16">
|
|
<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>
|
|
<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;
|