chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,492 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user