493 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|