feat: project init

This commit is contained in:
2024-08-14 15:36:25 +07:00
commit b0e5d53ee0
40 changed files with 4800 additions and 0 deletions
@@ -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;
+300
View File
@@ -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;
+70
View File
@@ -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),
});
};
+54
View File
@@ -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;
+53
View File
@@ -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 };
};
+4
View File
@@ -0,0 +1,4 @@
import { createDisclosure } from "@/lib/disclosure";
import { AssignNodeSchema } from "./schema";
export const assignNodeDialog = createDisclosure<Partial<AssignNodeSchema>>();
+57
View File
@@ -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;
};
+39
View File
@@ -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;
+10
View File
@@ -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"),
});
};
+69
View File
@@ -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;
+12
View File
@@ -0,0 +1,12 @@
//
export type GetHealthResult = {
status: string;
knownNodes: number;
connectedNodes: number;
storageNodes: number;
storageNodesOk: number;
partitions: number;
partitionsQuorum: number;
partitionsAllOk: number;
};