rastack 0.0.43 → 0.0.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/admin/AdminApp.tsx +461 -0
- package/admin/adapters.ts +346 -0
- package/admin/httpEngine.ts +56 -0
- package/admin/index.tsx +140 -0
- package/admin/ui/FilterBar.tsx +231 -0
- package/admin/ui/Popover.tsx +54 -0
- package/admin/ui/Sidebar.tsx +86 -0
- package/admin/ui/icons.tsx +146 -0
- package/admin/ui/styles.ts +398 -0
- package/admin/useAdmin.ts +434 -0
- package/dist/admin.js +459 -0
- package/dist/dev/harness.d.ts +70 -0
- package/dist/dev/harness.js +189 -0
- package/dist/rastack-admin.d.ts +14 -0
- package/dist/rastack-admin.js +128 -0
- package/dist/rastack-dev.d.ts +18 -0
- package/dist/rastack-dev.js +197 -0
- package/dist/rastack-wasm-build.d.ts +9 -4
- package/dist/rastack-wasm-build.js +44 -33
- package/dist/rastack.d.ts +2 -3
- package/dist/rastack.js +13 -32
- package/dist/wasm/package.json +4 -0
- package/dist/wasm/rastack_wasm.d.ts +100 -0
- package/dist/wasm/rastack_wasm.js +745 -0
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/dist/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
- package/package.json +6 -2
- package/scripts/build-admin.js +48 -0
- package/scripts/copy-wasm.js +35 -0
- package/src/dev/harness.ts +174 -0
- package/src/rastack-admin.ts +112 -0
- package/src/rastack-dev.ts +197 -0
- package/src/rastack-wasm-build.ts +52 -41
- package/src/rastack.ts +14 -37
- package/test/dev.spec.ts +373 -0
- package/wasm/package.json +4 -0
- package/wasm/rastack_wasm.d.ts +100 -0
- package/wasm/rastack_wasm.js +745 -0
- package/wasm/rastack_wasm_bg.wasm +0 -0
- package/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure adapters that turn the compiled manifest (`schema.rastack.json`) into the
|
|
3
|
+
* shapes the in-browser admin renders from — resource/field descriptors, the
|
|
4
|
+
* changelist columns, form field controls, value coercion, and the REST paths
|
|
5
|
+
* the WASM engine's `handle` answers. No React, no DOM, no engine — unit-tested
|
|
6
|
+
* directly (mirrors the Rust admin's manifest-driven rendering in
|
|
7
|
+
* `rust/rastack-api/src/admin.rs`, which derives every control from `field.ty`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { FormType } from "../hooks/form/interfaces";
|
|
11
|
+
import type { FieldOption } from "../hooks/form/interfaces";
|
|
12
|
+
import type { ReturnedHeader } from "../hooks/query/interfaces";
|
|
13
|
+
import { humanizeKey } from "../components/util";
|
|
14
|
+
|
|
15
|
+
/** The manifest's scalar/fk field type, as serialized in schema.rastack.json. */
|
|
16
|
+
export type ManifestFieldType =
|
|
17
|
+
| "string"
|
|
18
|
+
| "int"
|
|
19
|
+
| "float"
|
|
20
|
+
| "bool"
|
|
21
|
+
| "datetime"
|
|
22
|
+
| "uuid"
|
|
23
|
+
| "fk";
|
|
24
|
+
|
|
25
|
+
export interface AdminField {
|
|
26
|
+
name: string;
|
|
27
|
+
type: ManifestFieldType;
|
|
28
|
+
primaryKey: boolean;
|
|
29
|
+
maxLength?: number;
|
|
30
|
+
nullable: boolean;
|
|
31
|
+
/** Unique constraint — such fields are identifiers, not filter facets. */
|
|
32
|
+
unique?: boolean;
|
|
33
|
+
/** `{ app, model }` for a foreign key (`type === "fk"`). */
|
|
34
|
+
relation?: { app: string; model: string };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface AdminResource {
|
|
38
|
+
app: string;
|
|
39
|
+
model: string;
|
|
40
|
+
/** `"app.model"` — the stable id used in routes. */
|
|
41
|
+
key: string;
|
|
42
|
+
label: string;
|
|
43
|
+
fields: AdminField[];
|
|
44
|
+
/** Searchable field names (drives the search box + the API `search` param). */
|
|
45
|
+
search: string[];
|
|
46
|
+
/** `admin.listDisplay` columns, or null to fall back to all fields. */
|
|
47
|
+
listDisplay: string[] | null;
|
|
48
|
+
/** Whether this resource is shown in the admin (admin !== false). */
|
|
49
|
+
adminEnabled: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RawField {
|
|
53
|
+
name: string;
|
|
54
|
+
type: ManifestFieldType;
|
|
55
|
+
primaryKey?: boolean;
|
|
56
|
+
maxLength?: number;
|
|
57
|
+
null?: boolean;
|
|
58
|
+
unique?: boolean;
|
|
59
|
+
relation?: { app: string; model: string };
|
|
60
|
+
}
|
|
61
|
+
interface RawResource {
|
|
62
|
+
app: string;
|
|
63
|
+
model: string;
|
|
64
|
+
fields: RawField[];
|
|
65
|
+
search?: string[];
|
|
66
|
+
admin?: { listDisplay?: string[] } | false;
|
|
67
|
+
}
|
|
68
|
+
interface RawManifest {
|
|
69
|
+
resources?: RawResource[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Parse a manifest (object or JSON string) into admin resource descriptors. */
|
|
73
|
+
export function parseManifest(manifest: object | string): AdminResource[] {
|
|
74
|
+
const doc: RawManifest =
|
|
75
|
+
typeof manifest === "string" ? JSON.parse(manifest) : (manifest as RawManifest);
|
|
76
|
+
return (doc.resources ?? []).map((r) => ({
|
|
77
|
+
app: r.app,
|
|
78
|
+
model: r.model,
|
|
79
|
+
key: `${r.app}.${r.model}`,
|
|
80
|
+
label: humanizeKey(r.model),
|
|
81
|
+
fields: (r.fields ?? []).map((f) => ({
|
|
82
|
+
name: f.name,
|
|
83
|
+
type: f.type,
|
|
84
|
+
primaryKey: f.primaryKey === true,
|
|
85
|
+
maxLength: f.maxLength,
|
|
86
|
+
nullable: f.null === true,
|
|
87
|
+
unique: f.unique === true,
|
|
88
|
+
relation: f.relation,
|
|
89
|
+
})),
|
|
90
|
+
search: r.search ?? [],
|
|
91
|
+
listDisplay:
|
|
92
|
+
r.admin && Array.isArray(r.admin.listDisplay) ? r.admin.listDisplay : null,
|
|
93
|
+
adminEnabled: r.admin !== false,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The resources shown in the admin index (admin not disabled). */
|
|
98
|
+
export function adminResources(resources: AdminResource[]): AdminResource[] {
|
|
99
|
+
return resources.filter((r) => r.adminEnabled);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function findResource(
|
|
103
|
+
resources: AdminResource[],
|
|
104
|
+
app: string,
|
|
105
|
+
model: string,
|
|
106
|
+
): AdminResource | undefined {
|
|
107
|
+
return resources.find((r) => r.app === app && r.model === model);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -- REST paths the WASM engine's `handle` answers --------------------------
|
|
111
|
+
|
|
112
|
+
export function listPath(app: string, model: string): string {
|
|
113
|
+
return `/api/${app}/v1/${model}/`;
|
|
114
|
+
}
|
|
115
|
+
export function detailPath(app: string, model: string, id: string | number): string {
|
|
116
|
+
return `/api/${app}/v1/${model}/${id}/`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// -- changelist -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** The DRF list envelope the engine returns for a list request. */
|
|
122
|
+
export interface ListEnvelope {
|
|
123
|
+
count: number;
|
|
124
|
+
results: Record<string, unknown>[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Read the DRF `{ count, results }` list envelope defensively. */
|
|
128
|
+
export function readListEnvelope(body: unknown): ListEnvelope {
|
|
129
|
+
const b = (body ?? {}) as { count?: unknown; results?: unknown };
|
|
130
|
+
return {
|
|
131
|
+
count: typeof b.count === "number" ? b.count : 0,
|
|
132
|
+
results: Array.isArray(b.results) ? (b.results as Record<string, unknown>[]) : [],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Changelist columns: an `id` column first, then `admin.listDisplay` if set,
|
|
138
|
+
* else every field name — matching the Rust admin (`admin.rs` changelist).
|
|
139
|
+
*/
|
|
140
|
+
export function changelistHeaders(resource: AdminResource): ReturnedHeader[] {
|
|
141
|
+
const names =
|
|
142
|
+
resource.listDisplay ?? resource.fields.map((f) => f.name);
|
|
143
|
+
const keys = ["id", ...names.filter((n) => n !== "id")];
|
|
144
|
+
return keys.map((key) => ({ key, name: humanizeKey(key) }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Page count for a total + page size (never below 1, like the Rust admin). */
|
|
148
|
+
export function pageCount(total: number, pageSize: number): number {
|
|
149
|
+
return Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The admin's fixed changelist page size (matches `admin.rs`). */
|
|
153
|
+
export const PAGE_SIZE = 25;
|
|
154
|
+
|
|
155
|
+
// -- forms ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** The fields an add/change form renders — every field except the primary key. */
|
|
158
|
+
export function formFields(resource: AdminResource): AdminField[] {
|
|
159
|
+
return resource.fields.filter((f) => !f.primaryKey);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The `<AutoForm>` control kind for a manifest field. Foreign keys become a
|
|
164
|
+
* `select` (populated from the target table); the rest map by scalar type —
|
|
165
|
+
* exactly the control choices the Rust admin's `render_input` makes.
|
|
166
|
+
*/
|
|
167
|
+
export function fieldControlType(field: AdminField): FormType {
|
|
168
|
+
switch (field.type) {
|
|
169
|
+
case "fk":
|
|
170
|
+
return "select";
|
|
171
|
+
case "bool":
|
|
172
|
+
return "boolean";
|
|
173
|
+
case "int":
|
|
174
|
+
case "float":
|
|
175
|
+
return "number";
|
|
176
|
+
case "datetime":
|
|
177
|
+
return "datetime";
|
|
178
|
+
default:
|
|
179
|
+
return "text";
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A human label for a row in an FK `<select>`: the first non-`id` string field's
|
|
185
|
+
* value, else `#{id}` — the same heuristic as the Rust admin's `option_label`.
|
|
186
|
+
*/
|
|
187
|
+
export function optionLabel(row: Record<string, unknown>): string {
|
|
188
|
+
for (const [key, value] of Object.entries(row)) {
|
|
189
|
+
if (key !== "id" && typeof value === "string" && value.length > 0) {
|
|
190
|
+
return `${value} (#${row.id})`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return `#${row.id ?? ""}`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** FK `<select>` options from the target table's rows. */
|
|
197
|
+
export function optionsFromRows(rows: Record<string, unknown>[]): FieldOption[] {
|
|
198
|
+
return rows.map((row) => ({
|
|
199
|
+
label: optionLabel(row),
|
|
200
|
+
value: (row.id as string | number) ?? "",
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Initial form values keyed by field name (drives AutoForm's field order). */
|
|
205
|
+
export function initialFormValues(
|
|
206
|
+
resource: AdminResource,
|
|
207
|
+
): Record<string, unknown> {
|
|
208
|
+
const values: Record<string, unknown> = {};
|
|
209
|
+
for (const field of formFields(resource)) {
|
|
210
|
+
values[field.name] = field.type === "bool" ? false : "";
|
|
211
|
+
}
|
|
212
|
+
return values;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Coerce one raw form value to the type the API expects, mirroring the Rust
|
|
217
|
+
* admin's `form_to_row`: booleans always sent; numbers/FKs parsed (raw string
|
|
218
|
+
* on parse failure); empty non-bool values are omitted (so nullable fields are
|
|
219
|
+
* not sent as `""`). Returns `undefined` to signal "omit this field".
|
|
220
|
+
*/
|
|
221
|
+
export function coerceFormValue(field: AdminField, raw: unknown): unknown {
|
|
222
|
+
if (field.type === "bool") return raw === true || raw === "true";
|
|
223
|
+
if (raw === "" || raw === null || raw === undefined) return undefined;
|
|
224
|
+
if (field.type === "int" || field.type === "float" || field.type === "fk") {
|
|
225
|
+
const n = Number(raw);
|
|
226
|
+
return Number.isFinite(n) ? n : raw;
|
|
227
|
+
}
|
|
228
|
+
return raw;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Build the request body from form values, applying `coerceFormValue`. */
|
|
232
|
+
export function buildWriteBody(
|
|
233
|
+
resource: AdminResource,
|
|
234
|
+
values: Record<string, unknown>,
|
|
235
|
+
): Record<string, unknown> {
|
|
236
|
+
const body: Record<string, unknown> = {};
|
|
237
|
+
for (const field of formFields(resource)) {
|
|
238
|
+
const coerced = coerceFormValue(field, values[field.name]);
|
|
239
|
+
if (coerced !== undefined) body[field.name] = coerced;
|
|
240
|
+
}
|
|
241
|
+
return body;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// -- filters (Linear-style changelist filter bar) ---------------------------
|
|
245
|
+
|
|
246
|
+
/** How a filter's values are sourced/rendered. */
|
|
247
|
+
export type FilterKind = "fk" | "bool" | "choice";
|
|
248
|
+
|
|
249
|
+
/** A single selectable filter value. */
|
|
250
|
+
export interface FilterOption {
|
|
251
|
+
value: string;
|
|
252
|
+
label: string;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A field the changelist can filter on, and how to build its value list. */
|
|
256
|
+
export interface FilterableField {
|
|
257
|
+
name: string;
|
|
258
|
+
label: string;
|
|
259
|
+
kind: FilterKind;
|
|
260
|
+
/** FK target table (`kind === "fk"`). */
|
|
261
|
+
relation?: { app: string; model: string };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Distinct-value cap: above this a `string` field is an identifier, not a facet. */
|
|
265
|
+
export const DISTINCT_CAP = 40;
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The fields a changelist offers as filters, mirroring what makes sense as a
|
|
269
|
+
* Django `list_filter`: every foreign key (filter by related row), every boolean
|
|
270
|
+
* (true/false), and every non-unique plain-string field (filter by one of its
|
|
271
|
+
* observed values). Unique strings, numbers, ids and timestamps are excluded —
|
|
272
|
+
* they identify a row rather than group many, so they belong in search, not a
|
|
273
|
+
* facet.
|
|
274
|
+
*/
|
|
275
|
+
export function filterableFields(resource: AdminResource): FilterableField[] {
|
|
276
|
+
const out: FilterableField[] = [];
|
|
277
|
+
for (const f of resource.fields) {
|
|
278
|
+
if (f.primaryKey) continue;
|
|
279
|
+
if (f.type === "fk" && f.relation) {
|
|
280
|
+
out.push({ name: f.name, label: humanizeKey(f.name), kind: "fk", relation: f.relation });
|
|
281
|
+
} else if (f.type === "bool") {
|
|
282
|
+
out.push({ name: f.name, label: humanizeKey(f.name), kind: "bool" });
|
|
283
|
+
} else if (f.type === "string" && !f.unique) {
|
|
284
|
+
out.push({ name: f.name, label: humanizeKey(f.name), kind: "choice" });
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** The fixed True/False options a boolean filter offers. */
|
|
291
|
+
export function boolFilterOptions(): FilterOption[] {
|
|
292
|
+
return [
|
|
293
|
+
{ value: "true", label: "True" },
|
|
294
|
+
{ value: "false", label: "False" },
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Distinct non-empty values of `field` across `rows`, sorted, as filter options.
|
|
300
|
+
* Returns `[]` when the field has more than `cap` distinct values (too
|
|
301
|
+
* high-cardinality to be a useful facet — it should be searched, not filtered).
|
|
302
|
+
*/
|
|
303
|
+
export function distinctValueOptions(
|
|
304
|
+
rows: Record<string, unknown>[],
|
|
305
|
+
field: string,
|
|
306
|
+
cap = DISTINCT_CAP,
|
|
307
|
+
): FilterOption[] {
|
|
308
|
+
const seen = new Set<string>();
|
|
309
|
+
for (const row of rows) {
|
|
310
|
+
const v = row[field];
|
|
311
|
+
if (v === null || v === undefined || v === "") continue;
|
|
312
|
+
seen.add(String(v));
|
|
313
|
+
if (seen.size > cap) return [];
|
|
314
|
+
}
|
|
315
|
+
return [...seen].sort().map((value) => ({ value, label: value }));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* The label for an FK filter value: reuse the `<select>` option label heuristic
|
|
320
|
+
* (first non-id string field, else `#id`) against the target table's rows.
|
|
321
|
+
*/
|
|
322
|
+
export function fkFilterOptions(rows: Record<string, unknown>[]): FilterOption[] {
|
|
323
|
+
return rows.map((row) => ({
|
|
324
|
+
value: String(row.id ?? ""),
|
|
325
|
+
label: optionLabel(row),
|
|
326
|
+
}));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Merge active filters into an engine list-query object (field → value). */
|
|
330
|
+
export function withFilters(
|
|
331
|
+
query: Record<string, string>,
|
|
332
|
+
filters: Record<string, string>,
|
|
333
|
+
): Record<string, string> {
|
|
334
|
+
// Filters first, then reserved keys, so a field named `page`/`search` can
|
|
335
|
+
// never shadow pagination — the reserved keys always win.
|
|
336
|
+
return { ...filters, ...query };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Extract a `{detail}` error message from a non-2xx `handle` body. */
|
|
340
|
+
export function errorDetail(body: unknown): string {
|
|
341
|
+
if (body && typeof body === "object" && "detail" in body) {
|
|
342
|
+
const detail = (body as { detail: unknown }).detail;
|
|
343
|
+
return typeof detail === "string" ? detail : JSON.stringify(detail);
|
|
344
|
+
}
|
|
345
|
+
return typeof body === "string" ? body : "Request failed";
|
|
346
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTTP-backed `RastackEngine` — the same `handle(method, path, query, body)`
|
|
3
|
+
* seam the WASM engine exposes, implemented over `fetch` against a live
|
|
4
|
+
* `rastack-server`. This is what lets the *identical* admin UI run two ways:
|
|
5
|
+
* over WebAssembly in `rastack dev` (`client.engine`), or over the real Rust
|
|
6
|
+
* API + Iceberg store in `rastack admin` (this adapter) — the components,
|
|
7
|
+
* hooks and adapters above it never learn which.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { RastackEngine } from "../provider/types";
|
|
11
|
+
|
|
12
|
+
/** Build a same-origin (or `baseUrl`-rooted) HTTP engine. */
|
|
13
|
+
export function createHttpEngine(baseUrl = ""): RastackEngine {
|
|
14
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
15
|
+
|
|
16
|
+
async function handle(
|
|
17
|
+
method: string,
|
|
18
|
+
path: string,
|
|
19
|
+
query: Record<string, string> | undefined,
|
|
20
|
+
body: unknown,
|
|
21
|
+
): Promise<{ status: number; body: any }> {
|
|
22
|
+
const qs =
|
|
23
|
+
query && Object.keys(query).length
|
|
24
|
+
? "?" + new URLSearchParams(query).toString()
|
|
25
|
+
: "";
|
|
26
|
+
const hasBody = body !== undefined && body !== null;
|
|
27
|
+
const res = await fetch(base + path + qs, {
|
|
28
|
+
method,
|
|
29
|
+
headers: hasBody ? { "Content-Type": "application/json" } : undefined,
|
|
30
|
+
body: hasBody ? JSON.stringify(body) : undefined,
|
|
31
|
+
credentials: "same-origin",
|
|
32
|
+
});
|
|
33
|
+
// 204 (delete) and errors may carry no body; parse defensively.
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let parsed: any = null;
|
|
36
|
+
if (text) {
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(text);
|
|
39
|
+
} catch {
|
|
40
|
+
parsed = text;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { status: res.status, body: parsed };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const unavailable = (op: string) => async (): Promise<never> => {
|
|
47
|
+
throw new Error(`${op} is only available over the local WASM engine.`);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
handle,
|
|
52
|
+
seedFile: unavailable("seedFile"),
|
|
53
|
+
importWarehouse: unavailable("importWarehouse"),
|
|
54
|
+
exportWarehouse: unavailable("exportWarehouse") as RastackEngine["exportWarehouse"],
|
|
55
|
+
};
|
|
56
|
+
}
|
package/admin/index.tsx
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry point for the admin bundle (`dist/admin.js`). It runs two ways from the
|
|
3
|
+
* *same* build, chosen by a `window.__RASTACK_ADMIN__` config the host page
|
|
4
|
+
* injects (defaulting to local):
|
|
5
|
+
*
|
|
6
|
+
* • `rastack dev` → `mode: "local"` — boots the WASM engine via
|
|
7
|
+
* `<RAStackProvider mode="local">`, seeding from the served `/data/warehouse/`.
|
|
8
|
+
* • `rastack admin` → `mode: "remote"` — talks to the live `rastack-server`
|
|
9
|
+
* over HTTP through an `httpEngine`, against the real Iceberg warehouse.
|
|
10
|
+
*
|
|
11
|
+
* Either way it fetches the compiled schema, themes everything with
|
|
12
|
+
* `<ThemeProvider>` and the design tokens, and renders the manifest-driven
|
|
13
|
+
* `<AdminApp>` — which never learns which engine it got.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
17
|
+
import { createRoot } from "react-dom/client";
|
|
18
|
+
import { RAStackProvider, useRastackClient } from "../provider";
|
|
19
|
+
import type { RastackWasmModule } from "../provider/types";
|
|
20
|
+
import { Spinner } from "../components";
|
|
21
|
+
import { ThemeProvider } from "../theme";
|
|
22
|
+
import { defineTheme } from "../tokens";
|
|
23
|
+
import { AdminApp } from "./AdminApp";
|
|
24
|
+
import { parseManifest, type AdminResource } from "./adapters";
|
|
25
|
+
import { createHttpEngine } from "./httpEngine";
|
|
26
|
+
|
|
27
|
+
interface AdminConfig {
|
|
28
|
+
mode: "local" | "remote";
|
|
29
|
+
/** Remote: base URL the `/api/...` paths are joined onto (default same origin). */
|
|
30
|
+
apiBase?: string;
|
|
31
|
+
/** Where to fetch the compiled `schema.rastack.json`. */
|
|
32
|
+
schemaUrl?: string;
|
|
33
|
+
/** Local: base URL of the committed Iceberg warehouse to seed from. */
|
|
34
|
+
warehouseBaseUrl?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const cfg: AdminConfig =
|
|
38
|
+
(typeof window !== "undefined" && (window as any).__RASTACK_ADMIN__) || {
|
|
39
|
+
mode: "local",
|
|
40
|
+
schemaUrl: "/schema.rastack.json",
|
|
41
|
+
warehouseBaseUrl: "/data/warehouse/",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// The framework's own design system paints the whole console.
|
|
45
|
+
const theme = defineTheme({ brand: "#4F6BFF", radius: "rounded" });
|
|
46
|
+
|
|
47
|
+
/** Load the WASM engine from the URL `rastack dev` serves it at (`/wasm/…`). */
|
|
48
|
+
const loadEngine = async (): Promise<RastackWasmModule> => {
|
|
49
|
+
const runtimeImport = new Function("s", "return import(s)") as (
|
|
50
|
+
s: string,
|
|
51
|
+
) => Promise<any>;
|
|
52
|
+
const mod: RastackWasmModule = await runtimeImport("/wasm/rastack_wasm.js");
|
|
53
|
+
const init = mod.default || mod.init;
|
|
54
|
+
if (typeof init === "function") await init();
|
|
55
|
+
return mod;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function Loading({ label }: { label: string }): React.JSX.Element {
|
|
59
|
+
return (
|
|
60
|
+
<div className="ras-boot">
|
|
61
|
+
<Spinner /> {label}
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Local (WASM) admin: wait for the provider's engine, then render. */
|
|
67
|
+
function LocalAdmin({ resources }: { resources: AdminResource[] }): React.JSX.Element {
|
|
68
|
+
const client = useRastackClient();
|
|
69
|
+
if (client.status === "error")
|
|
70
|
+
return <Loading label="The WASM engine failed to start — check the console." />;
|
|
71
|
+
if (!client.engine || client.status !== "ready")
|
|
72
|
+
return (
|
|
73
|
+
<Loading
|
|
74
|
+
label={
|
|
75
|
+
client.status === "seeding"
|
|
76
|
+
? "Seeding the local warehouse…"
|
|
77
|
+
: "Starting the WASM engine…"
|
|
78
|
+
}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
return <AdminApp engine={client.engine} resources={resources} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Boot(): React.JSX.Element {
|
|
85
|
+
const [resources, setResources] = useState<AdminResource[] | null>(null);
|
|
86
|
+
const [manifest, setManifest] = useState<object | null>(null);
|
|
87
|
+
const [openapi, setOpenapi] = useState<object | undefined>();
|
|
88
|
+
const [error, setError] = useState<string>();
|
|
89
|
+
const httpEngine = useMemo(() => createHttpEngine(cfg.apiBase ?? ""), []);
|
|
90
|
+
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
(async () => {
|
|
93
|
+
const schemaUrl = cfg.schemaUrl || "/schema.rastack.json";
|
|
94
|
+
const res = await fetch(schemaUrl);
|
|
95
|
+
if (!res.ok) throw new Error(`${schemaUrl} (${res.status})`);
|
|
96
|
+
const doc = await res.json();
|
|
97
|
+
setManifest(doc);
|
|
98
|
+
setResources(parseManifest(doc));
|
|
99
|
+
if (cfg.mode === "local") {
|
|
100
|
+
try {
|
|
101
|
+
const o = await fetch("/openapi.json");
|
|
102
|
+
if (o.ok) setOpenapi(await o.json());
|
|
103
|
+
} catch {
|
|
104
|
+
/* openapi is optional */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
})().catch((e) => setError(String(e)));
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
110
|
+
if (error)
|
|
111
|
+
return <div className="ras-boot">Failed to load the schema: {error}</div>;
|
|
112
|
+
if (!resources || !manifest) return <Loading label="Loading the schema…" />;
|
|
113
|
+
|
|
114
|
+
if (cfg.mode === "remote") {
|
|
115
|
+
return (
|
|
116
|
+
<ThemeProvider tokens={theme}>
|
|
117
|
+
<AdminApp engine={httpEngine} resources={resources} />
|
|
118
|
+
</ThemeProvider>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<RAStackProvider
|
|
124
|
+
mode="local"
|
|
125
|
+
manifest={manifest}
|
|
126
|
+
openapi={openapi}
|
|
127
|
+
loadEngine={loadEngine}
|
|
128
|
+
warehouseBaseUrl={cfg.warehouseBaseUrl ?? "/data/warehouse/"}
|
|
129
|
+
persist={false}
|
|
130
|
+
cache={false}
|
|
131
|
+
>
|
|
132
|
+
<ThemeProvider tokens={theme}>
|
|
133
|
+
<LocalAdmin resources={resources} />
|
|
134
|
+
</ThemeProvider>
|
|
135
|
+
</RAStackProvider>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const container = document.getElementById("root");
|
|
140
|
+
if (container) createRoot(container).render(<Boot />);
|