mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +01:00
feat: move jobs to action based
Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { AppShell } from "@/layouts/app-shell.tsx"
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
|
||||
import { ProtectedRoute } from "@/components/protected-route.tsx"
|
||||
import { ActionsPage } from "@/pages/actions-page.tsx"
|
||||
import { AnnotationPage } from "@/pages/annotation-page.tsx"
|
||||
import { ClustersPage } from "@/pages/cluster-page"
|
||||
import { CredentialPage } from "@/pages/credential-page.tsx"
|
||||
@@ -46,6 +47,7 @@ export default function App() {
|
||||
<Route path="/clusters" element={<ClustersPage />} />
|
||||
|
||||
<Route path="/admin/jobs" element={<JobsPage />} />
|
||||
<Route path="/admin/actions" element={<ActionsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="*" element={<Login />} />
|
||||
|
||||
30
ui/src/api/actions.ts
Normal file
30
ui/src/api/actions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { withRefresh } from "@/api/with-refresh.ts"
|
||||
import type { DtoCreateActionRequest, DtoUpdateActionRequest } from "@/sdk"
|
||||
import { makeActionsApi } from "@/sdkClient.ts"
|
||||
|
||||
const actions = makeActionsApi()
|
||||
export const actionsApi = {
|
||||
listActions: () =>
|
||||
withRefresh(async () => {
|
||||
return await actions.listActions()
|
||||
}),
|
||||
createAction: (body: DtoCreateActionRequest) =>
|
||||
withRefresh(async () => {
|
||||
return await actions.createAction({
|
||||
dtoCreateActionRequest: body,
|
||||
})
|
||||
}),
|
||||
updateAction: (id: string, body: DtoUpdateActionRequest) =>
|
||||
withRefresh(async () => {
|
||||
return await actions.updateAction({
|
||||
actionID: id,
|
||||
dtoUpdateActionRequest: body,
|
||||
})
|
||||
}),
|
||||
deleteAction: (id: string) =>
|
||||
withRefresh(async () => {
|
||||
await actions.deleteAction({
|
||||
actionID: id,
|
||||
})
|
||||
}),
|
||||
}
|
||||
@@ -8,9 +8,10 @@ import type {
|
||||
DtoSetKubeconfigRequest,
|
||||
DtoUpdateClusterRequest,
|
||||
} from "@/sdk"
|
||||
import { makeClusterApi } from "@/sdkClient"
|
||||
import { makeClusterApi, makeClusterRunsApi } from "@/sdkClient"
|
||||
|
||||
const clusters = makeClusterApi()
|
||||
const clusterRuns = makeClusterRunsApi()
|
||||
|
||||
export const clustersApi = {
|
||||
// --- basic CRUD ---
|
||||
@@ -147,4 +148,20 @@ export const clustersApi = {
|
||||
withRefresh(async () => {
|
||||
return await clusters.detachNodePool({ clusterID, nodePoolID })
|
||||
}),
|
||||
|
||||
// --- cluster runs / actions ---
|
||||
listClusterRuns: (clusterID: string) =>
|
||||
withRefresh(async () => {
|
||||
return await clusterRuns.listClusterRuns({ clusterID })
|
||||
}),
|
||||
|
||||
getClusterRun: (clusterID: string, runID: string) =>
|
||||
withRefresh(async () => {
|
||||
return await clusterRuns.getClusterRun({ clusterID, runID })
|
||||
}),
|
||||
|
||||
runClusterAction: (clusterID: string, actionID: string) =>
|
||||
withRefresh(async () => {
|
||||
return await clusterRuns.runClusterAction({ clusterID, actionID })
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
FileKey2Icon,
|
||||
KeyRound,
|
||||
LockKeyholeIcon,
|
||||
PickaxeIcon,
|
||||
ServerIcon,
|
||||
SprayCanIcon,
|
||||
TagsIcon,
|
||||
@@ -49,5 +50,6 @@ export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
|
||||
export const adminNav: NavItem[] = [
|
||||
{ to: "/admin/users", label: "Users Admin", icon: Users },
|
||||
{ to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker },
|
||||
{ to: "/admin/actions", label: "Actions Admin", icon: PickaxeIcon},
|
||||
{ to: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" },
|
||||
]
|
||||
|
||||
433
ui/src/pages/actions-page.tsx
Normal file
433
ui/src/pages/actions-page.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { actionsApi } from "@/api/actions.ts"
|
||||
import type { DtoActionResponse } from "@/sdk"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { AlertCircle, CircleSlash2, Loader2, Pencil, Plus, Search, Trash2 } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
import { Badge } from "@/components/ui/badge.tsx"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog.tsx"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form.tsx"
|
||||
import { Input } from "@/components/ui/input.tsx"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx"
|
||||
import { Textarea } from "@/components/ui/textarea.tsx"
|
||||
|
||||
const createActionSchema = z.object({
|
||||
label: z.string().trim().min(1, "Label is required").max(255, "Max 255 chars"),
|
||||
description: z.string().trim().min(1, "Description is required"),
|
||||
make_target: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Make target is required")
|
||||
.max(255, "Max 255 chars")
|
||||
// keep client-side fairly strict to avoid surprises; server should also validate
|
||||
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/, "Invalid make target (allowed: a-z A-Z 0-9 . _ -)"),
|
||||
})
|
||||
type CreateActionInput = z.input<typeof createActionSchema>
|
||||
|
||||
const updateActionSchema = createActionSchema.partial()
|
||||
type UpdateActionInput = z.input<typeof updateActionSchema>
|
||||
|
||||
function TargetBadge({ target }: { target?: string | null }) {
|
||||
if (!target) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
—
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="font-mono text-xs">
|
||||
{target}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export const ActionsPage = () => {
|
||||
const qc = useQueryClient()
|
||||
|
||||
const [filter, setFilter] = useState("")
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [updateOpen, setUpdateOpen] = useState(false)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState<DtoActionResponse | null>(null)
|
||||
|
||||
const actionsQ = useQuery({
|
||||
queryKey: ["admin-actions"],
|
||||
queryFn: () => actionsApi.listActions(),
|
||||
})
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const data: DtoActionResponse[] = actionsQ.data ?? []
|
||||
const q = filter.trim().toLowerCase()
|
||||
if (!q) return data
|
||||
|
||||
return data.filter((a) => {
|
||||
return (
|
||||
(a.label ?? "").toLowerCase().includes(q) ||
|
||||
(a.description ?? "").toLowerCase().includes(q) ||
|
||||
(a.make_target ?? "").toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}, [filter, actionsQ.data])
|
||||
|
||||
const createForm = useForm<CreateActionInput>({
|
||||
resolver: zodResolver(createActionSchema),
|
||||
defaultValues: {
|
||||
label: "",
|
||||
description: "",
|
||||
make_target: "",
|
||||
},
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (values: CreateActionInput) => actionsApi.createAction(values),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
|
||||
createForm.reset()
|
||||
setCreateOpen(false)
|
||||
toast.success("Action created.")
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message ?? "Failed to create action.")
|
||||
},
|
||||
})
|
||||
|
||||
const updateForm = useForm<UpdateActionInput>({
|
||||
resolver: zodResolver(updateActionSchema),
|
||||
defaultValues: {},
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ id, values }: { id: string; values: UpdateActionInput }) =>
|
||||
actionsApi.updateAction(id, values),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
|
||||
updateForm.reset()
|
||||
setUpdateOpen(false)
|
||||
setEditing(null)
|
||||
toast.success("Action updated.")
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message ?? "Failed to update action.")
|
||||
},
|
||||
})
|
||||
|
||||
const openEdit = (a: DtoActionResponse) => {
|
||||
if (!a.id) return
|
||||
setEditing(a)
|
||||
updateForm.reset({
|
||||
label: a.label ?? "",
|
||||
description: a.description ?? "",
|
||||
make_target: a.make_target ?? "",
|
||||
})
|
||||
setUpdateOpen(true)
|
||||
}
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id: string) => actionsApi.deleteAction(id),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
|
||||
setDeleteId(null)
|
||||
toast.success("Action deleted.")
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message ?? "Failed to delete action.")
|
||||
},
|
||||
})
|
||||
|
||||
if (actionsQ.isLoading) return <div className="p-6">Loading actions…</div>
|
||||
if (actionsQ.error) return <div className="p-6 text-red-500">Error loading actions.</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="text-2xl font-bold">Admin Actions</h1>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Search actions"
|
||||
className="w-72 pl-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Action
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Action</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...createForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
|
||||
>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Label</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Setup" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="make_target"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Make Target</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="setup" className="font-mono" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
rows={4}
|
||||
placeholder="Runs prepare, ping-servers, then make setup on the bastion."
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMut.isPending}>
|
||||
{createMut.isPending ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Label</TableHead>
|
||||
<TableHead>Make Target</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead className="w-[260px] text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((a) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell className="font-medium">{a.label}</TableCell>
|
||||
<TableCell>
|
||||
<TargetBadge target={a.make_target} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-[680px] truncate">
|
||||
{a.description}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => openEdit(a)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => a.id && setDeleteId(a.id)}
|
||||
disabled={deleteMut.isPending && deleteId === a.id}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{deleteMut.isPending && deleteId === a.id ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center">
|
||||
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||
No actions match your search.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Update dialog */}
|
||||
<Dialog
|
||||
open={updateOpen}
|
||||
onOpenChange={(open) => {
|
||||
setUpdateOpen(open)
|
||||
if (!open) setEditing(null)
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Action</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{editing ? (
|
||||
<Form {...updateForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={updateForm.handleSubmit((values) => {
|
||||
if (!editing.id) return
|
||||
updateMut.mutate({ id: editing.id, values })
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={updateForm.control}
|
||||
name="label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Label</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={updateForm.control}
|
||||
name="make_target"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Make Target</FormLabel>
|
||||
<FormControl>
|
||||
<Input className="font-mono" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={updateForm.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea rows={4} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => setUpdateOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMut.isPending}>
|
||||
{updateMut.isPending ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Saving…
|
||||
</span>
|
||||
) : (
|
||||
"Save changes"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">No action selected.</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirm dialog */}
|
||||
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete action</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 text-red-500" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
This action cannot be undone. Are you sure you want to delete it?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => deleteId && deleteMut.mutate(deleteId)}
|
||||
disabled={deleteMut.isPending}
|
||||
>
|
||||
{deleteMut.isPending ? "Deleting…" : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
;
|
||||
|
||||
// src/pages/ClustersPage.tsx
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { actionsApi } from "@/api/actions";
|
||||
import { clustersApi } from "@/api/clusters";
|
||||
import { dnsApi } from "@/api/dns";
|
||||
import { loadBalancersApi } from "@/api/loadbalancers";
|
||||
import { nodePoolsApi } from "@/api/node_pools";
|
||||
import { serversApi } from "@/api/servers";
|
||||
import type { DtoClusterResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
|
||||
import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react";
|
||||
@@ -19,45 +16,15 @@ import { z } from "zod";
|
||||
|
||||
|
||||
import { truncateMiddle } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog.tsx";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.tsx";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table.tsx";
|
||||
import { Textarea } from "@/components/ui/textarea.tsx";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
|
||||
|
||||
@@ -77,6 +44,22 @@ type CreateClusterInput = z.input<typeof createClusterSchema>
|
||||
const updateClusterSchema = createClusterSchema.partial()
|
||||
type UpdateClusterValues = z.infer<typeof updateClusterSchema>
|
||||
|
||||
// --- Data normalization helpers (fixes rows.some is not a function) ---
|
||||
|
||||
function asArray<T>(res: any): T[] {
|
||||
if (Array.isArray(res)) return res as T[]
|
||||
if (Array.isArray(res?.data)) return res.data as T[]
|
||||
if (Array.isArray(res?.body)) return res.body as T[]
|
||||
if (Array.isArray(res?.result)) return res.result as T[]
|
||||
return []
|
||||
}
|
||||
|
||||
function asObject<T>(res: any): T {
|
||||
// for get endpoints that might return {data: {...}}
|
||||
if (res?.data && typeof res.data === "object") return res.data as T
|
||||
return res as T
|
||||
}
|
||||
|
||||
// --- UI helpers ---
|
||||
|
||||
function StatusBadge({ status }: { status?: string | null }) {
|
||||
@@ -133,6 +116,61 @@ function StatusBadge({ status }: { status?: string | null }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RunStatusBadge({ status }: { status?: string | null }) {
|
||||
const s = (status ?? "").toLowerCase()
|
||||
|
||||
if (!s)
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
unknown
|
||||
</Badge>
|
||||
)
|
||||
|
||||
if (s === "succeeded" || s === "success") {
|
||||
return (
|
||||
<Badge variant="default" className="flex items-center gap-1 text-xs">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
succeeded
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (s === "failed" || s === "error") {
|
||||
return (
|
||||
<Badge variant="destructive" className="flex items-center gap-1 text-xs">
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
failed
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
if (s === "queued" || s === "running") {
|
||||
return (
|
||||
<Badge variant="secondary" className="flex items-center gap-1 text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
{s}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{s}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function fmtTime(v: any): string {
|
||||
if (!v) return "-"
|
||||
try {
|
||||
const d = v instanceof Date ? v : new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return "-"
|
||||
return d.toLocaleString()
|
||||
} catch {
|
||||
return "-"
|
||||
}
|
||||
}
|
||||
|
||||
function ClusterSummary({ c }: { c: DtoClusterResponse }) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
|
||||
@@ -173,7 +211,7 @@ export const ClustersPage = () => {
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
// Config dialog state
|
||||
// Configure dialog state
|
||||
const [configCluster, setConfigCluster] = useState<DtoClusterResponse | null>(null)
|
||||
|
||||
const [captainDomainId, setCaptainDomainId] = useState("")
|
||||
@@ -193,36 +231,69 @@ export const ClustersPage = () => {
|
||||
|
||||
const clustersQ = useQuery({
|
||||
queryKey: ["clusters"],
|
||||
queryFn: () => clustersApi.listClusters(),
|
||||
queryFn: async () => asArray<DtoClusterResponse>(await clustersApi.listClusters()),
|
||||
})
|
||||
|
||||
const lbsQ = useQuery({
|
||||
queryKey: ["load-balancers"],
|
||||
queryFn: () => loadBalancersApi.listLoadBalancers(),
|
||||
queryFn: async () =>
|
||||
asArray<DtoLoadBalancerResponse>(await loadBalancersApi.listLoadBalancers()),
|
||||
})
|
||||
|
||||
const domainsQ = useQuery({
|
||||
queryKey: ["domains"],
|
||||
queryFn: () => dnsApi.listDomains(),
|
||||
queryFn: async () => asArray<DtoDomainResponse>(await dnsApi.listDomains()),
|
||||
})
|
||||
|
||||
// record sets fetched per captain domain
|
||||
const recordSetsQ = useQuery({
|
||||
queryKey: ["record-sets", captainDomainId],
|
||||
enabled: !!captainDomainId,
|
||||
queryFn: () => dnsApi.listRecordSetsByDomain(captainDomainId),
|
||||
queryFn: async () =>
|
||||
asArray<DtoRecordSetResponse>(await dnsApi.listRecordSetsByDomain(captainDomainId)),
|
||||
})
|
||||
|
||||
const serversQ = useQuery({
|
||||
queryKey: ["servers"],
|
||||
queryFn: () => serversApi.listServers(),
|
||||
queryFn: async () => asArray<DtoServerResponse>(await serversApi.listServers()),
|
||||
})
|
||||
|
||||
const npQ = useQuery({
|
||||
queryKey: ["node-pools"],
|
||||
queryFn: () => nodePoolsApi.listNodePools(),
|
||||
queryFn: async () => asArray<DtoNodePoolResponse>(await nodePoolsApi.listNodePools()),
|
||||
})
|
||||
|
||||
const actionsQ = useQuery({
|
||||
queryKey: ["actions"],
|
||||
queryFn: async () => asArray<DtoActionResponse>(await actionsApi.listActions()),
|
||||
})
|
||||
|
||||
const runsQ = useQuery({
|
||||
queryKey: ["cluster-runs", configCluster?.id],
|
||||
enabled: !!configCluster?.id,
|
||||
queryFn: async () =>
|
||||
asArray<DtoClusterRunResponse>(await clustersApi.listClusterRuns(configCluster!.id!)),
|
||||
refetchInterval: (data) => {
|
||||
// IMPORTANT: data might not be array if queryFn isn't normalizing. But it is here anyway.
|
||||
const rows = Array.isArray(data) ? data : []
|
||||
const active = rows.some((r: any) => {
|
||||
const s = String(r?.status ?? "").toLowerCase()
|
||||
return s === "queued" || s === "running"
|
||||
})
|
||||
return active ? 2000 : false
|
||||
},
|
||||
})
|
||||
|
||||
const actionLabelByTarget = useMemo(() => {
|
||||
const m = new Map<string, string>()
|
||||
;(actionsQ.data ?? []).forEach((a) => {
|
||||
if (a.make_target) m.set(a.make_target, a.label ?? a.make_target)
|
||||
})
|
||||
return m
|
||||
}, [actionsQ.data])
|
||||
|
||||
const runDisplayName = (r: DtoClusterRunResponse) =>
|
||||
actionLabelByTarget.get(r.action ?? "") ?? r.action ?? "unknown"
|
||||
|
||||
// --- Create ---
|
||||
|
||||
const createForm = useForm<CreateClusterInput>({
|
||||
@@ -244,15 +315,10 @@ export const ClustersPage = () => {
|
||||
setCreateOpen(false)
|
||||
toast.success("Cluster created successfully.")
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message ?? "There was an error while creating the cluster")
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast.error(err?.message ?? "There was an error while creating the cluster"),
|
||||
})
|
||||
|
||||
const onCreateSubmit = (values: CreateClusterInput) => {
|
||||
createMut.mutate(values)
|
||||
}
|
||||
|
||||
// --- Update basic details ---
|
||||
|
||||
const updateForm = useForm<UpdateClusterValues>({
|
||||
@@ -269,9 +335,8 @@ export const ClustersPage = () => {
|
||||
setUpdateOpen(false)
|
||||
toast.success("Cluster updated successfully.")
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message ?? "There was an error while updating the cluster")
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast.error(err?.message ?? "There was an error while updating the cluster"),
|
||||
})
|
||||
|
||||
const openEdit = (cluster: DtoClusterResponse) => {
|
||||
@@ -296,11 +361,32 @@ export const ClustersPage = () => {
|
||||
setDeleteId(null)
|
||||
toast.success("Cluster deleted successfully.")
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err?.message ?? "There was an error while deleting the cluster")
|
||||
},
|
||||
onError: (err: any) =>
|
||||
toast.error(err?.message ?? "There was an error while deleting the cluster"),
|
||||
})
|
||||
|
||||
// --- Run Action ---
|
||||
|
||||
const runActionMut = useMutation({
|
||||
mutationFn: ({ clusterID, actionID }: { clusterID: string; actionID: string }) =>
|
||||
clustersApi.runClusterAction(clusterID, actionID),
|
||||
onSuccess: async () => {
|
||||
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster?.id] })
|
||||
toast.success("Action enqueued.")
|
||||
},
|
||||
onError: (err: any) => toast.error(err?.message ?? "Failed to enqueue action."),
|
||||
})
|
||||
|
||||
async function handleRunAction(actionID: string) {
|
||||
if (!configCluster?.id) return
|
||||
setBusyKey(`run:${actionID}`)
|
||||
try {
|
||||
await runActionMut.mutateAsync({ clusterID: configCluster.id, actionID })
|
||||
} finally {
|
||||
setBusyKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filter ---
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -333,30 +419,23 @@ export const ClustersPage = () => {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefill IDs from current attachments
|
||||
if (configCluster.captain_domain?.id) {
|
||||
setCaptainDomainId(configCluster.captain_domain.id)
|
||||
}
|
||||
if (configCluster.control_plane_record_set?.id) {
|
||||
if (configCluster.captain_domain?.id) setCaptainDomainId(configCluster.captain_domain.id)
|
||||
if (configCluster.control_plane_record_set?.id)
|
||||
setRecordSetId(configCluster.control_plane_record_set.id)
|
||||
}
|
||||
if (configCluster.apps_load_balancer?.id) {
|
||||
setAppsLbId(configCluster.apps_load_balancer.id)
|
||||
}
|
||||
if (configCluster.glueops_load_balancer?.id) {
|
||||
if (configCluster.apps_load_balancer?.id) setAppsLbId(configCluster.apps_load_balancer.id)
|
||||
if (configCluster.glueops_load_balancer?.id)
|
||||
setGlueopsLbId(configCluster.glueops_load_balancer.id)
|
||||
}
|
||||
if (configCluster.bastion_server?.id) {
|
||||
setBastionId(configCluster.bastion_server.id)
|
||||
}
|
||||
if (configCluster.bastion_server?.id) setBastionId(configCluster.bastion_server.id)
|
||||
}, [configCluster])
|
||||
|
||||
async function refreshConfigCluster() {
|
||||
if (!configCluster?.id) return
|
||||
try {
|
||||
const updated = await clustersApi.getCluster(configCluster.id)
|
||||
const updatedRaw = await clustersApi.getCluster(configCluster.id)
|
||||
const updated = asObject<DtoClusterResponse>(updatedRaw)
|
||||
setConfigCluster(updated)
|
||||
await qc.invalidateQueries({ queryKey: ["clusters"] })
|
||||
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster.id] })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -364,15 +443,10 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleAttachCaptain() {
|
||||
if (!configCluster?.id) return
|
||||
if (!captainDomainId) {
|
||||
toast.error("Domain is required")
|
||||
return
|
||||
}
|
||||
if (!captainDomainId) return toast.error("Domain is required")
|
||||
setBusyKey("captain")
|
||||
try {
|
||||
await clustersApi.attachCaptainDomain(configCluster.id, {
|
||||
domain_id: captainDomainId,
|
||||
})
|
||||
await clustersApi.attachCaptainDomain(configCluster.id, { domain_id: captainDomainId })
|
||||
toast.success("Captain domain attached.")
|
||||
await refreshConfigCluster()
|
||||
} catch (err: any) {
|
||||
@@ -398,10 +472,7 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleAttachRecordSet() {
|
||||
if (!configCluster?.id) return
|
||||
if (!recordSetId) {
|
||||
toast.error("Record set is required")
|
||||
return
|
||||
}
|
||||
if (!recordSetId) return toast.error("Record set is required")
|
||||
setBusyKey("recordset")
|
||||
try {
|
||||
await clustersApi.attachControlPlaneRecordSet(configCluster.id, {
|
||||
@@ -432,15 +503,10 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleAttachAppsLb() {
|
||||
if (!configCluster?.id) return
|
||||
if (!appsLbId) {
|
||||
toast.error("Load balancer is required")
|
||||
return
|
||||
}
|
||||
if (!appsLbId) return toast.error("Load balancer is required")
|
||||
setBusyKey("apps-lb")
|
||||
try {
|
||||
await clustersApi.attachAppsLoadBalancer(configCluster.id, {
|
||||
load_balancer_id: appsLbId,
|
||||
})
|
||||
await clustersApi.attachAppsLoadBalancer(configCluster.id, { load_balancer_id: appsLbId })
|
||||
toast.success("Apps load balancer attached.")
|
||||
await refreshConfigCluster()
|
||||
} catch (err: any) {
|
||||
@@ -466,10 +532,7 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleAttachGlueopsLb() {
|
||||
if (!configCluster?.id) return
|
||||
if (!glueopsLbId) {
|
||||
toast.error("Load balancer is required")
|
||||
return
|
||||
}
|
||||
if (!glueopsLbId) return toast.error("Load balancer is required")
|
||||
setBusyKey("glueops-lb")
|
||||
try {
|
||||
await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, {
|
||||
@@ -500,15 +563,10 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleAttachBastion() {
|
||||
if (!configCluster?.id) return
|
||||
if (!bastionId) {
|
||||
toast.error("Server is required")
|
||||
return
|
||||
}
|
||||
if (!bastionId) return toast.error("Server is required")
|
||||
setBusyKey("bastion")
|
||||
try {
|
||||
await clustersApi.attachBastion(configCluster.id, {
|
||||
server_id: bastionId,
|
||||
})
|
||||
await clustersApi.attachBastion(configCluster.id, { server_id: bastionId })
|
||||
toast.success("Bastion server attached.")
|
||||
await refreshConfigCluster()
|
||||
} catch (err: any) {
|
||||
@@ -534,10 +592,7 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleAttachNodePool() {
|
||||
if (!configCluster?.id) return
|
||||
if (!nodePoolId) {
|
||||
toast.error("Node pool is required")
|
||||
return
|
||||
}
|
||||
if (!nodePoolId) return toast.error("Node pool is required")
|
||||
setBusyKey("nodepool")
|
||||
try {
|
||||
await clustersApi.attachNodePool(configCluster.id, nodePoolId)
|
||||
@@ -567,15 +622,10 @@ export const ClustersPage = () => {
|
||||
|
||||
async function handleSetKubeconfig() {
|
||||
if (!configCluster?.id) return
|
||||
if (!kubeconfigText.trim()) {
|
||||
toast.error("Kubeconfig is required")
|
||||
return
|
||||
}
|
||||
if (!kubeconfigText.trim()) return toast.error("Kubeconfig is required")
|
||||
setBusyKey("kubeconfig")
|
||||
try {
|
||||
await clustersApi.setKubeconfig(configCluster.id, {
|
||||
kubeconfig: kubeconfigText,
|
||||
})
|
||||
await clustersApi.setKubeconfig(configCluster.id, { kubeconfig: kubeconfigText })
|
||||
toast.success("Kubeconfig updated.")
|
||||
setKubeconfigText("")
|
||||
await refreshConfigCluster()
|
||||
@@ -636,7 +686,10 @@ export const ClustersPage = () => {
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...createForm}>
|
||||
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
|
||||
>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="name"
|
||||
@@ -750,7 +803,7 @@ export const ClustersPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{c.docker_image + ":" + c.docker_tag}</TableCell>
|
||||
<TableCell>{(c.docker_image ?? "") + ":" + (c.docker_tag ?? "")}</TableCell>
|
||||
<TableCell>
|
||||
<ClusterSummary c={c} />
|
||||
{c.id && (
|
||||
@@ -782,7 +835,7 @@ export const ClustersPage = () => {
|
||||
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-muted-foreground py-10 text-center">
|
||||
<TableCell colSpan={7} className="text-muted-foreground py-10 text-center">
|
||||
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
|
||||
No clusters match your search.
|
||||
</TableCell>
|
||||
@@ -799,6 +852,7 @@ export const ClustersPage = () => {
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Cluster</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...updateForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -890,7 +944,7 @@ export const ClustersPage = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Configure dialog (attachments + kubeconfig + node pools) */}
|
||||
{/* Configure dialog (attachments + kubeconfig + node pools + actions/runs) */}
|
||||
<Dialog open={!!configCluster} onOpenChange={(open) => !open && setConfigCluster(null)}>
|
||||
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
@@ -901,26 +955,144 @@ export const ClustersPage = () => {
|
||||
|
||||
{configCluster && (
|
||||
<div className="space-y-6 py-2">
|
||||
{/* Kubeconfig */}
|
||||
{/* Cluster Actions */}
|
||||
<section className="space-y-2 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode2 className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">Kubeconfig</h3>
|
||||
<Wrench className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">Cluster Actions</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Paste the kubeconfig for this cluster. It will be stored encrypted and never
|
||||
returned by the API.
|
||||
Run admin-configured actions on this cluster. Actions are executed
|
||||
asynchronously.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => runsQ.refetch()}
|
||||
disabled={runsQ.isFetching || !configCluster?.id}
|
||||
>
|
||||
{runsQ.isFetching ? "Refreshing…" : "Refresh runs"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{actionsQ.isLoading ? (
|
||||
<p className="text-muted-foreground text-xs">Loading actions…</p>
|
||||
) : (actionsQ.data ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
No actions configured yet. Create actions in Admin → Actions.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-border rounded-md border">
|
||||
{(actionsQ.data ?? []).map((a: DtoActionResponse) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between gap-3 px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{a.label}</span>
|
||||
{a.make_target && (
|
||||
<code className="text-muted-foreground text-xs">
|
||||
{a.make_target}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
{a.description && (
|
||||
<p className="text-muted-foreground line-clamp-2 text-xs">
|
||||
{a.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => a.id && handleRunAction(a.id)}
|
||||
disabled={!a.id || isBusy(`run:${a.id}`)}
|
||||
>
|
||||
{a.id && isBusy(`run:${a.id}`) ? "Enqueueing…" : "Run"}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
<Label className="text-xs">Recent Runs</Label>
|
||||
|
||||
{runsQ.isLoading ? (
|
||||
<p className="text-muted-foreground text-xs">Loading runs…</p>
|
||||
) : (runsQ.data ?? []).length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">No runs yet for this cluster.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Finished</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(runsQ.data ?? []).slice(0, 20).map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="min-w-[220px]">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{runDisplayName(r)}</span>
|
||||
{r.id && (
|
||||
<code className="text-muted-foreground text-xs">
|
||||
{truncateMiddle(r.id, 8)}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<RunStatusBadge status={r.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{fmtTime((r as any).created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{fmtTime((r as any).finished_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{r.error ? truncateMiddle(r.error, 80) : "-"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Kubeconfig */}
|
||||
<section className="space-y-2 rounded-xl border p-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCode2 className="h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">Kubeconfig</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Paste the kubeconfig for this cluster. It will be stored encrypted and never
|
||||
returned by the API.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Textarea
|
||||
value={kubeconfigText}
|
||||
onChange={(e) => setKubeconfigText(e.target.value)}
|
||||
rows={6}
|
||||
placeholder="apiVersion: v1 clusters: - cluster: ..."
|
||||
placeholder={"apiVersion: v1\nclusters:\n - cluster: ..."}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
|
||||
@@ -1005,7 +1177,7 @@ export const ClustersPage = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Control Plane Record Set (shown once we have a captainDomainId) */}
|
||||
{/* Control Plane Record Set */}
|
||||
{captainDomainId && (
|
||||
<section className="space-y-2 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -1242,14 +1414,12 @@ export const ClustersPage = () => {
|
||||
|
||||
{/* Node Pools */}
|
||||
<section className="space-y-2 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Node Pools</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Attach node pools to this cluster. Each node pool may have its own labels,
|
||||
taints, and backing servers.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Node Pools</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Attach node pools to this cluster. Each node pool may have its own labels,
|
||||
taints, and backing servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
@@ -1348,8 +1518,6 @@ export const ClustersPage = () => {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<pre>{JSON.stringify(clustersQ.data, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { orgStore } from "@/auth/org.ts"
|
||||
import { authStore } from "@/auth/store.ts"
|
||||
import {
|
||||
ActionsApi,
|
||||
AnnotationsApi,
|
||||
ArcherAdminApi,
|
||||
AuthApi,
|
||||
ClusterRunsApi,
|
||||
ClustersApi,
|
||||
Configuration,
|
||||
CredentialsApi,
|
||||
@@ -133,3 +135,11 @@ export function makeLoadBalancerApi() {
|
||||
export function makeClusterApi() {
|
||||
return makeApiClient(ClustersApi)
|
||||
}
|
||||
|
||||
export function makeActionsApi() {
|
||||
return makeApiClient(ActionsApi)
|
||||
}
|
||||
|
||||
export function makeClusterRunsApi() {
|
||||
return makeApiClient(ClusterRunsApi)
|
||||
}
|
||||
Reference in New Issue
Block a user