Skip to content
Open
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
8 changes: 8 additions & 0 deletions .claude/rules/data-hooks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
name: data-hooks
description: When (not) to wrap useQuery/useMutation in a custom hook.
---

# Data hooks

Don't wrap a single `useQuery`/`useMutation` call in its own custom hook (e.g. `useGetServers`, `useCreateServer`) unless it has real shared logic or is reused across multiple components. Call `useQuery`/`useMutation` directly in the component with the request function from `src/lib/request/*.ts`, matching the inline style already used in `src/components/jobs/CountsBar.tsx` and `JobSubmitForm.tsx`. A thin wrapper that only forwards `queryKey`/`queryFn` adds a layer of indirection without adding value.
30 changes: 30 additions & 0 deletions .claude/rules/single-source-of-truth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
name: single-source-of-truth
description: Avoid duplicated types, constants, query keys, and business logic across the frontend codebase.
---

# Single source of truth

Before writing a new type, constant, query key, or piece of business logic, grep for an existing one first (by field names or by the feature name) — duplicating one instead of reusing it is the most common mistake to avoid here.

## Types

Domain types (API request/response shapes, enums, form state) live in `src/types/<domain>.ts`, never inline in a `src/lib/request/*.ts` file, a hook, or a component. Request functions, hooks, and components all `import type { ... } from "@/types/<domain>"`. See `src/types/group.ts` and `src/types/resource.ts` for the pattern.

## Derived constants

Anything derived from a type — select option lists, label lookup maps, status-to-color maps — must be defined exactly once, in the matching `src/types/<domain>.ts` file, and imported everywhere it's needed. Never re-declare the same `{ value, label }[]` array or `Record<Enum, string>` map in more than one component. Example: `RESOURCE_ROLE_OPTIONS` / `RESOURCE_ROLE_LABEL_KEYS` in `src/types/resource.ts`.

## React Query keys

Each domain's query keys are defined once, as a small key object/factory in that domain's `src/lib/request/*.ts` file (e.g. `serverQueryKeys` in `src/lib/request/resources.ts`), and imported by every `useQuery`/`useMutation`/`invalidateQueries` call that touches it. Never hand-type the same `["thing", id]` array literal in more than one file — a typo silently breaks cache invalidation.

## Form default/empty state

A constant like `emptyXFormData` belongs in the file that actually uses it (usually the one component that seeds `useState` with it), not in a shared form-fields component that doesn't need it itself.

## Business logic

A computation, formatting rule, or coordination flow (e.g. converting `memory_mb` to GB for display, mapping a status to a badge color, deciding when to close a drawer after a save) is written once and imported/called from everywhere it's needed — never copy-pasted into a second component with the same shape. If two components need the same derived value or the same multi-step flow (e.g. "run these mutations, then toast + close only if all succeed"), extract it into a shared function (in `src/lib/`) or a shared component/hook, rather than re-implementing it inline a second time.

Before writing a calculation, mapping, or multi-step flow, check whether the same shape of logic already exists elsewhere in the feature (or a sibling feature like `jobs/`) and reuse or extract it instead of re-deriving it.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ dist-ssr
*.local
coverage

.playwright-mcp

# Editor directories and files
.vscode/*
!.vscode/extensions.json
Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import UserConfiguration from "@/pages/admin/UserConfiguration";
import BindCallback from "@/pages/BindCallback";
import JobSubmitPage from "@/pages/JobSubmitPage";
import JobLayout from "@/pages/layouts/JobLayout";
import ResourceListPage from "@/pages/resource/ResourceList";
import ErrorBoundary from "@/components/ErrorBoundary";

const App = () => {
Expand Down Expand Up @@ -85,6 +86,8 @@ const App = () => {
/>
</Route>

<Route path="/resources" element={<ResourceListPage />} />

<Route path="/groups/:id" element={<GroupLayout />}>
<Route index element={<GroupOverview />} />
<Route path="settings" element={<GroupSettings />} />
Expand Down
12 changes: 12 additions & 0 deletions src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,12 @@ export default function Navbar() {
>
{t("navbar.settingLink")}
</NavLink>
<NavLink
to="/resources"
className={({ isActive }) => navLinkClassForMobile(isActive)}
>
{t("navbar.resourceLink")}
</NavLink>
{role === "admin" && (
<NavLink
to="/admin"
Expand Down Expand Up @@ -139,6 +145,12 @@ export default function Navbar() {
</NavLink>
</>
)}
<NavLink
to="/resources"
className={({ isActive }) => navLinkclass(isActive)}
>
{t("navbar.resourceLink")}
</NavLink>
{role === "admin" && (
<NavLink
to="/admin"
Expand Down
140 changes: 140 additions & 0 deletions src/components/resource/AddResourceSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { useState } from "react";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetFooter,
SheetTrigger,
SheetClose,
} from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { createServer, serverQueryKeys } from "@/lib/request/resources";
import { getErrMessage } from "@/lib/errors";
import ResourceFormFields from "@/components/resource/ResourceFormFields";
import type { CreateResourceInput, ResourceFormData } from "@/types/resource";

const emptyResourceFormData: ResourceFormData = {
ansible_name: "",
ip_address: "",
ssh_config_host: "",
private_ip: "",
ssh_user: "",
ssh_key_name: "",
ansible_role: "",
slurm_partition: "",
cpu_cores: "",
memory_mb: "",
};

export default function AddResourceSheet() {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState<ResourceFormData>(
emptyResourceFormData,
);

const toastId = "create-server";
const { mutate: submitCreateServer, isPending } = useMutation({
mutationFn: createServer,
onMutate: () => {
toast.loading(
t("resourceComponents.createServer.creatingToast", "Adding node..."),
{ id: toastId },
);
},
onSuccess: () => {
toast.success(
t(
"resourceComponents.createServer.createSuccessToast",
"Node added successfully",
),
{ id: toastId },
);
queryClient.invalidateQueries({ queryKey: serverQueryKeys.all });
setOpen(false);
setFormData(emptyResourceFormData);
},
onError: (err) => {
const msg = getErrMessage(
err,
t(
"resourceComponents.createServer.createFailToast",
"Failed to add node",
),
);
toast.error(msg, { id: toastId });
},
});

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();

const payload: CreateResourceInput = {
ansible_name: formData.ansible_name,
ssh_user: formData.ssh_user,
ansible_role:
formData.ansible_role as CreateResourceInput["ansible_role"],
ip_address: formData.ip_address || undefined,
ssh_config_host: formData.ssh_config_host || undefined,
private_ip: formData.private_ip || undefined,
ssh_key_name: formData.ssh_key_name || undefined,
slurm_partition: formData.slurm_partition || undefined,
cpu_cores: formData.cpu_cores ? Number(formData.cpu_cores) : undefined,
memory_mb: formData.memory_mb ? Number(formData.memory_mb) : undefined,
};

submitCreateServer(payload);
};

return (
<Sheet
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setFormData(emptyResourceFormData);
}}
>
<SheetTrigger asChild>
<Button>
<Plus className="h-4 w-4" />
{t("resourceComponents.addResourceSheet.trigger")}
</Button>
</SheetTrigger>

<SheetContent className="w-full gap-0 overflow-y-auto sm:max-w-lg">
<form onSubmit={handleSubmit} className="flex h-full flex-col">
<SheetHeader>
<SheetTitle className="text-2xl">
{t("resourceComponents.addResourceSheet.title")}
</SheetTitle>
</SheetHeader>

<div className="flex-1 px-4">
<ResourceFormFields
formData={formData}
onChange={setFormData}
disabled={isPending}
/>
</div>

<SheetFooter className="flex-row justify-end">
<SheetClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
{t("common.cancel")}
</Button>
</SheetClose>
<Button type="submit" disabled={isPending}>
{t("resourceComponents.addResourceSheet.submit")}
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
73 changes: 73 additions & 0 deletions src/components/resource/AllowedLoginGroupsField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { useGetGroups } from "@/hooks/useGetGroups";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";

type Props = {
selectedGroupIds: string[];
onChange: (groupIds: string[]) => void;
};

export default function AllowedLoginGroupsField({
selectedGroupIds,
onChange,
}: Props) {
const { t } = useTranslation();
const { data, isLoading, isError } = useGetGroups();
const groups = data?.items ?? [];

const toggleGroup = (groupId: string, checked: boolean) => {
if (checked) {
onChange([...selectedGroupIds, groupId]);
} else {
onChange(selectedGroupIds.filter((id) => id !== groupId));
}
};

return (
<div className="grid gap-2">
<Label>{t("resourceComponents.form.allowedLoginGroups")}</Label>
<div className="rounded-md border">
{isLoading ? (
<div className="flex items-center gap-2 p-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("loading")}
</div>
) : isError ? (
<p className="p-3 text-sm text-red-500">
{t("resourceComponents.form.groupsLoadFail")}
</p>
) : groups.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">
{t("resourceComponents.form.noGroups")}
</p>
) : (
groups.map((group, index) => (
<label
key={group.id}
htmlFor={`allowed-login-group-${group.id}`}
className={`flex items-center gap-2 px-3 py-2 cursor-pointer ${
index !== groups.length - 1 ? "border-b" : ""
}`}
>
<Checkbox
id={`allowed-login-group-${group.id}`}
checked={selectedGroupIds.includes(group.id)}
onCheckedChange={(checked) =>
toggleGroup(group.id, checked === true)
}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{group.title}</span>
<span className="text-xs text-muted-foreground">
{group.ldapGroupName}
</span>
</div>
</label>
))
)}
</div>
</div>
);
}
58 changes: 58 additions & 0 deletions src/components/resource/ConfirmActionDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useTranslation } from "react-i18next";

type Props = {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
isPending?: boolean;
title: string;
description: string;
confirmLabel: string;
variant?: "destructive" | "default";
};

export default function ConfirmActionDialog({
open,
onOpenChange,
onConfirm,
isPending = false,
title,
description,
confirmLabel,
variant = "destructive",
}: Props) {
const { t } = useTranslation();

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle
className={variant === "destructive" ? "text-red-600" : ""}
>
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter className="flex justify-end gap-2">
<DialogClose asChild>
<Button variant="outline">{t("common.cancel")}</Button>
</DialogClose>
<Button variant={variant} onClick={onConfirm} disabled={isPending}>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Loading
Loading