Feat: Add development bootstrap reset

This commit is contained in:
MangoPig
2026-06-19 19:26:14 +01:00
parent 6ba04effcf
commit 935bee357c
6 changed files with 219 additions and 24 deletions

View File

@@ -410,6 +410,36 @@ func (service *Service) SaveStructure(ctx context.Context, input SaveStructureIn
}, nil
}
func (service *Service) ResetDevelopmentState(ctx context.Context) error {
tx, err := service.db.Pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer func() {
_ = tx.Rollback(ctx)
}()
if _, err := tx.Exec(ctx, `
TRUNCATE TABLE
project_memberships,
team_memberships,
organization_memberships,
workspaces,
projects,
teams,
departments,
user_homes,
users,
organizations,
installations
RESTART IDENTITY;
`); err != nil {
return err
}
return tx.Commit(ctx)
}
func (service *Service) GetInstallation(ctx context.Context) (*InstallationRecord, error) {
record, err := scanInstallationRecord(service.db.Pool.QueryRow(ctx, `
SELECT id::text, mode::text, access::text, protocol::text, host, is_bootstrapped

View File

@@ -166,6 +166,28 @@ func (routes apiRoutes) handleAppShellState(w http.ResponseWriter, r *http.Reque
})
}
func (routes apiRoutes) handleDevelopmentBootstrapReset(w http.ResponseWriter, r *http.Request) {
if !routes.cfg.Config.IsDevelopment() {
WriteError(w, http.StatusNotFound, RequestIDFromContext(r.Context()), "not_found", "The requested endpoint does not exist.")
return
}
if err := routes.bootstrapService().ResetDevelopmentState(r.Context()); err != nil {
routes.writeBootstrapPersistenceError(w, r, err)
return
}
WriteJSON(w, http.StatusOK, map[string]any{
"data": map[string]any{
"reset": true,
},
"meta": map[string]any{
"resource": "development-bootstrap-reset",
"developmentOnly": true,
},
})
}
func (routes apiRoutes) handleBootstrapInstanceStep(w http.ResponseWriter, r *http.Request) {
payload, ok := decodeBootstrapRequest[bootstrapInstanceStepRequest](w, r)
if !ok {

View File

@@ -33,6 +33,10 @@ func (routes apiRoutes) Register(router chi.Router) {
apiRouter.Get("/app-shell", routes.handleAppShellState)
apiRouter.Get("/organizations", routes.handleOrganizations)
apiRouter.Get("/workspaces", routes.handleWorkspaces)
if routes.cfg.Config.IsDevelopment() {
apiRouter.Post("/dev/bootstrap/reset", routes.handleDevelopmentBootstrapReset)
}
})
}

View File

@@ -100,9 +100,20 @@ type AppShellPayload = {
workspaces: AppShellWorkspace[];
};
const normalizeAppShellPayload = (payload: AppShellPayload | null | undefined): AppShellPayload => ({
installation: payload?.installation,
admin: payload?.admin,
organizations: Array.isArray(payload?.organizations) ? payload.organizations : [],
departments: Array.isArray(payload?.departments) ? payload.departments : [],
teams: Array.isArray(payload?.teams) ? payload.teams : [],
projects: Array.isArray(payload?.projects) ? payload.projects : [],
workspaces: Array.isArray(payload?.workspaces) ? payload.workspaces : [],
});
type AppShellContextValue = {
status: Accessor<"idle" | "loading" | "success" | "error">;
error: Accessor<string>;
installation: Accessor<AppShellInstallation | undefined>;
railItems: Accessor<readonly RailItem[]>;
activeServer: Accessor<ActiveServer>;
activeProject: Accessor<ActiveProject>;
@@ -277,7 +288,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem
throw new Error(body.error?.message || "Failed to load app shell state.");
}
setPayload(body.data);
setPayload(normalizeAppShellPayload(body.data));
setStatus("success");
} catch (loadError) {
setStatus("error");
@@ -292,6 +303,7 @@ export const AppShellDataProvider = (props: { children: JSX.Element }): JSX.Elem
const value: AppShellContextValue = {
status,
error,
installation: createMemo(() => payload()?.installation),
railItems: createMemo(() => buildRailItems(payload())),
activeServer: createMemo(() => buildActiveServer(payload())),
activeProject: createMemo(() => buildActiveProject(payload())),

View File

@@ -261,6 +261,14 @@
background 160ms var(--easing-standard);
}
.field input:disabled,
.field select:disabled {
cursor: not-allowed;
color: var(--color-text-muted);
background: color-mix(in srgb, var(--color-surface-secondary) 92%, transparent);
border-color: color-mix(in srgb, var(--color-border) 72%, transparent);
}
.field input:focus-visible,
.field select:focus-visible {
outline: none;

View File

@@ -1,6 +1,6 @@
// Path: Frontend/src/components/workspace-home/WorkspaceHome/WorkspaceHome.tsx
import { For, Show, createMemo, createSignal, onMount, type JSX } from "solid-js";
import { For, Show, createEffect, createMemo, createSignal, onMount, type JSX } from "solid-js";
import { Portal } from "solid-js/web";
import { createStore } from "solid-js/store";
import { resolveAPIBase } from "../../../lib/api";
@@ -45,6 +45,38 @@ const bootstrapStepDefinitions: readonly BootstrapStepDefinition[] = [
];
const bootstrapCompletionStorageKey = "moku.bootstrap.completed";
const isDevelopmentEnvironment = import.meta.env.DEV;
const defaultInstanceForm = {
protocol: "http",
access: "local",
host: "localhost",
} as const;
const defaultModeForm = {
mode: "personal",
} as const;
const defaultAdminForm = {
displayName: "First admin",
email: "admin@example.com",
password: "",
} as const;
const personalStructureDefaults = {
departmentName: "Default",
teamName: "Personal",
} as const;
const organizationalStructureDefaults = {
departmentName: "Department",
teamName: "Team",
} as const;
const defaultStructureForm = {
...personalStructureDefaults,
projectName: "Project",
} as const;
const initialSubmissionState = (): BootstrapSubmissionState => ({
status: "idle",
@@ -93,24 +125,10 @@ type WorkspaceHomeProps = {
export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const appShellData = useAppShellData();
const [instanceForm, setInstanceForm] = createStore({
protocol: "http",
access: "local",
host: "localhost",
});
const [modeForm, setModeForm] = createStore({
mode: "personal",
});
const [adminForm, setAdminForm] = createStore({
displayName: "Ronald",
email: "admin@example.com",
password: "",
});
const [structureForm, setStructureForm] = createStore({
departmentName: "Platform",
teamName: "Core",
projectName: "Moku",
});
const [instanceForm, setInstanceForm] = createStore({ ...defaultInstanceForm });
const [modeForm, setModeForm] = createStore({ ...defaultModeForm });
const [adminForm, setAdminForm] = createStore({ ...defaultAdminForm });
const [structureForm, setStructureForm] = createStore({ ...defaultStructureForm });
const [stepState, setStepState] = createStore<Record<BootstrapStepKey, BootstrapSubmissionState>>({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
@@ -129,10 +147,54 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
setIsBootstrapStateResolved(true);
});
createEffect(() => {
if (modeForm.mode === "personal") {
setStructureForm("departmentName", personalStructureDefaults.departmentName);
setStructureForm("teamName", personalStructureDefaults.teamName);
return;
}
if (structureForm.departmentName === personalStructureDefaults.departmentName) {
setStructureForm("departmentName", organizationalStructureDefaults.departmentName);
}
if (structureForm.teamName === personalStructureDefaults.teamName) {
setStructureForm("teamName", organizationalStructureDefaults.teamName);
}
});
createEffect(() => {
if (!isBootstrapStateResolved()) {
return;
}
if (appShellData.status() !== "success") {
return;
}
const installation = appShellData.installation();
const isPersistedBootstrap = installation?.isBootstrapped ?? false;
if (isPersistedBootstrap) {
return;
}
if (!isBootstrapComplete() && isWizardOpen()) {
return;
}
writeBootstrapCompletion(false);
setIsBootstrapComplete(false);
setIsWizardOpen(true);
resetWizardState();
});
const sidebarToggleLabel = (): string =>
props.sidebarCollapsed ? "Expand left workspace sidebar" : "Collapse left workspace sidebar";
const breadcrumb = (): string => `${appShellData.activeServer().name} / ${appShellData.activeProject().name} / Home`;
const apiBase = (): string => resolveAPIBase();
const bootstrapTargetLabel = (): string =>
modeForm.mode === "personal" ? "Personal server" : "Organization server";
const currentStep = createMemo<BootstrapStepDefinition>(
() => bootstrapStepDefinitions[currentStepIndex()] ?? bootstrapStepDefinitions[0]!,
);
@@ -141,6 +203,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
const isLastStep = (): boolean => currentStepIndex() === bootstrapStepDefinitions.length - 1;
const canDismissWizard = (): boolean => isBootstrapComplete();
const resetWizardState = (): void => {
setInstanceForm({ ...defaultInstanceForm });
setModeForm({ ...defaultModeForm });
setAdminForm({ ...defaultAdminForm });
setStructureForm({ ...defaultStructureForm });
setStepState({
instance: initialSubmissionState(),
mode: initialSubmissionState(),
admin: initialSubmissionState(),
structure: initialSubmissionState(),
});
setCurrentStepIndex(0);
};
const submitStep = async (step: BootstrapStepKey, payload: unknown): Promise<boolean> => {
setStepState(step, { status: "submitting", error: "" });
@@ -201,6 +277,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
}
if (isLastStep()) {
await appShellData.reload();
writeBootstrapCompletion(true);
setIsBootstrapComplete(true);
setIsWizardOpen(false);
@@ -215,6 +292,32 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
void submitCurrentStep();
};
const handleDevelopmentReset = async (): Promise<void> => {
const response = await fetch(`${apiBase()}/dev/bootstrap/reset`, {
method: "POST",
headers: {
Accept: "application/json",
},
});
const data = await readResponseBody(response);
if (!response.ok) {
throw new Error(
typeof data?.message === "string"
? data.message
: typeof data?.error?.message === "string"
? data.error.message
: "Failed to reset development bootstrap state.",
);
}
writeBootstrapCompletion(false);
setIsBootstrapComplete(false);
setIsWizardOpen(true);
resetWizardState();
await appShellData.reload();
};
const statusLabel = (state: BootstrapSubmissionState): string => {
switch (state.status) {
case "submitting":
@@ -268,7 +371,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<section class={styles.hero} data-slot="workspace-home-hero">
<span class={styles.eyebrow}>Bootstrap</span>
<h1 class={styles.title}>{appShellData.activeServer().name}</h1>
<h1 class={styles.title}>{isBootstrapComplete() ? appShellData.activeServer().name : bootstrapTargetLabel()}</h1>
<Show when={isBootstrapStateResolved() && !isBootstrapComplete()}>
<div class={styles.heroActions}>
<button type="button" class={styles.primaryButton} onClick={(): void => setIsWizardOpen(true)}>
@@ -276,6 +379,20 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
</button>
</div>
</Show>
<Show when={isDevelopmentEnvironment && isBootstrapStateResolved()}>
<div class={styles.heroActions}>
<button
type="button"
class={styles.secondaryButton}
data-slot="development-bootstrap-reset"
onClick={() => {
void handleDevelopmentReset();
}}
>
Reset development bootstrap state
</button>
</div>
</Show>
</section>
</main>
@@ -288,7 +405,7 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<header class={styles.wizardHeader} data-slot="bootstrap-wizard-header">
<div class={styles.wizardHeaderCopy}>
<h2 id="bootstrap-wizard-title" class={styles.wizardTitle}>
Bootstrap {appShellData.activeServer().name}
Bootstrap {bootstrapTargetLabel()}
</h2>
</div>
<Show when={canDismissWizard()}>
@@ -414,8 +531,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<input
type="text"
value={structureForm.departmentName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("departmentName", event.currentTarget.value)}
placeholder="Platform"
placeholder={organizationalStructureDefaults.departmentName}
/>
</label>
<label class={styles.field}>
@@ -423,8 +541,9 @@ export const WorkspaceHome = (props: WorkspaceHomeProps): JSX.Element => {
<input
type="text"
value={structureForm.teamName}
disabled={modeForm.mode === "personal"}
onInput={(event): void => setStructureForm("teamName", event.currentTarget.value)}
placeholder="Core"
placeholder={organizationalStructureDefaults.teamName}
/>
</label>
<label class={styles.field}>