Skip to content

Commit 446db33

Browse files
authored
feat: add S3 Tables catalog management (#210)
1 parent c16568d commit 446db33

30 files changed

Lines changed: 7573 additions & 0 deletions

‎app/(dashboard)/table-catalog/page.tsx‎

Lines changed: 1394 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { useTranslation } from "react-i18next"
5+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
6+
import { Button } from "@/components/ui/button"
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogDescription,
11+
DialogFooter,
12+
DialogHeader,
13+
DialogTitle,
14+
} from "@/components/ui/dialog"
15+
import { Field, FieldContent, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
16+
import { Input } from "@/components/ui/input"
17+
import { Spinner } from "@/components/ui/spinner"
18+
import { Textarea } from "@/components/ui/textarea"
19+
import { useTableCatalog, type NamespaceSummary } from "@/hooks/use-table-catalog"
20+
import { useMessage } from "@/lib/feedback/message"
21+
import { displayNamespace, isCatalogIdentifierValid } from "@/lib/table-catalog-paths"
22+
23+
interface NamespaceDialogProps {
24+
open: boolean
25+
bucket: string
26+
catalogPrefix?: string
27+
initialNamespace?: NamespaceSummary | null
28+
canCreate?: boolean
29+
canUpdate?: boolean
30+
onOpenChange: (open: boolean) => void
31+
onSuccess?: () => void
32+
}
33+
34+
function parseProperties(value: string): Record<string, string> {
35+
if (!value.trim()) return {}
36+
const parsed: unknown = JSON.parse(value)
37+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
38+
throw new Error("Properties must be a JSON object")
39+
}
40+
41+
return Object.fromEntries(
42+
Object.entries(parsed).map(([key, item]) => {
43+
if (typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean") {
44+
throw new Error("Property values must be strings, numbers, or booleans")
45+
}
46+
return [key, String(item)]
47+
}),
48+
)
49+
}
50+
51+
export function NamespaceDialog({
52+
open,
53+
bucket,
54+
catalogPrefix,
55+
initialNamespace = null,
56+
canCreate = false,
57+
canUpdate = false,
58+
onOpenChange,
59+
onSuccess,
60+
}: NamespaceDialogProps) {
61+
const { t } = useTranslation()
62+
const message = useMessage()
63+
const { createNamespace, getNamespace, updateNamespaceProperties } = useTableCatalog(catalogPrefix)
64+
const [namespace, setNamespace] = React.useState("")
65+
const [properties, setProperties] = React.useState("")
66+
const [originalProperties, setOriginalProperties] = React.useState<Record<string, string>>({})
67+
const [fieldError, setFieldError] = React.useState("")
68+
const [loadError, setLoadError] = React.useState("")
69+
const [saveError, setSaveError] = React.useState("")
70+
const [loading, setLoading] = React.useState(false)
71+
const [submitting, setSubmitting] = React.useState(false)
72+
const submittingRef = React.useRef(false)
73+
const requestId = React.useRef(0)
74+
const [reloadVersion, setReloadVersion] = React.useState(0)
75+
76+
const editing = Boolean(initialNamespace)
77+
const canSubmit = editing ? canUpdate : canCreate
78+
79+
const reset = React.useCallback(() => {
80+
setNamespace(initialNamespace ? displayNamespace(initialNamespace.namespace) : "")
81+
setProperties("")
82+
setOriginalProperties({})
83+
setFieldError("")
84+
setLoadError("")
85+
setSaveError("")
86+
setLoading(false)
87+
setSubmitting(false)
88+
submittingRef.current = false
89+
}, [initialNamespace])
90+
91+
React.useEffect(() => {
92+
if (!open) {
93+
requestId.current += 1
94+
return
95+
}
96+
97+
reset()
98+
if (!initialNamespace) return
99+
100+
const currentRequest = ++requestId.current
101+
setLoading(true)
102+
void getNamespace(bucket, initialNamespace.namespace)
103+
.then((result) => {
104+
if (currentRequest !== requestId.current) return
105+
setOriginalProperties(result.properties)
106+
setProperties(JSON.stringify(result.properties, null, 2))
107+
})
108+
.catch((error) => {
109+
if (currentRequest !== requestId.current) return
110+
setLoadError(error instanceof Error && error.message ? error.message : t("Unable to load namespace"))
111+
})
112+
.finally(() => {
113+
if (currentRequest === requestId.current) setLoading(false)
114+
})
115+
}, [bucket, getNamespace, initialNamespace, open, reloadVersion, reset, t])
116+
117+
const validate = () => {
118+
const value = namespace.trim()
119+
const segments = value.split(".").map((segment) => segment.trim())
120+
if (!value || segments.some((segment) => !isCatalogIdentifierValid(segment))) {
121+
setFieldError(t("Use lowercase letters, numbers, hyphens, or underscores; separate levels with dots."))
122+
document.getElementById("table-catalog-namespace")?.focus()
123+
return null
124+
}
125+
if (segments.some((segment) => segment.length > 64)) {
126+
setFieldError(t("Each namespace level must be 64 characters or fewer."))
127+
document.getElementById("table-catalog-namespace")?.focus()
128+
return null
129+
}
130+
if (value.length > 512) {
131+
setFieldError(t("The namespace must be 512 characters or fewer."))
132+
document.getElementById("table-catalog-namespace")?.focus()
133+
return null
134+
}
135+
setFieldError("")
136+
return segments
137+
}
138+
139+
const handleSubmit = async () => {
140+
if (submittingRef.current) return
141+
if (!canSubmit || (editing && (loading || Boolean(loadError)))) return
142+
const segments = validate()
143+
if (!segments) return
144+
145+
let parsedProperties: Record<string, string>
146+
try {
147+
parsedProperties = parseProperties(properties)
148+
} catch (error) {
149+
const text = error instanceof Error ? error.message : t("Properties must be a JSON object")
150+
setSaveError(text)
151+
return
152+
}
153+
154+
submittingRef.current = true
155+
setSubmitting(true)
156+
setSaveError("")
157+
try {
158+
if (editing) {
159+
const removals = Object.keys(originalProperties).filter((key) => !(key in parsedProperties))
160+
await updateNamespaceProperties(bucket, segments, { removals, updates: parsedProperties })
161+
message.success(t("Namespace updated"))
162+
} else {
163+
await createNamespace(bucket, segments, parsedProperties)
164+
message.success(t("Namespace created"))
165+
}
166+
onSuccess?.()
167+
onOpenChange(false)
168+
reset()
169+
} catch (error) {
170+
const text =
171+
error instanceof Error && error.message
172+
? error.message
173+
: editing
174+
? t("Unable to update namespace")
175+
: t("Unable to create namespace")
176+
setSaveError(text)
177+
message.error(text)
178+
} finally {
179+
submittingRef.current = false
180+
setSubmitting(false)
181+
}
182+
}
183+
184+
return (
185+
<Dialog
186+
open={open}
187+
disablePointerDismissal={submitting}
188+
onOpenChange={(nextOpen) => {
189+
if (!submitting) onOpenChange(nextOpen)
190+
}}
191+
>
192+
<DialogContent
193+
className="max-h-[min(90dvh,42rem)] grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:max-w-lg"
194+
aria-busy={loading || submitting}
195+
>
196+
<DialogHeader className="border-b px-4 py-4 pe-12 sm:px-6">
197+
<DialogTitle>{editing ? t("Edit namespace") : t("Create namespace")}</DialogTitle>
198+
<DialogDescription>
199+
{editing ? (
200+
<>
201+
{t("Update catalog properties for")} <span className="font-mono text-foreground">{bucket}</span>.
202+
</>
203+
) : (
204+
<>
205+
{t("Add a logical namespace inside")} <span className="font-mono text-foreground">{bucket}</span>.
206+
</>
207+
)}
208+
</DialogDescription>
209+
</DialogHeader>
210+
211+
<form
212+
className="contents"
213+
noValidate
214+
aria-busy={submitting}
215+
onSubmit={(event) => {
216+
event.preventDefault()
217+
void handleSubmit()
218+
}}
219+
>
220+
<div className="min-h-0 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
221+
<FieldGroup>
222+
{saveError ? (
223+
<Alert variant="destructive">
224+
<AlertTitle>{editing ? t("Namespace update failed") : t("Namespace creation failed")}</AlertTitle>
225+
<AlertDescription>{saveError}</AlertDescription>
226+
</Alert>
227+
) : null}
228+
229+
{editing && loadError ? (
230+
<Alert variant="destructive">
231+
<AlertTitle>{t("Unable to load namespace")}</AlertTitle>
232+
<AlertDescription>{loadError}</AlertDescription>
233+
<Button
234+
type="button"
235+
variant="outline"
236+
size="sm"
237+
className="mt-3"
238+
onClick={() => setReloadVersion((value) => value + 1)}
239+
>
240+
{t("Retry")}
241+
</Button>
242+
</Alert>
243+
) : null}
244+
245+
{editing && loading ? (
246+
<div
247+
className="flex min-h-24 items-center justify-center gap-2 text-sm text-muted-foreground"
248+
role="status"
249+
>
250+
<Spinner className="size-4" aria-hidden />
251+
{t("Loading namespace…")}
252+
</div>
253+
) : null}
254+
255+
{!canSubmit ? (
256+
<Alert>
257+
<AlertTitle>{t("Permission required")}</AlertTitle>
258+
<AlertDescription>
259+
{editing
260+
? t("You do not have permission to update namespaces.")
261+
: t("You do not have permission to create namespaces.")}
262+
</AlertDescription>
263+
</Alert>
264+
) : null}
265+
266+
<Field data-invalid={Boolean(fieldError)}>
267+
<FieldLabel htmlFor="table-catalog-namespace">{t("Namespace")}</FieldLabel>
268+
<FieldContent>
269+
<Input
270+
id="table-catalog-namespace"
271+
name="namespace"
272+
value={namespace}
273+
onChange={(event) => {
274+
setNamespace(event.target.value.toLowerCase())
275+
setFieldError("")
276+
setLoadError("")
277+
setSaveError("")
278+
}}
279+
disabled={editing || loading || submitting}
280+
placeholder="analytics.reporting"
281+
autoComplete="off"
282+
spellCheck={false}
283+
required
284+
aria-invalid={Boolean(fieldError)}
285+
aria-describedby={fieldError ? "table-catalog-namespace-error" : "table-catalog-namespace-help"}
286+
/>
287+
</FieldContent>
288+
<FieldDescription id="table-catalog-namespace-help">
289+
{t("Use dots for nested levels. RustFS stores the REST separator as %1F.")}
290+
</FieldDescription>
291+
<FieldError id="table-catalog-namespace-error">{fieldError}</FieldError>
292+
</Field>
293+
294+
<Field>
295+
<FieldLabel htmlFor="table-catalog-namespace-properties">{t("Properties (optional)")}</FieldLabel>
296+
<FieldContent>
297+
<Textarea
298+
id="table-catalog-namespace-properties"
299+
name="properties"
300+
value={properties}
301+
onChange={(event) => setProperties(event.target.value)}
302+
placeholder={'{"owner":"data-platform"}'}
303+
className="min-h-24 font-mono text-[0.7rem]"
304+
spellCheck={false}
305+
disabled={loading || submitting || !canSubmit || (editing && Boolean(loadError))}
306+
aria-describedby="table-catalog-namespace-properties-help"
307+
/>
308+
</FieldContent>
309+
<FieldDescription id="table-catalog-namespace-properties-help">
310+
{t("Provide a flat JSON object. Values are stored as strings.")}
311+
</FieldDescription>
312+
</Field>
313+
</FieldGroup>
314+
</div>
315+
316+
<DialogFooter className="border-t px-4 py-3 sm:px-6">
317+
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
318+
{t("Cancel")}
319+
</Button>
320+
<Button type="submit" disabled={submitting || loading || !canSubmit || (editing && Boolean(loadError))}>
321+
{submitting ? <Spinner className="size-4" aria-hidden /> : null}
322+
{submitting
323+
? editing
324+
? t("Saving…")
325+
: t("Creating…")
326+
: editing
327+
? t("Save changes")
328+
: t("Create namespace")}
329+
</Button>
330+
</DialogFooter>
331+
</form>
332+
</DialogContent>
333+
</Dialog>
334+
)
335+
}

0 commit comments

Comments
 (0)