mirror of
https://github.com/khairul169/garage-webui.git
synced 2026-09-15 00:43:21 +07:00
feat: project init
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
import { Button, Checkbox, Input, Modal, Select } from "react-daisyui";
|
||||
import { useAssignNode, useClusterLayout, useClusterStatus } from "../hooks";
|
||||
import { Controller, useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
AssignNodeSchema,
|
||||
assignNodeSchema,
|
||||
capacityUnits,
|
||||
calculateCapacity,
|
||||
parseCapacity,
|
||||
} from "../schema";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { assignNodeDialog } from "../stores";
|
||||
import FormControl from "@/components/ui/form-control";
|
||||
import Select2 from "@/components/ui/select";
|
||||
|
||||
const defaultValues: AssignNodeSchema = {
|
||||
nodeId: "",
|
||||
zone: "",
|
||||
capacity: 1,
|
||||
capacityUnit: "GB",
|
||||
isGateway: false,
|
||||
tags: [],
|
||||
};
|
||||
|
||||
const AssignNodeDialog = () => {
|
||||
const { isOpen, data } = assignNodeDialog.use();
|
||||
const { data: cluster } = useClusterStatus();
|
||||
const { data: layout } = useClusterLayout();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<AssignNodeSchema>({
|
||||
resolver: zodResolver(assignNodeSchema),
|
||||
defaultValues,
|
||||
});
|
||||
const isGateway = useWatch({ control: form.control, name: "isGateway" });
|
||||
|
||||
const assignNode = useAssignNode({
|
||||
onSuccess() {
|
||||
form.reset();
|
||||
toast.success("Node staged for assignment!");
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["layout"] });
|
||||
assignNodeDialog.close();
|
||||
},
|
||||
onError(err) {
|
||||
toast.error(err?.message || "Unknown error");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const isGateway = data.capacity === null;
|
||||
const cap = parseCapacity(data.capacity);
|
||||
|
||||
form.reset({
|
||||
...defaultValues,
|
||||
...data,
|
||||
capacity: cap.value,
|
||||
capacityUnit: cap.unit,
|
||||
isGateway,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const zoneList = useMemo(() => {
|
||||
const list = cluster?.nodes
|
||||
.flatMap((i) => {
|
||||
const role = layout?.roles.find((role) => role.id === i.id);
|
||||
const staged = layout?.stagedRoleChanges.find(
|
||||
(role) => role.id === i.id
|
||||
);
|
||||
return staged?.zone || role?.zone || i.role?.zone;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set(list)].map((zone) => ({
|
||||
label: zone,
|
||||
value: zone,
|
||||
}));
|
||||
}, [cluster, layout]);
|
||||
|
||||
const tagsList = useMemo(() => {
|
||||
const list = cluster?.nodes
|
||||
.flatMap((i) => {
|
||||
const role = layout?.roles.find((role) => role.id === i.id);
|
||||
const staged = layout?.stagedRoleChanges.find(
|
||||
(role) => role.id === i.id
|
||||
);
|
||||
return staged?.tags || role?.tags || i.role?.tags;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return [...new Set(list)].map((tag) => ({
|
||||
label: tag,
|
||||
value: tag,
|
||||
}));
|
||||
}, [cluster, layout]);
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
const capacity = !values.isGateway
|
||||
? calculateCapacity(values.capacity, values.capacityUnit)
|
||||
: null;
|
||||
const data = {
|
||||
id: values.nodeId,
|
||||
zone: values.zone,
|
||||
capacity,
|
||||
tags: values.tags,
|
||||
};
|
||||
assignNode.mutate(data);
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal open={isOpen}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}}
|
||||
>
|
||||
<Modal.Header>Assign Node</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div className="form-control">
|
||||
<label className="label label-text">Node ID:</label>
|
||||
<Input
|
||||
placeholder="..."
|
||||
className="w-full"
|
||||
{...form.register("nodeId")}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
form={form}
|
||||
name="zone"
|
||||
title="Zone"
|
||||
className="mt-2"
|
||||
render={(field) => (
|
||||
<Select2
|
||||
creatable
|
||||
{...field}
|
||||
value={
|
||||
field.value
|
||||
? { label: field.value, value: field.value }
|
||||
: null
|
||||
}
|
||||
options={zoneList}
|
||||
onChange={({ value }: any) => field.onChange(value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<label className="label label-text flex-1 truncate">Capacity</label>
|
||||
<label className="label label-text cursor-pointer">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="isGateway"
|
||||
render={({ field }) => (
|
||||
<Checkbox
|
||||
{...(field as any)}
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="mr-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
Gateway
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isGateway && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<FormControl
|
||||
form={form}
|
||||
name="capacity"
|
||||
render={(field) => <Input type="number" {...(field as any)} />}
|
||||
/>
|
||||
<FormControl
|
||||
form={form}
|
||||
name="capacityUnit"
|
||||
render={(field) => (
|
||||
<Select {...(field as any)}>
|
||||
<option value="">Select Unit</option>
|
||||
|
||||
{capacityUnits.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{unit}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormControl
|
||||
form={form}
|
||||
name="tags"
|
||||
title="Tags"
|
||||
className="mt-2"
|
||||
render={(field) => (
|
||||
<Select2
|
||||
creatable
|
||||
isMulti
|
||||
{...field}
|
||||
value={
|
||||
field.value
|
||||
? (field.value as string[]).map((value) => ({
|
||||
label: value,
|
||||
value,
|
||||
}))
|
||||
: null
|
||||
}
|
||||
options={tagsList}
|
||||
onChange={(values) => {
|
||||
if (Array.isArray(values)) {
|
||||
field.onChange(values.map((value) => value.value));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Actions>
|
||||
<Button type="button" onClick={assignNodeDialog.close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" color="primary" disabled={assignNode.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignNodeDialog;
|
||||
@@ -0,0 +1,88 @@
|
||||
import Code from "@/components/ui/code";
|
||||
import { Button, Input, Modal } from "react-daisyui";
|
||||
import { useConnectNode } from "../hooks";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { ConnectNodeSchema, connectNodeSchema } from "../schema";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
const ConnectNodeDialog = () => {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<ConnectNodeSchema>({
|
||||
resolver: zodResolver(connectNodeSchema),
|
||||
defaultValues: { nodeId: "" },
|
||||
});
|
||||
|
||||
const connectNode = useConnectNode({
|
||||
onSuccess() {
|
||||
form.reset({ nodeId: "" });
|
||||
handleHide();
|
||||
toast.success("Node connected!");
|
||||
queryClient.invalidateQueries({ queryKey: ["status"] });
|
||||
},
|
||||
onError(err) {
|
||||
handleHide();
|
||||
toast.error(err?.message || "Unknown error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleShow = useCallback(() => {
|
||||
dialogRef.current?.showModal();
|
||||
}, [dialogRef]);
|
||||
|
||||
const handleHide = useCallback(() => {
|
||||
dialogRef.current?.close();
|
||||
}, [dialogRef]);
|
||||
|
||||
const onSubmit = form.handleSubmit((values) => {
|
||||
connectNode.mutate(values.nodeId);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button color="primary" onClick={handleShow}>
|
||||
Connect
|
||||
</Button>
|
||||
|
||||
<Modal ref={dialogRef} backdrop>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onSubmit(e);
|
||||
}}
|
||||
>
|
||||
<Modal.Header>Connect Node</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>Run this command to get node id:</p>
|
||||
<Code className="mt-2">docker exec -it garage /garage node id</Code>
|
||||
|
||||
<p className="mt-8">Enter node id:</p>
|
||||
<Input
|
||||
placeholder="..."
|
||||
className="w-full"
|
||||
{...form.register("nodeId")}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Actions>
|
||||
<Button type="button" onClick={handleHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
disabled={connectNode.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Modal.Actions>
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectNodeDialog;
|
||||
@@ -0,0 +1,300 @@
|
||||
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-row items-center my-2 gap-4">
|
||||
<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">
|
||||
<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;
|
||||
@@ -0,0 +1,70 @@
|
||||
import api from "@/lib/api";
|
||||
import {
|
||||
ApplyLayoutResult,
|
||||
AssignNodeBody,
|
||||
GetClusterLayoutResult,
|
||||
GetStatusResult,
|
||||
} from "./types";
|
||||
import {
|
||||
useMutation,
|
||||
UseMutationOptions,
|
||||
useQuery,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
export const useClusterStatus = () => {
|
||||
return useQuery({
|
||||
queryKey: ["status"],
|
||||
queryFn: () => api.get<GetStatusResult>("/v1/status"),
|
||||
});
|
||||
};
|
||||
|
||||
export const useClusterLayout = () => {
|
||||
return useQuery({
|
||||
queryKey: ["layout"],
|
||||
queryFn: () => api.get<GetClusterLayoutResult>("/v1/layout"),
|
||||
});
|
||||
};
|
||||
|
||||
export const useConnectNode = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, string>({
|
||||
mutationFn: async (nodeId) => {
|
||||
const [res] = await api.post("/v1/connect", { body: [nodeId] });
|
||||
if (!res.success) {
|
||||
throw new Error(res.error || "Unknown error");
|
||||
}
|
||||
return res;
|
||||
},
|
||||
...(options as any),
|
||||
});
|
||||
};
|
||||
|
||||
export const useAssignNode = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, AssignNodeBody>({
|
||||
mutationFn: (data) => api.post("/v1/layout", { body: [data] }),
|
||||
...(options as any),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnassignNode = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, string>({
|
||||
mutationFn: (nodeId) =>
|
||||
api.post("/v1/layout", { body: [{ id: nodeId, remove: true }] }),
|
||||
...(options as any),
|
||||
});
|
||||
};
|
||||
|
||||
export const useRevertChanges = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<any, Error, number>({
|
||||
mutationFn: (version) =>
|
||||
api.post("/v1/layout/revert", { body: { version } }),
|
||||
...(options as any),
|
||||
});
|
||||
};
|
||||
|
||||
export const useApplyChanges = (options?: Partial<UseMutationOptions>) => {
|
||||
return useMutation<ApplyLayoutResult, Error, number>({
|
||||
mutationFn: (version) =>
|
||||
api.post("/v1/layout/apply", { body: { version } }),
|
||||
...(options as any),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import Page from "@/context/page-context";
|
||||
import { useClusterStatus } from "./hooks";
|
||||
import { Card } from "react-daisyui";
|
||||
import NodesList from "./components/nodes-list";
|
||||
|
||||
const ClusterPage = () => {
|
||||
const { data } = useClusterStatus();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Page title="Cluster" />
|
||||
|
||||
<Card>
|
||||
<Card.Body className="gap-1">
|
||||
<Card.Title className="mb-2">Details</Card.Title>
|
||||
|
||||
<DetailItem title="Node ID" value={data?.node} />
|
||||
<DetailItem title="Version" value={data?.garageVersion} />
|
||||
{/* <DetailItem title="Rust version" value={data?.rustVersion} /> */}
|
||||
<DetailItem title="DB engine" value={data?.dbEngine} />
|
||||
<DetailItem title="Layout version" value={data?.layoutVersion} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card className="mt-4 md:mt-8">
|
||||
<Card.Body>
|
||||
<Card.Title>Nodes</Card.Title>
|
||||
|
||||
<NodesList nodes={data?.nodes || []} />
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type DetailItemProps = {
|
||||
title: string;
|
||||
value?: string | number | null;
|
||||
};
|
||||
|
||||
const DetailItem = ({ title, value }: DetailItemProps) => {
|
||||
return (
|
||||
<div className="flex flex-row items-start max-w-xl gap-3 text-left text-sm">
|
||||
<div className="shrink-0 w-[200px]">
|
||||
<p className="text-base-content/80">{title}</p>
|
||||
</div>
|
||||
<div className="flex-1 truncate">
|
||||
<p className="truncate">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterPage;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const connectNodeSchema = z.object({
|
||||
nodeId: z.string().min(1, "Node ID is required"),
|
||||
});
|
||||
|
||||
export type ConnectNodeSchema = z.infer<typeof connectNodeSchema>;
|
||||
|
||||
export const capacityUnits = ["MB", "GB", "TB"] as const;
|
||||
|
||||
export const assignNodeSchema = z
|
||||
.object({
|
||||
nodeId: z.string().min(1, "Node ID is required"),
|
||||
zone: z.string().min(1, 'Zone is required, e.g. "dc1"'),
|
||||
capacity: z.coerce.number().nullish(),
|
||||
capacityUnit: z.enum(capacityUnits),
|
||||
isGateway: z.boolean(),
|
||||
tags: z.string().min(1).array(),
|
||||
})
|
||||
.refine(
|
||||
(values) => values.isGateway || (values.capacity && values.capacity > 0),
|
||||
{
|
||||
message: "Capacity required",
|
||||
path: ["capacity"],
|
||||
}
|
||||
);
|
||||
|
||||
export type AssignNodeSchema = z.infer<typeof assignNodeSchema>;
|
||||
|
||||
export const calculateCapacity = (
|
||||
value?: number | null,
|
||||
unit?: (typeof capacityUnits)[number]
|
||||
) => {
|
||||
if (!value || !unit) return 0;
|
||||
return value * 1000 ** (capacityUnits.indexOf(unit) + 2);
|
||||
};
|
||||
|
||||
export const parseCapacity = (value?: number | null) => {
|
||||
if (!value) {
|
||||
return { value: 0, unit: undefined };
|
||||
}
|
||||
|
||||
for (let i = capacityUnits.length - 1; i >= 0; i--) {
|
||||
if (value >= 1000 ** (i + 2)) {
|
||||
return {
|
||||
value: Math.floor(value / 1000 ** (i + 2)),
|
||||
unit: capacityUnits[i],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { value, unit: undefined };
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createDisclosure } from "@/lib/disclosure";
|
||||
import { AssignNodeSchema } from "./schema";
|
||||
|
||||
export const assignNodeDialog = createDisclosure<Partial<AssignNodeSchema>>();
|
||||
@@ -0,0 +1,57 @@
|
||||
//
|
||||
|
||||
export type GetStatusResult = {
|
||||
node: string;
|
||||
garageVersion: string;
|
||||
garageFeatures: string[];
|
||||
rustVersion: string;
|
||||
dbEngine: string;
|
||||
layoutVersion: number;
|
||||
nodes: Node[];
|
||||
};
|
||||
|
||||
export type Node = {
|
||||
id: string;
|
||||
role?: Role | StagedRole;
|
||||
addr: string;
|
||||
hostname: string;
|
||||
isUp: boolean;
|
||||
lastSeenSecsAgo: number | null;
|
||||
draining: boolean;
|
||||
dataPartition: DataPartition;
|
||||
metadataPartition: DataPartition;
|
||||
};
|
||||
|
||||
export type DataPartition = {
|
||||
available: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type Role = {
|
||||
id: string;
|
||||
zone: string;
|
||||
capacity: number;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type StagedRole = { id: string; remove: boolean } & Partial<
|
||||
Omit<Role, "id">
|
||||
>;
|
||||
|
||||
export type GetClusterLayoutResult = {
|
||||
version: number;
|
||||
roles: Role[];
|
||||
stagedRoleChanges: StagedRole[];
|
||||
};
|
||||
|
||||
export type AssignNodeBody = {
|
||||
id: string;
|
||||
zone: string;
|
||||
capacity: number | null;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export type ApplyLayoutResult = {
|
||||
message: string[];
|
||||
layout: GetClusterLayoutResult;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
value?: string | number | null;
|
||||
icon: LucideIcon;
|
||||
valueClassName?: string;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
const StatsCard = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
valueClassName,
|
||||
children,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="bg-base-100 rounded-box p-4 md:p-6 flex flex-row items-center">
|
||||
<div className="shrink-0 w-[60px]">
|
||||
<Icon size={32} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 truncate">
|
||||
{children != null ? (
|
||||
children
|
||||
) : (
|
||||
<p className={cn("flex-1 text-3xl font-bold", valueClassName)}>
|
||||
{typeof value === "undefined" ? "..." : value}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm mt-0.5">{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatsCard;
|
||||
@@ -0,0 +1,10 @@
|
||||
import api from "@/lib/api";
|
||||
import { GetHealthResult } from "./types";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
export const useNodesHealth = () => {
|
||||
return useQuery({
|
||||
queryKey: ["health"],
|
||||
queryFn: () => api.get<GetHealthResult>("/v1/health"),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import Page from "@/context/page-context";
|
||||
import { useNodesHealth } from "./hooks";
|
||||
import StatsCard from "./components/stats-card";
|
||||
import {
|
||||
Database,
|
||||
DatabaseZap,
|
||||
FileBox,
|
||||
FileCheck,
|
||||
FileClock,
|
||||
HardDrive,
|
||||
HardDriveUpload,
|
||||
Leaf,
|
||||
} from "lucide-react";
|
||||
import { cn, ucfirst } from "@/lib/utils";
|
||||
|
||||
const HomePage = () => {
|
||||
const { data: health } = useNodesHealth();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Page title="Dashboard" />
|
||||
|
||||
<section className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6">
|
||||
<StatsCard
|
||||
title="Status"
|
||||
icon={Leaf}
|
||||
value={ucfirst(health?.status)}
|
||||
valueClassName={cn(
|
||||
"text-lg",
|
||||
health?.status === "healthy" ? "text-success" : "text-error"
|
||||
)}
|
||||
/>
|
||||
<StatsCard title="Nodes" icon={HardDrive} value={health?.knownNodes} />
|
||||
<StatsCard
|
||||
title="Connected Nodes"
|
||||
icon={HardDriveUpload}
|
||||
value={health?.connectedNodes}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Storage Nodes"
|
||||
icon={Database}
|
||||
value={health?.storageNodes}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Storage Nodes"
|
||||
icon={DatabaseZap}
|
||||
value={health?.storageNodesOk}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Partitions"
|
||||
icon={FileBox}
|
||||
value={health?.partitions}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Partitions Quorum"
|
||||
icon={FileClock}
|
||||
value={health?.partitionsQuorum}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Active Partitions"
|
||||
icon={FileCheck}
|
||||
value={health?.partitionsAllOk}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -0,0 +1,12 @@
|
||||
//
|
||||
|
||||
export type GetHealthResult = {
|
||||
status: string;
|
||||
knownNodes: number;
|
||||
connectedNodes: number;
|
||||
storageNodes: number;
|
||||
storageNodesOk: number;
|
||||
partitions: number;
|
||||
partitionsQuorum: number;
|
||||
partitionsAllOk: number;
|
||||
};
|
||||
Reference in New Issue
Block a user