2026-02-04 23:07:22 +05:30
|
|
|
"use client";
|
|
|
|
|
|
2026-02-04 23:43:40 +05:30
|
|
|
import { useMemo, useState } from "react";
|
2026-02-04 23:07:22 +05:30
|
|
|
import Link from "next/link";
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
|
2026-02-04 23:07:22 +05:30
|
|
|
import {
|
|
|
|
|
type ColumnDef,
|
|
|
|
|
type SortingState,
|
|
|
|
|
flexRender,
|
|
|
|
|
getCoreRowModel,
|
|
|
|
|
getSortedRowModel,
|
|
|
|
|
useReactTable,
|
|
|
|
|
} from "@tanstack/react-table";
|
2026-02-04 23:43:40 +05:30
|
|
|
import { useQueryClient } from "@tanstack/react-query";
|
2026-02-04 23:07:22 +05:30
|
|
|
|
|
|
|
|
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
|
|
|
|
|
import { DashboardShell } from "@/components/templates/DashboardShell";
|
2026-02-04 23:43:40 +05:30
|
|
|
import { Button, buttonVariants } from "@/components/ui/button";
|
2026-02-04 23:07:22 +05:30
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog";
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
import { ApiError } from "@/api/mutator";
|
|
|
|
|
import {
|
|
|
|
|
type listGatewaysApiV1GatewaysGetResponse,
|
|
|
|
|
getListGatewaysApiV1GatewaysGetQueryKey,
|
|
|
|
|
useDeleteGatewayApiV1GatewaysGatewayIdDelete,
|
|
|
|
|
useListGatewaysApiV1GatewaysGet,
|
|
|
|
|
} from "@/api/generated/gateways/gateways";
|
|
|
|
|
import type { GatewayRead } from "@/api/generated/model";
|
2026-02-04 23:07:22 +05:30
|
|
|
|
|
|
|
|
const truncate = (value?: string | null, max = 24) => {
|
|
|
|
|
if (!value) return "—";
|
|
|
|
|
if (value.length <= max) return value;
|
|
|
|
|
return `${value.slice(0, max)}…`;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const formatTimestamp = (value?: string | null) => {
|
|
|
|
|
if (!value) return "—";
|
|
|
|
|
const date = new Date(`${value}${value.endsWith("Z") ? "" : "Z"}`);
|
|
|
|
|
if (Number.isNaN(date.getTime())) return "—";
|
|
|
|
|
return date.toLocaleString(undefined, {
|
|
|
|
|
month: "short",
|
|
|
|
|
day: "numeric",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit",
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function GatewaysPage() {
|
2026-02-06 16:12:04 +05:30
|
|
|
const { isSignedIn } = useAuth();
|
2026-02-04 23:43:40 +05:30
|
|
|
const queryClient = useQueryClient();
|
2026-02-04 23:07:22 +05:30
|
|
|
const [sorting, setSorting] = useState<SortingState>([
|
|
|
|
|
{ id: "name", desc: false },
|
|
|
|
|
]);
|
2026-02-06 16:12:04 +05:30
|
|
|
const [deleteTarget, setDeleteTarget] = useState<GatewayRead | null>(null);
|
|
|
|
|
|
|
|
|
|
const gatewaysKey = getListGatewaysApiV1GatewaysGetQueryKey();
|
|
|
|
|
const gatewaysQuery = useListGatewaysApiV1GatewaysGet<
|
|
|
|
|
listGatewaysApiV1GatewaysGetResponse,
|
|
|
|
|
ApiError
|
|
|
|
|
>({
|
|
|
|
|
query: {
|
|
|
|
|
enabled: Boolean(isSignedIn),
|
2026-02-04 23:43:40 +05:30
|
|
|
refetchInterval: 30_000,
|
|
|
|
|
refetchOnMount: "always",
|
2026-02-06 16:12:04 +05:30
|
|
|
},
|
|
|
|
|
});
|
2026-02-04 23:07:22 +05:30
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
const gateways = useMemo(() => gatewaysQuery.data?.data ?? [], [gatewaysQuery.data]);
|
2026-02-04 23:43:40 +05:30
|
|
|
const sortedGateways = useMemo(() => [...gateways], [gateways]);
|
2026-02-04 23:07:22 +05:30
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
const deleteMutation = useDeleteGatewayApiV1GatewaysGatewayIdDelete<
|
|
|
|
|
ApiError,
|
|
|
|
|
{ previous?: listGatewaysApiV1GatewaysGetResponse }
|
2026-02-04 23:43:40 +05:30
|
|
|
>(
|
|
|
|
|
{
|
2026-02-06 16:12:04 +05:30
|
|
|
mutation: {
|
|
|
|
|
onMutate: async ({ gatewayId }) => {
|
|
|
|
|
await queryClient.cancelQueries({ queryKey: gatewaysKey });
|
|
|
|
|
const previous =
|
|
|
|
|
queryClient.getQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey);
|
|
|
|
|
if (previous) {
|
|
|
|
|
queryClient.setQueryData<listGatewaysApiV1GatewaysGetResponse>(gatewaysKey, {
|
|
|
|
|
...previous,
|
|
|
|
|
data: previous.data.filter((gateway) => gateway.id !== gatewayId),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return { previous };
|
|
|
|
|
},
|
|
|
|
|
onError: (_error, _gateway, context) => {
|
|
|
|
|
if (context?.previous) {
|
|
|
|
|
queryClient.setQueryData(gatewaysKey, context.previous);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setDeleteTarget(null);
|
|
|
|
|
},
|
|
|
|
|
onSettled: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: gatewaysKey });
|
|
|
|
|
},
|
2026-02-04 23:43:40 +05:30
|
|
|
},
|
2026-02-06 16:12:04 +05:30
|
|
|
},
|
|
|
|
|
queryClient
|
2026-02-04 23:43:40 +05:30
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
if (!deleteTarget) return;
|
2026-02-06 16:12:04 +05:30
|
|
|
deleteMutation.mutate({ gatewayId: deleteTarget.id });
|
2026-02-04 23:07:22 +05:30
|
|
|
};
|
|
|
|
|
|
2026-02-06 16:12:04 +05:30
|
|
|
const columns = useMemo<ColumnDef<GatewayRead>[]>(
|
2026-02-04 23:07:22 +05:30
|
|
|
() => [
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "name",
|
|
|
|
|
header: "Gateway",
|
|
|
|
|
cell: ({ row }) => (
|
2026-02-04 23:54:31 +05:30
|
|
|
<Link
|
|
|
|
|
href={`/gateways/${row.original.id}`}
|
|
|
|
|
className="group block"
|
|
|
|
|
>
|
|
|
|
|
<p className="text-sm font-medium text-slate-900 group-hover:text-blue-600">
|
2026-02-04 23:07:22 +05:30
|
|
|
{row.original.name}
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-xs text-slate-500">
|
|
|
|
|
{truncate(row.original.url, 36)}
|
|
|
|
|
</p>
|
2026-02-04 23:54:31 +05:30
|
|
|
</Link>
|
2026-02-04 23:07:22 +05:30
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "main_session_key",
|
|
|
|
|
header: "Main session",
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<span className="text-sm text-slate-700">
|
|
|
|
|
{truncate(row.original.main_session_key, 24)}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "workspace_root",
|
|
|
|
|
header: "Workspace root",
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<span className="text-sm text-slate-700">
|
|
|
|
|
{truncate(row.original.workspace_root, 28)}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "skyll_enabled",
|
|
|
|
|
header: "Skyll",
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<span className="text-sm text-slate-700">
|
|
|
|
|
{row.original.skyll_enabled ? "Enabled" : "Off"}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: "updated_at",
|
|
|
|
|
header: "Updated",
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<span className="text-sm text-slate-700">
|
|
|
|
|
{formatTimestamp(row.original.updated_at)}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "actions",
|
|
|
|
|
header: "",
|
|
|
|
|
cell: ({ row }) => (
|
2026-02-04 23:43:40 +05:30
|
|
|
<div className="flex justify-end gap-2">
|
|
|
|
|
<Link
|
|
|
|
|
href={`/gateways/${row.original.id}/edit`}
|
|
|
|
|
className={buttonVariants({ variant: "ghost", size: "sm" })}
|
|
|
|
|
>
|
|
|
|
|
Edit
|
|
|
|
|
</Link>
|
|
|
|
|
<Button
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setDeleteTarget(row.original)}
|
|
|
|
|
>
|
|
|
|
|
Delete
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-02-04 23:07:22 +05:30
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[]
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-05 00:11:48 +05:30
|
|
|
// eslint-disable-next-line react-hooks/incompatible-library
|
2026-02-04 23:07:22 +05:30
|
|
|
const table = useReactTable({
|
|
|
|
|
data: sortedGateways,
|
|
|
|
|
columns,
|
|
|
|
|
state: { sorting },
|
|
|
|
|
onSortingChange: setSorting,
|
|
|
|
|
getCoreRowModel: getCoreRowModel(),
|
|
|
|
|
getSortedRowModel: getSortedRowModel(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<DashboardShell>
|
|
|
|
|
<SignedOut>
|
|
|
|
|
<div className="col-span-2 flex min-h-[calc(100vh-64px)] items-center justify-center bg-slate-50 p-10 text-center">
|
|
|
|
|
<div className="rounded-xl border border-slate-200 bg-white px-8 py-6 shadow-sm">
|
|
|
|
|
<p className="text-sm text-slate-600">Sign in to view gateways.</p>
|
|
|
|
|
<SignInButton mode="modal" forceRedirectUrl="/gateways">
|
|
|
|
|
<Button className="mt-4">Sign in</Button>
|
|
|
|
|
</SignInButton>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</SignedOut>
|
|
|
|
|
<SignedIn>
|
|
|
|
|
<DashboardSidebar />
|
|
|
|
|
<main className="flex-1 overflow-y-auto bg-slate-50">
|
2026-02-04 23:43:40 +05:30
|
|
|
<div className="sticky top-0 z-30 border-b border-slate-200 bg-white">
|
|
|
|
|
<div className="px-8 py-6">
|
|
|
|
|
<div className="flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-2xl font-semibold tracking-tight text-slate-900">
|
|
|
|
|
Gateways
|
|
|
|
|
</h1>
|
|
|
|
|
<p className="mt-1 text-sm text-slate-500">
|
|
|
|
|
Manage OpenClaw gateway connections used by boards
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
{gateways.length > 0 ? (
|
|
|
|
|
<Link
|
|
|
|
|
href="/gateways/new"
|
|
|
|
|
className={buttonVariants({ size: "md", variant: "primary" })}
|
|
|
|
|
>
|
|
|
|
|
Create gateway
|
|
|
|
|
</Link>
|
|
|
|
|
) : null}
|
2026-02-04 23:07:22 +05:30
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-04 23:43:40 +05:30
|
|
|
</div>
|
2026-02-04 23:07:22 +05:30
|
|
|
|
|
|
|
|
<div className="p-8">
|
2026-02-04 23:43:40 +05:30
|
|
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
2026-02-04 23:07:22 +05:30
|
|
|
<div className="overflow-x-auto">
|
2026-02-04 23:43:40 +05:30
|
|
|
<table className="w-full text-left text-sm">
|
|
|
|
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
2026-02-04 23:07:22 +05:30
|
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
|
|
|
<tr key={headerGroup.id}>
|
|
|
|
|
{headerGroup.headers.map((header) => (
|
2026-02-04 23:43:40 +05:30
|
|
|
<th key={header.id} className="px-6 py-3">
|
2026-02-04 23:07:22 +05:30
|
|
|
{header.isPlaceholder
|
|
|
|
|
? null
|
|
|
|
|
: flexRender(
|
|
|
|
|
header.column.columnDef.header,
|
|
|
|
|
header.getContext()
|
|
|
|
|
)}
|
|
|
|
|
</th>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-slate-100">
|
2026-02-04 23:43:40 +05:30
|
|
|
{gatewaysQuery.isLoading ? (
|
|
|
|
|
<tr>
|
|
|
|
|
<td colSpan={columns.length} className="px-6 py-8">
|
|
|
|
|
<span className="text-sm text-slate-500">Loading…</span>
|
|
|
|
|
</td>
|
2026-02-04 23:07:22 +05:30
|
|
|
</tr>
|
2026-02-04 23:43:40 +05:30
|
|
|
) : table.getRowModel().rows.length ? (
|
|
|
|
|
table.getRowModel().rows.map((row) => (
|
|
|
|
|
<tr key={row.id} className="hover:bg-slate-50">
|
|
|
|
|
{row.getVisibleCells().map((cell) => (
|
|
|
|
|
<td key={cell.id} className="px-6 py-4">
|
|
|
|
|
{flexRender(
|
|
|
|
|
cell.column.columnDef.cell,
|
|
|
|
|
cell.getContext()
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
))}
|
|
|
|
|
</tr>
|
|
|
|
|
))
|
|
|
|
|
) : (
|
|
|
|
|
<tr>
|
|
|
|
|
<td colSpan={columns.length} className="px-6 py-16">
|
|
|
|
|
<div className="flex flex-col items-center justify-center text-center">
|
|
|
|
|
<div className="mb-4 rounded-full bg-slate-50 p-4">
|
|
|
|
|
<svg
|
|
|
|
|
className="h-16 w-16 text-slate-300"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
strokeWidth="1.5"
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
>
|
|
|
|
|
<rect
|
|
|
|
|
x="2"
|
|
|
|
|
y="7"
|
|
|
|
|
width="20"
|
|
|
|
|
height="14"
|
|
|
|
|
rx="2"
|
|
|
|
|
ry="2"
|
|
|
|
|
/>
|
|
|
|
|
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
|
|
|
|
No gateways yet
|
|
|
|
|
</h3>
|
|
|
|
|
<p className="mb-6 max-w-md text-sm text-slate-500">
|
|
|
|
|
Create your first gateway to connect boards and
|
|
|
|
|
start managing your OpenClaw connections.
|
|
|
|
|
</p>
|
|
|
|
|
<Link
|
|
|
|
|
href="/gateways/new"
|
|
|
|
|
className={buttonVariants({ size: "md", variant: "primary" })}
|
|
|
|
|
>
|
|
|
|
|
Create your first gateway
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
)}
|
2026-02-04 23:07:22 +05:30
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-04 23:43:40 +05:30
|
|
|
{gatewaysQuery.error ? (
|
|
|
|
|
<p className="mt-4 text-sm text-red-500">
|
|
|
|
|
{gatewaysQuery.error.message}
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
2026-02-04 23:07:22 +05:30
|
|
|
</div>
|
|
|
|
|
</main>
|
|
|
|
|
</SignedIn>
|
|
|
|
|
|
|
|
|
|
<Dialog open={Boolean(deleteTarget)} onOpenChange={() => setDeleteTarget(null)}>
|
|
|
|
|
<DialogContent>
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle>Delete gateway?</DialogTitle>
|
|
|
|
|
<DialogDescription>
|
|
|
|
|
This removes the gateway connection from Mission Control. Boards
|
|
|
|
|
using it will need a new gateway assigned.
|
|
|
|
|
</DialogDescription>
|
|
|
|
|
</DialogHeader>
|
2026-02-04 23:43:40 +05:30
|
|
|
{deleteMutation.error ? (
|
|
|
|
|
<p className="text-sm text-red-500">
|
|
|
|
|
{deleteMutation.error.message}
|
|
|
|
|
</p>
|
2026-02-04 23:07:22 +05:30
|
|
|
) : null}
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button variant="ghost" onClick={() => setDeleteTarget(null)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
2026-02-04 23:43:40 +05:30
|
|
|
<Button onClick={handleDelete} disabled={deleteMutation.isPending}>
|
|
|
|
|
{deleteMutation.isPending ? "Deleting…" : "Delete"}
|
2026-02-04 23:07:22 +05:30
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
</DashboardShell>
|
|
|
|
|
);
|
|
|
|
|
}
|