Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions crates/assets/js/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/solid-query";

import { TablePage } from "@/components/tables/TablesPage";
import { AccountsPage } from "@/components/accounts/AccountsPage";
import { WasmModulesPage } from "@/components/wasm/WasmModulesPage";
import { WasmModuleSettingsPage } from "@/components/wasm/WasmModuleSettingsPage";
import { LoginPage } from "@/components/auth/LoginPage";
import { SettingsPage } from "@/components/settings/SettingsPage";
import { IndexPage } from "@/components/IndexPage";
Expand Down Expand Up @@ -100,6 +102,8 @@ const App: Component = () => {
<Route path="/" component={IndexPage} />
<Route path="/table/:table?" component={TablePage} />
<Route path="/auth" component={AccountsPage} />
<Route path="/wasm-modules" component={WasmModulesPage} />
<Route path="/wasm-modules/:name" component={WasmModuleSettingsPage} />
<Route path="/editor" component={LazyEditorPage} />
<Route path="/erd" component={LazyErdPage} />
<Route path="/logs" component={LazyLogsPage} />
Expand Down
6 changes: 6 additions & 0 deletions crates/assets/js/admin/src/components/IndexPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
TbOutlineEdit,
TbOutlineChartDots3,
TbOutlineUsers,
TbOutlinePackage,
TbOutlineTimeline,
TbOutlineSettings,
} from "solid-icons/tb";
Expand Down Expand Up @@ -88,6 +89,11 @@ const elements = [
content: "Browse and manage your application's user registry.",
href: `${BASE}/auth`,
},
{
icon: TbOutlinePackage,
content: "Loaded WASM modules",
href: `${BASE}/wasm-modules`,
},
{
icon: TbOutlineTimeline,
content: "Access logs for your application",
Expand Down
2 changes: 2 additions & 0 deletions crates/assets/js/admin/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
TbOutlineChartDots3,
TbOutlineTimeline,
TbOutlineSettings,
TbOutlinePackage,
} from "solid-icons/tb";

import { AuthButton } from "@/components/auth/AuthButton";
Expand Down Expand Up @@ -36,6 +37,7 @@ const options = [
[`${BASE}/editor`, TbOutlineEdit, "SQL Editor"],
[`${BASE}/erd`, TbOutlineChartDots3, "Entity Relationship Diagram"],
[`${BASE}/auth`, TbOutlineUsers, "User Accounts"],
[`${BASE}/wasm-modules`, TbOutlinePackage, "WASM Modules"],
[`${BASE}/logs`, TbOutlineTimeline, "Logs & Metrics"],
[`${BASE}/settings/`, TbOutlineSettings, "Settings"],
] as const;
Expand Down
147 changes: 147 additions & 0 deletions crates/assets/js/admin/src/components/wasm/WasmModuleSettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { createEffect, createSignal, Match, Show, Switch } from "solid-js";
import { A, useParams } from "@solidjs/router";
import { useQuery } from "@tanstack/solid-query";
import { TbOutlineArrowLeft } from "solid-icons/tb";

import { Header } from "@/components/Header";
import { Spinner } from "@/components/Spinner";

import { fetchWasmModules } from "@/lib/api/wasm-modules";

// Injects an HTML fragment string into a container element.
// Script tags in the fragment do not execute when set via innerHTML; this
// function clones each script element so the browser treats it as new and
// executes it. Non-script nodes are inserted as-is.
function injectFragment(container: HTMLDivElement, html: string): void {
container.innerHTML = html;
container.querySelectorAll("script").forEach((old) => {
const next = document.createElement("script");
Array.from(old.attributes).forEach((attr) => {
next.setAttribute(attr.name, attr.value);
});
next.textContent = old.textContent;
old.parentNode?.replaceChild(next, old);
});
}

export function WasmModuleSettingsPage() {
const params = useParams<{ name: string }>();

const wasmModules = useQuery(() => ({
queryKey: ["wasm-modules"],
queryFn: fetchWasmModules,
// The module list doesn't change on its own during an admin session;
// refetching on window focus would re-inject the settings fragment and
// reset any unsaved state the user may have entered.
refetchOnWindowFocus: false,
}));

const module = () =>
wasmModules.data?.modules.find((m) => m.name === params.name);

const [fragmentState, setFragmentState] = createSignal<
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready" }
>({ status: "idle" });

let containerRef: HTMLDivElement | undefined;
// Tracks the config_path that was last successfully injected so that
// spurious re-runs of the effect (e.g. from an unrelated query refetch that
// produces a new object reference) don't wipe and re-inject the fragment.
let loadedConfigPath: string | undefined;

createEffect(() => {
const mod = module();
if (!mod) return;
const configPath = mod.config_path;
if (!configPath) return;
if (configPath === loadedConfigPath) return;

setFragmentState({ status: "loading" });

fetch(configPath, { credentials: "include" })
.then((res) => {
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
return res.text();
})
.then((html) => {
if (containerRef) {
injectFragment(containerRef, html);
loadedConfigPath = configPath;
setFragmentState({ status: "ready" });
}
})
.catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Failed to load settings";
setFragmentState({ status: "error", message });
});
});

const backLink = () => (
<A
href="/wasm-modules"
class="flex items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title="Back to WASM Modules"
>
<TbOutlineArrowLeft size={20} />
</A>
);

return (
<div>
<Show
when={!wasmModules.isLoading}
fallback={
<div class="flex h-64 items-center justify-center">
<Spinner size={32} class="text-muted-foreground" />
</div>
}
>
<Switch>
<Match when={module() === undefined}>
<Header title="Module not found" leading={backLink()} />
<div class="p-4 text-muted-foreground">
No module named "{params.name}" is installed.
</div>
</Match>

<Match when={!module()?.config_path}>
<Header
title={module()?.display_name ?? params.name}
leading={backLink()}
/>
<div class="p-4 text-muted-foreground">
This module has no settings page.
</div>
</Match>

<Match when={module()?.config_path}>
<Header title={module()!.display_name} leading={backLink()} />

<div class="p-4">
<Show when={fragmentState().status === "loading"}>
<div class="flex h-64 items-center justify-center">
<Spinner size={32} class="text-muted-foreground" />
</div>
</Show>

<Show when={fragmentState().status === "error"}>
<div class="text-destructive">
Failed to load settings:{" "}
{(fragmentState() as { status: "error"; message: string }).message}
</div>
</Show>

<div ref={containerRef} />
</div>
</Match>
</Switch>
</Show>
</div>
);
}
120 changes: 120 additions & 0 deletions crates/assets/js/admin/src/components/wasm/WasmModulesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { createMemo, For, Show } from "solid-js";
import { useQuery } from "@tanstack/solid-query";
import { A } from "@solidjs/router";
import {
TbOutlinePackage,
TbOutlinePuzzle,
TbOutlineSettings,
} from "solid-icons/tb";

import { Header } from "@/components/Header";
import { Spinner } from "@/components/Spinner";

import { fetchWasmModules } from "@/lib/api/wasm-modules";

import type { WasmModuleEntry } from "@bindings/WasmModuleEntry";

function ModuleIcon(props: { icon?: string }) {
const src = (): string | undefined => {
const icon = props.icon;
if (icon === undefined) {
return undefined;
}
if (icon.trimStart().startsWith("<svg")) {
return `data:image/svg+xml;utf8,${encodeURIComponent(icon)}`;
}
if (icon.startsWith("data:")) {
return icon;
}
return undefined;
};

return (
<Show
when={src() !== undefined}
fallback={<TbOutlinePuzzle size={24} />}
>
<img src={src()!} alt="" class="size-6" />
</Show>
);
}

function ModuleCard(props: { module: WasmModuleEntry }) {
const hasManifest = createMemo(
() =>
props.module.display_name !== props.module.name ||
props.module.icon !== null,
);

return (
<div class="flex items-center gap-3 rounded-lg border border-border p-4">
<div class="flex size-10 shrink-0 items-center justify-center text-muted-foreground">
<ModuleIcon icon={props.module.icon ?? undefined} />
</div>

<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-2">
<h3 class="truncate font-medium">{props.module.display_name}</h3>
<Show when={hasManifest()}>
<span class="shrink-0 text-xs text-muted-foreground">
{props.module.name}
</span>
</Show>
</div>
<Show when={props.module.description}>
<p class="mt-0.5 line-clamp-2 text-sm text-muted-foreground">
{props.module.description}
</p>
</Show>
</div>

<Show when={props.module.config_path !== null}>
<A
href={`/wasm-modules/${props.module.name}`}
class="flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<TbOutlineSettings size={18} />
</A>
</Show>
</div>
);
}

export function WasmModulesPage() {
const wasmModules = useQuery(() => ({
queryKey: ["wasm-modules"],
queryFn: fetchWasmModules,
}));

const modules = createMemo(() => wasmModules.data?.modules ?? []);

return (
<div>
<Header title="WASM Modules" />

<div class="flex flex-col gap-3 p-4">
<Show
when={!wasmModules.isLoading}
fallback={
<div class="flex h-64 items-center justify-center">
<Spinner size={32} class="text-muted-foreground" />
</div>
}
>
<Show
when={modules().length > 0}
fallback={
<div class="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<TbOutlinePackage size={48} />
</div>
}
>
<For each={modules()}>
{(module) => <ModuleCard module={module} />}
</For>
</Show>
</Show>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions crates/assets/js/admin/src/lib/api/wasm-modules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { adminFetch } from "@/lib/fetch";

import type { ListWasmModulesResponse } from "@bindings/ListWasmModulesResponse";

export async function fetchWasmModules(): Promise<ListWasmModulesResponse> {
const response = await adminFetch("/wasm-modules");
return await response.json();
}
4 changes: 4 additions & 0 deletions crates/assets/js/bindings/ListWasmModulesResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WasmModuleEntry } from "./WasmModuleEntry";

export type ListWasmModulesResponse = { modules: Array<WasmModuleEntry>, };
9 changes: 9 additions & 0 deletions crates/assets/js/bindings/WasmModuleEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type WasmModuleEntry = {
name: string,
display_name: string,
icon: string | null,
config_path: string | null,
description: string | null,
};
4 changes: 4 additions & 0 deletions crates/core/bindings/ListWasmModulesResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { WasmModuleEntry } from "./WasmModuleEntry";

export type ListWasmModulesResponse = { modules: Array<WasmModuleEntry>, };
9 changes: 9 additions & 0 deletions crates/core/bindings/WasmModuleEntry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type WasmModuleEntry = {
name: string,
display_name: string,
icon: string | null,
config_path: string | null,
description: string | null,
};
2 changes: 2 additions & 0 deletions crates/core/src/admin/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod wasm_modules;
mod config;
mod email;
mod error;
Expand Down Expand Up @@ -73,6 +74,7 @@ pub fn router() -> Router<AppState> {
)
.route("/public_key", get(jwt::get_public_key))
.route("/info", get(info::info_handler))
.route("/wasm-modules", get(wasm_modules::list_wasm_modules_handler))
.route("/jobs", get(jobs::list_jobs_handler))
.route("/job/run", post(jobs::run_job_handler))
.route("/email/test", post(email::test_email_handler))
Expand Down
Loading
Loading