mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 12:50:05 +01:00
feat: Complete AG Loadbalancer & Cluster API
Refactor routing logic (Chi can be a pain when you're managing large sets of routes, but its one of the better options when considering a potential gRPC future)
Upgrade API Generation to fully support OAS3.1
Update swagger interface to RapiDoc - the old swagger interface doesnt support OAS3.1 yet
Docs are now embedded as part of the UI - once logged in they pick up the cookies and org id from what gets set by the UI, but you can override it
Other updates include better portability of the db-studio
Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import { AnnotationPage } from "@/pages/annotations/annotation-page.tsx"
|
||||
import { Login } from "@/pages/auth/login.tsx"
|
||||
import { CredentialPage } from "@/pages/credentials/credential-page.tsx"
|
||||
import { DnsPage } from "@/pages/dns/dns-page.tsx"
|
||||
import { DocsPage } from "@/pages/docs/docs-page.tsx"
|
||||
import { JobsPage } from "@/pages/jobs/jobs-page.tsx"
|
||||
import { LabelsPage } from "@/pages/labels/labels-page.tsx"
|
||||
import { MePage } from "@/pages/me/me-page.tsx"
|
||||
@@ -21,6 +22,8 @@ export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/docs" element={<DocsPage />} />
|
||||
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route element={<AppShell />}>
|
||||
<Route path="/me" element={<MePage />} />
|
||||
|
||||
@@ -4,14 +4,6 @@ import { makeArcherAdminApi } from "@/sdkClient.ts"
|
||||
|
||||
const archerAdmin = makeArcherAdminApi()
|
||||
|
||||
type ListParams = {
|
||||
status?: "queued" | "running" | "succeeded" | "failed" | "canceled" | "retrying" | "scheduled"
|
||||
queue?: string
|
||||
q?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const archerAdminApi = {
|
||||
listJobs: (params: AdminListArcherJobsRequest = {}) => {
|
||||
return withRefresh(async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { orgStore } from "@/auth/org.ts"
|
||||
import { Footer } from "@/layouts/footer.tsx"
|
||||
import { adminNav, mainNav, orgNav, userNav } from "@/layouts/nav-config.ts"
|
||||
import { OrgSwitcher } from "@/layouts/org-switcher.tsx"
|
||||
import { ThemePillSwitcher } from "@/layouts/theme-switcher"
|
||||
import { Topbar } from "@/layouts/topbar.tsx"
|
||||
import { NavLink, Outlet } from "react-router-dom"
|
||||
|
||||
@@ -147,6 +148,7 @@ export const AppShell = () => {
|
||||
<SidebarMenuButton asChild tooltip={n.label}>
|
||||
<NavLink
|
||||
to={n.to}
|
||||
target={n.target ? n.target : "_self"}
|
||||
className={({ isActive }) =>
|
||||
cn("flex items-center gap-2", isActive && "text-primary")
|
||||
}
|
||||
@@ -160,6 +162,9 @@ export const AppShell = () => {
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<div className="mt-auto flex items-center justify-center p-3">
|
||||
<ThemePillSwitcher />
|
||||
</div>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -33,7 +33,7 @@ function asClipboardText(v?: VersionInfo) {
|
||||
return `v${v.version} (${shortCommit(v.commit)}) • built ${v.built} • ${v.go} ${v.goOS}/${v.goArch}`
|
||||
}
|
||||
|
||||
export const Footer = memo(function Footer({ className }: { className?: string }) {
|
||||
export const Footer = memo(function Footer() {
|
||||
const footerQ = useQuery({
|
||||
queryKey: ["footer"],
|
||||
queryFn: () => metaApi.footer() as Promise<VersionInfo>,
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
import { AiOutlineCluster } from "react-icons/ai"
|
||||
import { GrUserWorker } from "react-icons/gr"
|
||||
import { MdOutlineDns } from "react-icons/md"
|
||||
import { SiSwagger } from "react-icons/si"
|
||||
|
||||
export type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
target?: string
|
||||
}
|
||||
|
||||
export const mainNav: NavItem[] = [
|
||||
@@ -45,4 +47,5 @@ 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: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" },
|
||||
]
|
||||
|
||||
78
ui/src/layouts/theme-switcher.tsx
Normal file
78
ui/src/layouts/theme-switcher.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type ComponentType } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Monitor, Moon, Sun } from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ThemeValue = "light" | "dark" | "system"
|
||||
|
||||
const options: { id: ThemeValue; icon: ComponentType<{ className?: string }>; label: string }[] = [
|
||||
{ id: "light", icon: Sun, label: "Light" },
|
||||
{ id: "dark", icon: Moon, label: "Dark" },
|
||||
{ id: "system", icon: Monitor, label: "System" },
|
||||
]
|
||||
|
||||
interface ThemePillSwitcherProps {
|
||||
className?: string
|
||||
variant?: "pill" | "wide"
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export const ThemePillSwitcher = ({
|
||||
className = "",
|
||||
variant = "pill",
|
||||
ariaLabel = "Toggle theme",
|
||||
}: ThemePillSwitcherProps) => {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const currentTheme = (theme ?? "system") as ThemeValue
|
||||
const isPill = variant === "pill"
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center",
|
||||
isPill && "bg-muted/70 rounded-full p-1 text-xs shadow-sm",
|
||||
!isPill && "gap-2",
|
||||
className
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
role="radiogroup"
|
||||
>
|
||||
{options.map(({ id, icon: Icon, label }) => {
|
||||
const isActive = currentTheme === id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
onClick={() => setTheme(id)}
|
||||
aria-label={isPill ? label : undefined}
|
||||
className={cn(
|
||||
"focus-visible:ring-ring focus-visible:ring-offset-background relative flex items-center justify-center rounded-full transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
|
||||
isActive ? "text-foreground border" : "text-muted-foreground hover:text-foreground",
|
||||
|
||||
// --- Conditional Classes ---
|
||||
// "pill" variant is a fixed 8x8 square
|
||||
isPill && "h-8 w-8",
|
||||
// "wide" variant has padding, a gap, and auto width
|
||||
!isPill && "h-8 gap-2 px-3 text-sm font-medium"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.span
|
||||
layoutId="theme-switcher-pill"
|
||||
className="bg-background absolute inset-0 rounded-full shadow-sm"
|
||||
transition={{ type: "spring", stiffness: 350, damping: 26 }}
|
||||
/>
|
||||
)}
|
||||
<Icon className="relative z-10 h-4 w-4" />
|
||||
{!isPill && <span className="relative z-10">{label}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react"
|
||||
import { ThemePillSwitcher } from "@/layouts/theme-switcher"
|
||||
import { Link, useLocation } from "react-router-dom"
|
||||
|
||||
import { useMe } from "@/hooks/use-me.ts"
|
||||
@@ -69,6 +70,7 @@ export const Topbar = () => {
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
|
||||
<ThemePillSwitcher variant="wide" />
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/me">{isLoading ? "…" : me?.display_name || "Profile"}</Link>
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { annotationsApi } from "@/api/annotations.ts"
|
||||
import { labelsApi } from "@/api/labels.ts"
|
||||
import type { DtoLabelResponse } from "@/sdk"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
@@ -1,66 +1,28 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { credentialsApi } from "@/api/credentials"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import {
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react"
|
||||
import { Controller, useForm } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import { useMemo, useState } from "react";
|
||||
import { credentialsApi } from "@/api/credentials";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Eye, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
|
||||
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
// -------------------- Constants --------------------
|
||||
|
||||
@@ -192,7 +154,9 @@ function extractErr(e: any): string {
|
||||
try {
|
||||
const msg = (e as any)?.response?.data?.message || (e as any)?.message
|
||||
if (msg) return String(msg)
|
||||
} catch {}
|
||||
} catch {
|
||||
return "Unknown error"
|
||||
}
|
||||
return "Unknown error"
|
||||
}
|
||||
|
||||
|
||||
@@ -224,9 +224,12 @@ export const DnsPage = () => {
|
||||
const r53Credentials = useMemo(() => (credentialQ.data ?? []).filter(isR53), [credentialQ.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected && domainsQ.data && domainsQ.data.length) {
|
||||
setSelected(domainsQ.data[0]!)
|
||||
const setSelectedDns = () => {
|
||||
if (!selected && domainsQ.data && domainsQ.data.length) {
|
||||
setSelected(domainsQ.data[0]!)
|
||||
}
|
||||
}
|
||||
setSelectedDns()
|
||||
}, [domainsQ.data, selected])
|
||||
|
||||
const filteredDomains = useMemo(() => {
|
||||
|
||||
171
ui/src/pages/docs/docs-page.tsx
Normal file
171
ui/src/pages/docs/docs-page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef, useState, type FC } from "react"
|
||||
import { useTheme } from "next-themes"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
import "rapidoc"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card.tsx"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx"
|
||||
|
||||
type RdThemeMode = "auto" | "light" | "dark"
|
||||
|
||||
export const DocsPage: FC = () => {
|
||||
const rdRef = useRef<any>(null)
|
||||
const { theme, systemTheme, setTheme } = useTheme()
|
||||
|
||||
const [orgId, setOrgId] = useState("")
|
||||
const [rdThemeMode, setRdThemeMode] = useState<RdThemeMode>("auto")
|
||||
|
||||
useEffect(() => {
|
||||
const stateSetter = () => {
|
||||
const stored = localStorage.getItem("autoglue.org")
|
||||
if (stored) setOrgId(stored)
|
||||
}
|
||||
|
||||
stateSetter()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const rd = rdRef.current
|
||||
if (!rd) return
|
||||
|
||||
let effectiveTheme: "light" | "dark" = "light"
|
||||
if (rdThemeMode === "light") {
|
||||
effectiveTheme = "light"
|
||||
} else if (rdThemeMode === "dark") {
|
||||
effectiveTheme = "dark"
|
||||
} else {
|
||||
const appTheme = theme === "system" ? systemTheme : theme
|
||||
effectiveTheme = appTheme === "dark" ? "dark" : "light"
|
||||
}
|
||||
|
||||
rd.setAttribute("theme", effectiveTheme)
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const defaultServer = `${window.location.origin}/api/v1`
|
||||
rd.setAttribute("default-api-server", defaultServer)
|
||||
}
|
||||
|
||||
if (orgId) {
|
||||
rd.setAttribute("api-key-name", "X-ORG-ID")
|
||||
rd.setAttribute("api-key-location", "header")
|
||||
rd.setAttribute("api-key-value", orgId)
|
||||
} else {
|
||||
rd.removeAttribute("api-key-value")
|
||||
}
|
||||
}, [theme, systemTheme, rdThemeMode, orgId])
|
||||
|
||||
const handleSaveOrg = () => {
|
||||
const trimmed = orgId.trim()
|
||||
localStorage.setItem("autoglue.org", trimmed)
|
||||
const rd = rdRef.current
|
||||
if (!rd) return
|
||||
|
||||
if (trimmed) {
|
||||
rd.setAttribute("api-key-value", trimmed)
|
||||
} else {
|
||||
rd.removeAttribute("api-key-value")
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetOrg = () => {
|
||||
localStorage.removeItem("autoglue.org")
|
||||
setOrgId("")
|
||||
const rd = rdRef.current
|
||||
if (!rd) return
|
||||
rd.removeAttribute("api-key-value")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[100svh] flex-col">
|
||||
{/* Control bar */}
|
||||
<Card className="rounded-none border-b">
|
||||
<CardHeader className="py-3">
|
||||
<CardTitle className="flex flex-wrap items-center justify-between gap-4 text-base">
|
||||
<span>AutoGlue API Docs</span>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs">
|
||||
{/* Theme selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Docs theme</span>
|
||||
<Select
|
||||
value={rdThemeMode}
|
||||
onValueChange={(v) => {
|
||||
const mode = v as RdThemeMode
|
||||
setRdThemeMode(mode)
|
||||
|
||||
if (mode === "auto") {
|
||||
setTheme("system")
|
||||
} else {
|
||||
setTheme(v)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (match app)</SelectItem>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Org ID controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Org ID (X-ORG-ID)</span>
|
||||
<Input
|
||||
className="h-8 w-80"
|
||||
value={orgId}
|
||||
onChange={(e) => setOrgId(e.target.value)}
|
||||
placeholder="org_..."
|
||||
/>
|
||||
<Button size="sm" onClick={handleSaveOrg}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleResetOrg}>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-muted-foreground py-0 pb-2 text-xs">
|
||||
Requests from <code><rapi-doc></code> will include:
|
||||
<code className="ml-1">Cookie: ag_jwt=…</code> and{" "}
|
||||
<code className="ml-1">X-ORG-ID={orgId}</code>
|
||||
{!orgId && <> (set an Org ID above to send an X-ORG-ID header)</>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* @ts-expect-error ts-2339 */}
|
||||
<rapi-doc
|
||||
ref={rdRef}
|
||||
id="autoglue-docs"
|
||||
spec-url="/swagger/swagger.json"
|
||||
render-style="read"
|
||||
show-header="false"
|
||||
persist-auth="true"
|
||||
allow-advanced-search="true"
|
||||
schema-description-expanded="true"
|
||||
allow-schema-description-expand-toggle="false"
|
||||
allow-spec-file-download="true"
|
||||
allow-spec-file-load="false"
|
||||
allow-spec-url-load="false"
|
||||
allow-try="true"
|
||||
schema-style="tree"
|
||||
fetch-credentials="include"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { credentialsApi } from "@/api/credentials.ts"
|
||||
import { useEffect } from "react"
|
||||
import { withRefresh } from "@/api/with-refresh.ts"
|
||||
import { orgStore } from "@/auth/org.ts"
|
||||
import type { DtoCredentialOut } from "@/sdk"
|
||||
import { makeOrgsApi } from "@/sdkClient.ts"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
@@ -22,6 +20,7 @@ import {
|
||||
} from "@/components/ui/form.tsx"
|
||||
import { Input } from "@/components/ui/input.tsx"
|
||||
|
||||
/*
|
||||
const isS3 = (c: DtoCredentialOut) =>
|
||||
c.provider === "aws" &&
|
||||
c.scope_kind === "service" &&
|
||||
@@ -35,6 +34,7 @@ const isS3 = (c: DtoCredentialOut) =>
|
||||
return false
|
||||
}
|
||||
})()
|
||||
*/
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Required"),
|
||||
@@ -54,13 +54,14 @@ export const OrgSettings = () => {
|
||||
queryFn: () => withRefresh(() => api.getOrg({ id: orgId! })),
|
||||
})
|
||||
|
||||
/*
|
||||
const credentialQ = useQuery({
|
||||
queryKey: ["credentials", "s3"],
|
||||
queryFn: () => credentialsApi.listCredentials(), // client-side filter
|
||||
})
|
||||
|
||||
const s3Credentials = useMemo(() => (credentialQ.data ?? []).filter(isS3), [credentialQ.data])
|
||||
|
||||
*/
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
@@ -76,7 +77,7 @@ export const OrgSettings = () => {
|
||||
domain: q.data.domain ?? "",
|
||||
})
|
||||
}
|
||||
}, [q.data])
|
||||
}, [q.data, form])
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (v: Partial<Values>) => api.updateOrg({ id: orgId!, body: v }),
|
||||
|
||||
@@ -5,7 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { Plus, Search } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
@@ -136,8 +136,17 @@ export const ServerPage = () => {
|
||||
mode: "onChange",
|
||||
})
|
||||
|
||||
const roleIsBastion = createForm.watch("role") === "bastion"
|
||||
const pubCreate = createForm.watch("public_ip_address")?.trim() ?? ""
|
||||
const watchedRoleCreate = useWatch({
|
||||
control: createForm.control,
|
||||
name: "role",
|
||||
})
|
||||
const roleIsBastion = watchedRoleCreate === "bastion"
|
||||
|
||||
const watchedPublicIpCreate = useWatch({
|
||||
control: createForm.control,
|
||||
name: "public_ip_address",
|
||||
})
|
||||
const pubCreate = watchedPublicIpCreate?.trim() ?? ""
|
||||
const needPubCreate = roleIsBastion && pubCreate === ""
|
||||
|
||||
const createMut = useMutation({
|
||||
@@ -160,8 +169,19 @@ export const ServerPage = () => {
|
||||
mode: "onChange",
|
||||
})
|
||||
|
||||
const roleIsBastionU = updateForm.watch("role") === "bastion"
|
||||
const pubUpdate = updateForm.watch("public_ip_address")?.trim() ?? ""
|
||||
const watchedRoleUpdate = useWatch({
|
||||
control: updateForm.control,
|
||||
name: "role",
|
||||
})
|
||||
|
||||
const watchedPublicIpAddressUpdate = useWatch({
|
||||
control: updateForm.control,
|
||||
name: "public_ip_address",
|
||||
})
|
||||
|
||||
const roleIsBastionU = watchedRoleUpdate === "bastion"
|
||||
|
||||
const pubUpdate = watchedPublicIpAddressUpdate?.trim() ?? ""
|
||||
const needPubUpdate = roleIsBastionU && pubUpdate === ""
|
||||
|
||||
const updateMut = useMutation({
|
||||
|
||||
@@ -4,11 +4,10 @@ import type { DtoCreateSSHRequest, DtoSshRevealResponse } from "@/sdk"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { Download, Eye, Loader2, Plus, Trash2 } from "lucide-react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useForm, useWatch } from "react-hook-form"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
|
||||
import { truncateMiddle } from "@/lib/utils.ts"
|
||||
import { Badge } from "@/components/ui/badge.tsx"
|
||||
import { Button } from "@/components/ui/button.tsx"
|
||||
import {
|
||||
@@ -105,6 +104,11 @@ export const SshPage = () => {
|
||||
},
|
||||
})
|
||||
|
||||
const watchedType = useWatch({
|
||||
control: form.control,
|
||||
name: "type",
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (values: CreateKeyInput) => {
|
||||
const payload: DtoCreateSSHRequest = {
|
||||
@@ -257,7 +261,7 @@ export const SshPage = () => {
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
disabled={form.watch("type") === "ed25519"}
|
||||
disabled={watchedType === "ed25519"}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
@@ -316,7 +320,6 @@ export const SshPage = () => {
|
||||
<TableBody>
|
||||
{filtered.map((k) => {
|
||||
const keyType = getKeyType(k.public_key!)
|
||||
const truncated = truncateMiddle(k.public_key!, 18)
|
||||
return (
|
||||
<TableRow key={k.id}>
|
||||
<TableCell className="font-medium">{k.name || "—"}</TableCell>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { type ReactNode } from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
|
||||
export type Theme = "light" | "dark" | "system"
|
||||
|
||||
30
ui/src/types/rapidoc.d.ts
vendored
Normal file
30
ui/src/types/rapidoc.d.ts
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
import type React from "react"
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"rapi-doc": React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement> & {
|
||||
"spec-url"?: string
|
||||
"render-style"?: string
|
||||
theme?: string
|
||||
"show-header"?: string | boolean
|
||||
"persist-auth"?: string | boolean
|
||||
"allow-advanced-search"?: string | boolean
|
||||
"schema-description-expanded"?: string | boolean
|
||||
"allow-schema-description-expand-toggle"?: string | boolean
|
||||
"allow-spec-file-download"?: string | boolean
|
||||
"allow-spec-file-load"?: string | boolean
|
||||
"allow-spec-url-load"?: string | boolean
|
||||
"allow-try"?: string | boolean
|
||||
"schema-style"?: string
|
||||
"fetch-credentials"?: string
|
||||
"default-api-server"?: string
|
||||
"api-key-name"?: string
|
||||
"api-key-location"?: string
|
||||
"api-key-value"?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
Reference in New Issue
Block a user