Files
triggerdotdev--trigger.dev/apps/webapp/app/routes/admin.data-stores.tsx
T
2026-07-13 13:32:57 +08:00

493 lines
16 KiB
TypeScript

import { useState } from "react";
import { useFetcher } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button } from "~/components/primitives/Buttons";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/primitives/Dialog";
import { Input } from "~/components/primitives/Input";
import { Paragraph } from "~/components/primitives/Paragraph";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { prisma } from "~/db.server";
import { requireUser } from "~/services/session.server";
import { ClickhouseConnectionSchema } from "~/services/clickhouse/clickhouseSecretSchemas.server";
import { organizationDataStoresRegistry } from "~/services/dataStores/organizationDataStoresRegistryInstance.server";
import { tryCatch } from "@trigger.dev/core/utils";
// ---------------------------------------------------------------------------
// Loader
// ---------------------------------------------------------------------------
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
if (!user.admin) throw redirect("/");
const dataStores = await prisma.organizationDataStore.findMany({
orderBy: { createdAt: "desc" },
});
return typedjson({ dataStores });
};
// ---------------------------------------------------------------------------
// Action
// ---------------------------------------------------------------------------
const AddSchema = z.object({
_action: z.literal("add"),
key: z.string().min(1),
organizationIds: z.string().min(1),
connectionUrl: z.string().url(),
});
const UpdateSchema = z.object({
_action: z.literal("update"),
key: z.string().min(1),
organizationIds: z.string().min(1),
connectionUrl: z.string().url().optional(),
});
const DeleteSchema = z.object({
_action: z.literal("delete"),
key: z.string().min(1),
});
const FormSchema = z.discriminatedUnion("_action", [AddSchema, UpdateSchema, DeleteSchema]);
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
if (!user.admin) throw redirect("/");
const formData = await request.formData();
const result = FormSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return typedjson(
{ error: result.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
switch (result.data._action) {
case "add": {
const { key, organizationIds: rawOrgIds, connectionUrl } = result.data;
const organizationIds = rawOrgIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const parsedConfig = ClickhouseConnectionSchema.safeParse({ url: connectionUrl });
if (!parsedConfig.success) {
return typedjson(
{ error: parsedConfig.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const [error, _] = await tryCatch(
organizationDataStoresRegistry.addDataStore({
key,
kind: "CLICKHOUSE",
organizationIds,
config: parsedConfig.data,
})
);
if (error) {
return typedjson({ error: error.message }, { status: 400 });
}
return typedjson({ success: true });
}
case "update": {
const { key, organizationIds: rawOrgIds, connectionUrl } = result.data;
const organizationIds = rawOrgIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
let config: ReturnType<typeof ClickhouseConnectionSchema.parse> | undefined;
if (connectionUrl) {
const parsedConfig = ClickhouseConnectionSchema.safeParse({ url: connectionUrl });
if (!parsedConfig.success) {
return typedjson(
{ error: parsedConfig.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
config = parsedConfig.data;
}
const [error, _] = await tryCatch(
organizationDataStoresRegistry.updateDataStore({
key,
kind: "CLICKHOUSE",
organizationIds,
config,
})
);
if (error) {
return typedjson({ error: error.message }, { status: 400 });
}
return typedjson({ success: true });
}
case "delete": {
const { key } = result.data;
const [error, _] = await tryCatch(
organizationDataStoresRegistry.deleteDataStore({
key,
kind: "CLICKHOUSE",
})
);
if (error) {
return typedjson({ error: error.message }, { status: 400 });
}
return typedjson({ success: true });
}
default: {
return typedjson({ error: "Unknown action" }, { status: 400 });
}
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function AdminDataStoresRoute() {
const { dataStores } = useTypedLoaderData<typeof loader>();
const [addOpen, setAddOpen] = useState(false);
return (
<main className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<Paragraph variant="small" className="text-text-dimmed">
{dataStores.length} data store{dataStores.length !== 1 ? "s" : ""}
</Paragraph>
<Button variant="primary/small" onClick={() => setAddOpen(true)}>
Add data store
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Key</TableHeaderCell>
<TableHeaderCell>Kind</TableHeaderCell>
<TableHeaderCell>Organizations</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Updated</TableHeaderCell>
<TableHeaderCell>
<span className="sr-only">Actions</span>
</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{dataStores.length === 0 ? (
<TableBlankRow colSpan={6}>
<Paragraph>No data stores configured</Paragraph>
</TableBlankRow>
) : (
dataStores.map((ds) => (
<TableRow key={ds.id}>
<TableCell>
<span className="font-mono text-xs text-text-bright">{ds.key}</span>
</TableCell>
<TableCell>
<span className="inline-flex rounded-sm bg-indigo-500/20 px-1.5 py-0.5 text-[11px] font-medium text-indigo-400">
{ds.kind}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-text-dimmed">
{ds.organizationIds.length} org{ds.organizationIds.length !== 1 ? "s" : ""}
</span>
{ds.organizationIds.length > 0 && (
<span
className="ml-1 text-xs text-text-dimmed"
title={ds.organizationIds.join(", ")}
>
({ds.organizationIds.slice(0, 2).join(", ")}
{ds.organizationIds.length > 2
? ` +${ds.organizationIds.length - 2} more`
: ""}
)
</span>
)}
</TableCell>
<TableCell>
<span className="text-xs text-text-dimmed">
{new Date(ds.createdAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-xs text-text-dimmed">
{new Date(ds.updatedAt).toLocaleString()}
</span>
</TableCell>
<TableCell isSticky>
<div className="flex items-center gap-1">
<EditButton name={ds.key} organizationIds={ds.organizationIds} />
<DeleteButton name={ds.key} />
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<AddDataStoreDialog open={addOpen} onOpenChange={setAddOpen} />
</main>
);
}
// ---------------------------------------------------------------------------
// Delete button with popover confirmation
// ---------------------------------------------------------------------------
function DeleteButton({ name }: { name: string }) {
const [open, setOpen] = useState(false);
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
const isDeleting = fetcher.state !== "idle";
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="danger/small" disabled={isDeleting}>
{isDeleting ? "Deleting…" : "Delete"}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-72 space-y-3">
<Paragraph variant="small" className="text-text-bright">
Delete <span className="font-mono font-medium">{name}</span>?
</Paragraph>
<Paragraph variant="extra-small" className="text-text-dimmed">
This will remove the data store and its secret. Organizations using it will fall back to
the default ClickHouse instance.
</Paragraph>
<div className="flex items-center justify-end gap-2">
<Button variant="tertiary/small" onClick={() => setOpen(false)}>
Cancel
</Button>
<fetcher.Form method="post" onSubmit={() => setOpen(false)}>
<input type="hidden" name="_action" value="delete" />
<input type="hidden" name="key" value={name} />
<Button type="submit" variant="danger/small">
Confirm delete
</Button>
</fetcher.Form>
</div>
</PopoverContent>
</Popover>
);
}
// ---------------------------------------------------------------------------
// Edit button with dialog
// ---------------------------------------------------------------------------
function EditButton({ name, organizationIds }: { name: string; organizationIds: string[] }) {
const [open, setOpen] = useState(false);
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
const isSubmitting = fetcher.state !== "idle";
if (fetcher.data?.success && open) {
setOpen(false);
}
return (
<>
<Button variant="secondary/small" onClick={() => setOpen(true)}>
Edit
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit data store</DialogTitle>
</DialogHeader>
<fetcher.Form method="post" className="space-y-4 pt-2">
<input type="hidden" name="_action" value="update" />
<input type="hidden" name="key" value={name} />
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">Key</label>
<Input
name="_key_display"
value={name}
readOnly
variant="medium"
className="font-mono opacity-60"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Organization IDs <span className="text-rose-400">*</span>
</label>
<Input
name="organizationIds"
defaultValue={organizationIds.join(", ")}
placeholder="clxxxxx, clyyyyy, clzzzzz"
variant="medium"
required
/>
<p className="text-[11px] text-text-dimmed">Comma-separated organization IDs.</p>
</div>
{fetcher.data?.error && <p className="text-xs text-rose-400">{fetcher.data.error}</p>}
<DialogFooter>
<Button
variant="tertiary/small"
type="button"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
{isSubmitting ? "Saving…" : "Save"}
</Button>
</DialogFooter>
</fetcher.Form>
</DialogContent>
</Dialog>
</>
);
}
// ---------------------------------------------------------------------------
// Add data store dialog
// ---------------------------------------------------------------------------
function AddDataStoreDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const fetcher = useFetcher<{ success?: boolean; error?: string }>();
const isSubmitting = fetcher.state !== "idle";
// Close dialog on success
if (fetcher.data?.success && open) {
onOpenChange(false);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add data store</DialogTitle>
</DialogHeader>
<fetcher.Form method="post" className="space-y-4 pt-2">
<input type="hidden" name="_action" value="add" />
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Key <span className="text-rose-400">*</span>
</label>
<Input
name="key"
placeholder="e.g. hipaa-clickhouse-us-east"
variant="medium"
required
className="font-mono"
/>
<p className="text-[11px] text-text-dimmed">
Unique identifier for this data store. Used as the secret key prefix.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Kind <span className="text-rose-400">*</span>
</label>
<Input
name="kind"
value="CLICKHOUSE"
readOnly
variant="medium"
className="opacity-60"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
Organization IDs <span className="text-rose-400">*</span>
</label>
<Input
name="organizationIds"
placeholder="clxxxxx, clyyyyy, clzzzzz"
variant="medium"
required
/>
<p className="text-[11px] text-text-dimmed">Comma-separated organization IDs.</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium text-text-dimmed">
ClickHouse connection URL <span className="text-rose-400">*</span>
</label>
<Input
name="connectionUrl"
type="password"
placeholder="https://user:password@host:8443"
variant="medium"
required
className="font-mono"
/>
<p className="text-[11px] text-text-dimmed">
Stored encrypted in SecretStore. Never logged or displayed again.
</p>
</div>
{fetcher.data?.error && <p className="text-xs text-rose-400">{fetcher.data.error}</p>}
<DialogFooter>
<Button
variant="tertiary/small"
type="button"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" variant="primary/small" disabled={isSubmitting}>
{isSubmitting ? "Adding…" : "Add data store"}
</Button>
</DialogFooter>
</fetcher.Form>
</DialogContent>
</Dialog>
);
}