perf(webui): lazily load module descriptors
This commit is contained in:
@@ -151,6 +151,10 @@ PATH=/home/zemion/.nvm/versions/node/v22.22.3/bin:$PATH /home/zemion/.nvm/versio
|
|||||||
|
|
||||||
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
The local host links sibling module WebUI packages through local file dependencies and Vite filesystem allowances. Release builds should use `webui/package.release.json`, which points WebUI module packages at tagged git refs. See [RELEASE_DEPENDENCIES.md](docs/RELEASE_DEPENDENCIES.md).
|
||||||
|
|
||||||
|
Production builds lazy-load enabled module descriptors and enforce initial and
|
||||||
|
asynchronous JavaScript budgets. See
|
||||||
|
[WEBUI_BUNDLE_BUDGETS.md](docs/WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
## Module contract
|
## Module contract
|
||||||
|
|
||||||
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
Backend modules register through the `govoplan.modules` entry point and return a `ModuleManifest`. A manifest can contribute:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ operator, and roadmap pages.
|
|||||||
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
| Release dependencies and catalogs | `RELEASE_DEPENDENCIES.md` | Release package refs, migration baselines, release lockfiles, catalog trust/licensing, catalog publishing, and release checklist. |
|
||||||
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
| Dependency vulnerability audits | `DEPENDENCY_AUDITS.md` | Local and CI audit commands plus dated audit result notes. |
|
||||||
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
| Remote WebUI bundle design | `REMOTE_WEBUI_BUNDLES.md` | Experimental controlled-deployment design; normal releases still use package builds. |
|
||||||
|
| WebUI loading and bundle budgets | `WEBUI_BUNDLE_BUDGETS.md` | Installed-module lazy boundaries, enforced initial/async budgets, and baseline measurements. |
|
||||||
|
|
||||||
## Product And Module Planning
|
## Product And Module Planning
|
||||||
|
|
||||||
|
|||||||
@@ -624,11 +624,18 @@ Uninstall remains non-destructive unless the operator explicitly requests
|
|||||||
|
|
||||||
## WebUI Contract
|
## WebUI Contract
|
||||||
|
|
||||||
A WebUI module exports a `PlatformWebModule` from its package. The object contributes local/fallback metadata and route render functions.
|
A WebUI module exports a `PlatformWebModule` from its package. The object
|
||||||
|
contributes local/fallback metadata and route render functions. The package
|
||||||
|
must ship `src/module.ts` with the default contribution export: Core's Vite
|
||||||
|
host imports that descriptor directly after the backend reports the module as
|
||||||
|
enabled. This keeps package-root re-exports from pulling page implementations
|
||||||
|
into the initial shell.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
|
const FilesPage = lazy(() => import("./features/files/FilesPage"));
|
||||||
|
|
||||||
export const filesModule: PlatformWebModule = {
|
export const filesModule: PlatformWebModule = {
|
||||||
id: "files",
|
id: "files",
|
||||||
label: "Files",
|
label: "Files",
|
||||||
@@ -643,6 +650,11 @@ export const filesModule: PlatformWebModule = {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Route pages and substantial panels must use stable lazy imports. Core supplies
|
||||||
|
the shared loading and retryable error state around route rendering. The
|
||||||
|
initial static import closure and largest asynchronous chunk are enforced by
|
||||||
|
the budgets documented in [WEBUI_BUNDLE_BUDGETS.md](WEBUI_BUNDLE_BUDGETS.md).
|
||||||
|
|
||||||
WebUI modules receive only the core route context:
|
WebUI modules receive only the core route context:
|
||||||
|
|
||||||
- `settings`
|
- `settings`
|
||||||
|
|||||||
71
docs/WEBUI_BUNDLE_BUDGETS.md
Normal file
71
docs/WEBUI_BUNDLE_BUDGETS.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# WebUI Loading And Bundle Budgets
|
||||||
|
|
||||||
|
The Core WebUI host owns the loading boundary for installed module packages.
|
||||||
|
Vite discovers configured packages at build time, but emits an asynchronous
|
||||||
|
loader for each package's `src/module.ts` contribution descriptor. At runtime,
|
||||||
|
Core imports only descriptors whose backend manifests are enabled and identify
|
||||||
|
the matching `frontend.package_name`.
|
||||||
|
|
||||||
|
The direct descriptor entry is intentional. A package root may re-export pages
|
||||||
|
for consumers; importing that barrel as module wiring can cause those pages to
|
||||||
|
be evaluated before navigation. Route pages and substantial panels should use
|
||||||
|
`React.lazy`, and Core wraps routes in the shared loading/error boundary.
|
||||||
|
|
||||||
|
## Enforced Budgets
|
||||||
|
|
||||||
|
`webui/bundle-budget.json` contains the production limits:
|
||||||
|
|
||||||
|
| Measurement | Raw limit | Gzip limit |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Initial JavaScript static import closure | 512 KiB | 160 KiB |
|
||||||
|
| Largest individual asynchronous JavaScript chunk | 384 KiB | 110 KiB |
|
||||||
|
|
||||||
|
`npm run build` writes a Vite manifest, measures the entry and its recursive
|
||||||
|
static imports, writes `dist/bundle-metrics.json`, and fails when either budget
|
||||||
|
is exceeded. `npm run test:module-permutations` applies the same gate to every
|
||||||
|
permutation and records the collected results in
|
||||||
|
`dist/module-permutation-bundle-metrics.json`. In CI, each result is also added
|
||||||
|
to the step summary.
|
||||||
|
|
||||||
|
Budgets are limits, not targets. A change that approaches a limit should add a
|
||||||
|
new lazy boundary or remove unnecessary entry code instead of raising the
|
||||||
|
limit without measurement and review.
|
||||||
|
|
||||||
|
## 2026-07-30 Baseline
|
||||||
|
|
||||||
|
Measurements use the same full-product source tree and Node 22 runtime. The
|
||||||
|
post-change build additionally includes the Search module in the default and
|
||||||
|
full-product sets.
|
||||||
|
|
||||||
|
| Initial-load measurement | Before | After | Reduction |
|
||||||
|
| --- | ---: | ---: | ---: |
|
||||||
|
| JavaScript assets in initial static closure | 1 | 1 | 0% |
|
||||||
|
| Raw JavaScript | 1,387,043 B | 453,769 B | 67.3% |
|
||||||
|
| Gzip level 9 | 364,767 B | 141,725 B | 61.1% |
|
||||||
|
| Brotli quality 11 | 254,797 B | 106,701 B | 58.1% |
|
||||||
|
| Parse proxy median | 18.776 ms | 7.750 ms | 58.7% |
|
||||||
|
| Parse proxy p95 | 21.980 ms | 8.739 ms | 60.2% |
|
||||||
|
|
||||||
|
The parse proxy constructs a fresh `node:vm` `SourceTextModule` from the entry
|
||||||
|
source 30 times with a randomized source marker. It is useful for a controlled
|
||||||
|
before/after comparison, but is not enforced in CI because absolute timings
|
||||||
|
vary across runners. Transfer budgets use deterministic raw and gzip byte
|
||||||
|
counts.
|
||||||
|
|
||||||
|
The first budgeted full-product build reported:
|
||||||
|
|
||||||
|
- initial JavaScript: 453,769 B raw / 141,725 B gzip;
|
||||||
|
- largest async chunk: `CampaignWorkspace`, 353,724 B raw / 98,142 B gzip.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/DATA/git/govoplan-core/webui
|
||||||
|
npm run build
|
||||||
|
npm run check:bundle-budget
|
||||||
|
npm run test:module-permutations
|
||||||
|
```
|
||||||
|
|
||||||
|
The build gate also catches accidental eager imports: a page pulled into the
|
||||||
|
entry closure consumes the initial budget, while an oversized page or module
|
||||||
|
descriptor consumes the asynchronous chunk budget.
|
||||||
10
webui/bundle-budget.json
Normal file
10
webui/bundle-budget.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"initialJs": {
|
||||||
|
"rawBytes": 524288,
|
||||||
|
"gzipBytes": 163840
|
||||||
|
},
|
||||||
|
"asyncChunk": {
|
||||||
|
"rawBytes": 393216,
|
||||||
|
"gzipBytes": 112640
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1 --port 5173",
|
"dev": "vite --host 127.0.0.1 --port 5173",
|
||||||
"prebuild": "npm run audit:i18n-structural",
|
"prebuild": "npm run audit:i18n-structural",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build && node scripts/check-bundle-budget.mjs",
|
||||||
|
"check:bundle-budget": "node scripts/check-bundle-budget.mjs",
|
||||||
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
"preview": "vite preview --host 127.0.0.1 --port 4173",
|
||||||
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
"audit:i18n-structural": "node scripts/audit-i18n-structural.mjs",
|
||||||
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
"test:i18n-catalog": "node --test tests/i18n-catalog-validation.test.mjs",
|
||||||
|
|||||||
173
webui/scripts/check-bundle-budget.mjs
Normal file
173
webui/scripts/check-bundle-budget.mjs
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { appendFileSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||||
|
import { gzipSync } from "node:zlib";
|
||||||
|
|
||||||
|
const webuiRoot = new URL("..", import.meta.url);
|
||||||
|
const distRoot = new URL("./dist/", webuiRoot);
|
||||||
|
const manifestPath = new URL("./.vite/manifest.json", distRoot);
|
||||||
|
const budgetPath = new URL("./bundle-budget.json", webuiRoot);
|
||||||
|
const metricsPath = new URL("./bundle-metrics.json", distRoot);
|
||||||
|
const buildName = process.env.GOVOPLAN_WEBUI_BUILD_NAME || "default";
|
||||||
|
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
||||||
|
const budgets = JSON.parse(readFileSync(budgetPath, "utf8"));
|
||||||
|
const entries = Object.entries(manifest);
|
||||||
|
const entryKeys = entries
|
||||||
|
.filter(([, chunk]) => chunk.isEntry && isJavaScript(chunk.file))
|
||||||
|
.map(([key]) => key);
|
||||||
|
|
||||||
|
if (entryKeys.length === 0) {
|
||||||
|
throw new Error("Vite manifest has no JavaScript entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialKeys = new Set();
|
||||||
|
for (const entryKey of entryKeys) collectStaticImports(entryKey, initialKeys);
|
||||||
|
const initialFiles = uniqueChunks(
|
||||||
|
[...initialKeys].map((key) => [key, manifest[key]]).filter(([, chunk]) => chunk)
|
||||||
|
);
|
||||||
|
const initialFileNames = new Set(initialFiles.map(([, chunk]) => chunk.file));
|
||||||
|
const moduleDescriptorChunks = entries.filter(([key, chunk]) => (
|
||||||
|
isJavaScript(chunk.file) &&
|
||||||
|
chunk.isDynamicEntry &&
|
||||||
|
(
|
||||||
|
key.startsWith("virtual:govoplan-installed-module/") ||
|
||||||
|
chunk.name?.startsWith("_40govoplan_2F")
|
||||||
|
)
|
||||||
|
));
|
||||||
|
const eagerModuleDescriptors = moduleDescriptorChunks.filter(
|
||||||
|
([, chunk]) => initialFileNames.has(chunk.file)
|
||||||
|
);
|
||||||
|
const asyncFiles = uniqueChunks(
|
||||||
|
entries.filter(([, chunk]) => (
|
||||||
|
isJavaScript(chunk.file) && !initialFileNames.has(chunk.file)
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
const initial = summarizeChunks(initialFiles);
|
||||||
|
const measuredAsyncChunks = asyncFiles.map(measureChunk);
|
||||||
|
const largestAsync = measuredAsyncChunks.sort(
|
||||||
|
(left, right) => right.rawBytes - left.rawBytes
|
||||||
|
)[0] ?? null;
|
||||||
|
const failures = [
|
||||||
|
...eagerModuleDescriptors.map(([, chunk]) => (
|
||||||
|
`Installed module descriptor ${chunk.file} is part of the initial static closure`
|
||||||
|
)),
|
||||||
|
...budgetFailures("Initial JavaScript", initial, budgets.initialJs),
|
||||||
|
...(largestAsync
|
||||||
|
? budgetFailures("Largest async chunk", largestAsync, budgets.asyncChunk)
|
||||||
|
: ["No asynchronous JavaScript chunk was produced"])
|
||||||
|
];
|
||||||
|
|
||||||
|
const metrics = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
buildName,
|
||||||
|
measuredAt: new Date().toISOString(),
|
||||||
|
initial,
|
||||||
|
largestAsync,
|
||||||
|
moduleDescriptorCount: moduleDescriptorChunks.length,
|
||||||
|
eagerModuleDescriptorCount: eagerModuleDescriptors.length,
|
||||||
|
budgets,
|
||||||
|
passed: failures.length === 0,
|
||||||
|
failures
|
||||||
|
};
|
||||||
|
writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`);
|
||||||
|
|
||||||
|
console.log(`\nWebUI bundle budget (${buildName})`);
|
||||||
|
console.log(formatMetric("Initial JavaScript", initial, budgets.initialJs));
|
||||||
|
console.log(formatMetric("Largest async chunk", largestAsync, budgets.asyncChunk));
|
||||||
|
console.log(`Metrics: ${metricsPath.pathname}`);
|
||||||
|
appendCiSummary(metrics);
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
for (const failure of failures) console.error(`Bundle budget exceeded: ${failure}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectStaticImports(key, target) {
|
||||||
|
if (target.has(key)) return;
|
||||||
|
const chunk = manifest[key];
|
||||||
|
if (!chunk || !isJavaScript(chunk.file)) return;
|
||||||
|
target.add(key);
|
||||||
|
for (const importedKey of chunk.imports ?? []) {
|
||||||
|
collectStaticImports(importedKey, target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueChunks(chunks) {
|
||||||
|
const byFile = new Map();
|
||||||
|
for (const [key, chunk] of chunks) {
|
||||||
|
if (!chunk || byFile.has(chunk.file)) continue;
|
||||||
|
byFile.set(chunk.file, [key, chunk]);
|
||||||
|
}
|
||||||
|
return [...byFile.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeChunks(chunks) {
|
||||||
|
const measured = chunks.map(measureChunk);
|
||||||
|
return {
|
||||||
|
assetCount: measured.length,
|
||||||
|
rawBytes: measured.reduce((total, chunk) => total + chunk.rawBytes, 0),
|
||||||
|
gzipBytes: measured.reduce((total, chunk) => total + chunk.gzipBytes, 0),
|
||||||
|
assets: measured
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function measureChunk([key, chunk]) {
|
||||||
|
const assetUrl = new URL(chunk.file, distRoot);
|
||||||
|
const source = readFileSync(assetUrl);
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
file: chunk.file,
|
||||||
|
rawBytes: statSync(assetUrl).size,
|
||||||
|
gzipBytes: gzipSync(source, { level: 9 }).length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function budgetFailures(label, measured, budget) {
|
||||||
|
if (!measured) return [`${label} was not measured`];
|
||||||
|
const failures = [];
|
||||||
|
for (const field of ["rawBytes", "gzipBytes"]) {
|
||||||
|
if (measured[field] > budget[field]) {
|
||||||
|
failures.push(
|
||||||
|
`${label} ${field} is ${measured[field]} bytes; budget is ${budget[field]} bytes`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return failures;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMetric(label, measured, budget) {
|
||||||
|
if (!measured) return `${label}: not produced`;
|
||||||
|
const file = measured.file ? ` (${measured.file})` : ` (${measured.assetCount} assets)`;
|
||||||
|
return [
|
||||||
|
`${label}${file}:`,
|
||||||
|
`${formatBytes(measured.rawBytes)} raw / ${formatBytes(budget.rawBytes)} budget,`,
|
||||||
|
`${formatBytes(measured.gzipBytes)} gzip / ${formatBytes(budget.gzipBytes)} budget`
|
||||||
|
].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value) {
|
||||||
|
return `${(value / 1024).toFixed(1)} KiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJavaScript(file) {
|
||||||
|
return typeof file === "string" && file.endsWith(".js");
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCiSummary(result) {
|
||||||
|
const summaryPath = process.env.GITHUB_STEP_SUMMARY;
|
||||||
|
if (!summaryPath) return;
|
||||||
|
const status = result.passed ? "pass" : "fail";
|
||||||
|
const asyncFile = result.largestAsync?.file ?? "none";
|
||||||
|
appendFileSync(
|
||||||
|
summaryPath,
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
`### WebUI bundle budget: ${result.buildName}`,
|
||||||
|
"",
|
||||||
|
"| Status | Initial raw | Initial gzip | Largest async | Async raw | Async gzip |",
|
||||||
|
"| --- | ---: | ---: | --- | ---: | ---: |",
|
||||||
|
`| ${status} | ${formatBytes(result.initial.rawBytes)} | ${formatBytes(result.initial.gzipBytes)} | \`${asyncFile}\` | ${formatBytes(result.largestAsync?.rawBytes ?? 0)} | ${formatBytes(result.largestAsync?.gzipBytes ?? 0)} |`,
|
||||||
|
""
|
||||||
|
].join("\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
|
||||||
const packageByModule = {
|
const packageByModule = {
|
||||||
access: "@govoplan/access-webui",
|
access: "@govoplan/access-webui",
|
||||||
@@ -19,7 +20,9 @@ const packageByModule = {
|
|||||||
ops: "@govoplan/ops-webui",
|
ops: "@govoplan/ops-webui",
|
||||||
policy: "@govoplan/policy-webui",
|
policy: "@govoplan/policy-webui",
|
||||||
postbox: "@govoplan/postbox-webui",
|
postbox: "@govoplan/postbox-webui",
|
||||||
|
risk_compliance: "@govoplan/risk-compliance-webui",
|
||||||
scheduling: "@govoplan/scheduling-webui",
|
scheduling: "@govoplan/scheduling-webui",
|
||||||
|
search: "@govoplan/search-webui",
|
||||||
views: "@govoplan/views-webui",
|
views: "@govoplan/views-webui",
|
||||||
workflow: "@govoplan/workflow-webui"
|
workflow: "@govoplan/workflow-webui"
|
||||||
};
|
};
|
||||||
@@ -52,19 +55,23 @@ const cases = [
|
|||||||
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
{ name: "campaign-with-mail-no-files", modules: ["campaigns", "mail"] },
|
||||||
{ name: "scheduling-only", modules: ["scheduling"] },
|
{ name: "scheduling-only", modules: ["scheduling"] },
|
||||||
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
{ name: "scheduling-with-calendar", modules: ["scheduling", "calendar"] },
|
||||||
|
{ name: "search-only", modules: ["search"] },
|
||||||
|
{ name: "risk-compliance-only", modules: ["risk_compliance"] },
|
||||||
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
{ name: "docs-and-ops", modules: ["access", "docs", "ops"] },
|
||||||
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox"] }
|
{ name: "full-product", modules: ["access", "admin", "addresses", "policy", "audit", "dashboard", "datasources", "dataflow", "workflow", "views", "organizations", "idm", "campaigns", "files", "mail", "notifications", "docs", "ops", "calendar", "scheduling", "postbox", "risk_compliance", "search"] }
|
||||||
];
|
];
|
||||||
|
|
||||||
const npmExec = process.env.npm_execpath;
|
const npmExec = process.env.npm_execpath;
|
||||||
const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm");
|
const command = npmExec ? process.execPath : (process.platform === "win32" ? "npm.cmd" : "npm");
|
||||||
const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
|
const baseArgs = npmExec ? [npmExec, "run", "build"] : ["run", "build"];
|
||||||
|
const collectedMetrics = [];
|
||||||
|
|
||||||
for (const testCase of cases) {
|
for (const testCase of cases) {
|
||||||
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
|
const packages = testCase.modules.map((moduleId) => packageByModule[moduleId]).join(",");
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages
|
GOVOPLAN_WEBUI_MODULE_PACKAGES: packages,
|
||||||
|
GOVOPLAN_WEBUI_BUILD_NAME: testCase.name
|
||||||
};
|
};
|
||||||
delete env.npm_config_tmp;
|
delete env.npm_config_tmp;
|
||||||
delete env.NPM_CONFIG_TMP;
|
delete env.NPM_CONFIG_TMP;
|
||||||
@@ -81,4 +88,15 @@ for (const testCase of cases) {
|
|||||||
if (result.status !== 0) {
|
if (result.status !== 0) {
|
||||||
process.exit(result.status ?? 1);
|
process.exit(result.status ?? 1);
|
||||||
}
|
}
|
||||||
|
collectedMetrics.push(
|
||||||
|
JSON.parse(readFileSync(new URL("../dist/bundle-metrics.json", import.meta.url), "utf8"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const metricsDirectory = new URL("../dist/", import.meta.url);
|
||||||
|
mkdirSync(metricsDirectory, { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
new URL("module-permutation-bundle-metrics.json", metricsDirectory),
|
||||||
|
`${JSON.stringify({ schemaVersion: 1, builds: collectedMetrics }, null, 2)}\n`
|
||||||
|
);
|
||||||
|
console.log(`\nRecorded bundle metrics for ${collectedMetrics.length} module permutations.`);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate, Route, Routes } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||||
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
|
import { lazy, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
import { fetchSession, fetchShellAuth, updateProfile } from "./api/auth";
|
||||||
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
import { fetchPlatformModules, fetchPlatformPublicModules, fetchPlatformStatus } from "./api/platform";
|
||||||
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
import { AUTH_REQUIRED_EVENT, isApiError, loadApiSettings, saveApiSettings, type AuthRequiredEventDetail } from "./api/client";
|
||||||
@@ -8,7 +8,7 @@ import AppShell from "./layout/AppShell";
|
|||||||
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
import PublicLandingPage from "./features/auth/PublicLandingPage";
|
||||||
import LoginModal from "./features/auth/LoginModal";
|
import LoginModal from "./features/auth/LoginModal";
|
||||||
import { PermissionBoundary } from "./components/AccessBoundary";
|
import { PermissionBoundary } from "./components/AccessBoundary";
|
||||||
import { firstAccessibleRoute, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, resolveInstalledPublicWebModules, resolveInstalledWebModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
import { firstAccessibleRoute, loadInstalledPublicWebModules, loadInstalledWebModules, loadRemotePublicWebModules, loadRemoteWebModules, moduleInstalled, navItemsForModules, publicRouteContributionsForModules, routeContributionsForModules, uiCapability } from "./platform/modules";
|
||||||
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
import { PlatformModulesProvider } from "./platform/ModuleContext";
|
||||||
import { PlatformViewProvider } from "./platform/ViewContext";
|
import { PlatformViewProvider } from "./platform/ViewContext";
|
||||||
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
|
import { PLATFORM_VIEW_CHANGED_EVENT } from "./platform/views";
|
||||||
@@ -16,6 +16,7 @@ import { PLATFORM_MODULES_CHANGED_EVENT } from "./platform/moduleEvents";
|
|||||||
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
import { UnsavedChangesProvider } from "./components/UnsavedChangesGuard";
|
||||||
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
import { PlatformLanguageProvider, type PlatformLanguage } from "./i18n/LanguageContext";
|
||||||
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
import ViewSurfaceRouteBoundary from "./components/ViewSurfaceRouteBoundary";
|
||||||
|
import ModuleLoadBoundary from "./components/ModuleLoadBoundary";
|
||||||
|
|
||||||
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
const DashboardPage = lazy(() => import("./features/dashboard/DashboardPage"));
|
||||||
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
const SettingsPage = lazy(() => import("./features/settings/SettingsPage"));
|
||||||
@@ -29,22 +30,24 @@ const DEFAULT_UI_PREFERENCES: UserUiPreferences = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const location = useLocation();
|
||||||
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
const [settings, setSettings] = useState<ApiSettings>(() => loadApiSettings());
|
||||||
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||||
const [checkingSession, setCheckingSession] = useState(true);
|
const [checkingSession, setCheckingSession] = useState(true);
|
||||||
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
const [platformModules, setPlatformModules] = useState<PlatformModuleInfo[] | null>(null);
|
||||||
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
const [platformPublicModules, setPlatformPublicModules] = useState<PlatformPublicModuleInfo[] | null>(null);
|
||||||
|
const [localWebModules, setLocalWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
|
const [localPublicWebModules, setLocalPublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
const [remoteWebModules, setRemoteWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
const [remotePublicWebModules, setRemotePublicWebModules] = useState<PlatformWebModule[]>([]);
|
||||||
|
const [webModulesLoading, setWebModulesLoading] = useState(true);
|
||||||
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
const [maintenanceMode, setMaintenanceMode] = useState<{enabled: boolean;message?: string | null;}>({ enabled: false, message: null });
|
||||||
const [backendReachable, setBackendReachable] = useState(true);
|
const [backendReachable, setBackendReachable] = useState(true);
|
||||||
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
const [systemLanguages, setSystemLanguages] = useState<{available: PlatformLanguage[];enabled: string[];defaultLanguage: string;} | null>(null);
|
||||||
const [reloginMessage, setReloginMessage] = useState("");
|
const [reloginMessage, setReloginMessage] = useState("");
|
||||||
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
|
const [viewProjection, setViewProjection] = useState<EffectiveViewProjection | null>(null);
|
||||||
|
|
||||||
const localWebModules = useMemo(() => resolveInstalledWebModules(platformModules), [platformModules]);
|
|
||||||
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
const webModules = useMemo(() => mergeWebModules(localWebModules, remoteWebModules), [localWebModules, remoteWebModules]);
|
||||||
const localPublicWebModules = useMemo(() => resolveInstalledPublicWebModules(platformPublicModules), [platformPublicModules]);
|
|
||||||
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
const publicWebModules = useMemo(() => mergeWebModules(localPublicWebModules, remotePublicWebModules), [localPublicWebModules, remotePublicWebModules]);
|
||||||
const viewsRuntime = useMemo(
|
const viewsRuntime = useMemo(
|
||||||
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
|
() => uiCapability<ViewsRuntimeUiCapability>("views.runtime", webModules),
|
||||||
@@ -112,6 +115,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePublicLogin(response: LoginResponse) {
|
function handlePublicLogin(response: LoginResponse) {
|
||||||
|
setWebModulesLoading(true);
|
||||||
updateAuth(authFromLoginResponse(response), "");
|
updateAuth(authFromLoginResponse(response), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +190,7 @@ export default function App() {
|
|||||||
const shellAuth = await fetchShellAuth(settings);
|
const shellAuth = await fetchShellAuth(settings);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setBackendReachable(true);
|
setBackendReachable(true);
|
||||||
|
setWebModulesLoading(true);
|
||||||
setAuth(normalizeAuthInfo(shellAuth));
|
setAuth(normalizeAuthInfo(shellAuth));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -196,6 +201,7 @@ export default function App() {
|
|||||||
saveApiSettings(cleared);
|
saveApiSettings(cleared);
|
||||||
setAuth(null);
|
setAuth(null);
|
||||||
setPlatformModules(null);
|
setPlatformModules(null);
|
||||||
|
setWebModulesLoading(false);
|
||||||
setRemoteWebModules([]);
|
setRemoteWebModules([]);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -218,7 +224,12 @@ export default function App() {
|
|||||||
lastRefreshAt = Date.now();
|
lastRefreshAt = Date.now();
|
||||||
return fetchPlatformModules(settings).
|
return fetchPlatformModules(settings).
|
||||||
then((response) => {if (!cancelled) setPlatformModules(response.modules);}).
|
then((response) => {if (!cancelled) setPlatformModules(response.modules);}).
|
||||||
catch(() => {if (!cancelled) setPlatformModules(null);}).
|
catch(() => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setPlatformModules(null);
|
||||||
|
setWebModulesLoading(false);
|
||||||
|
}
|
||||||
|
}).
|
||||||
finally(() => {inFlight = false;});
|
finally(() => {inFlight = false;});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +256,70 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, settings.apiBaseUrl, settings.apiKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!auth) {
|
||||||
|
setLocalWebModules([]);
|
||||||
|
setRemoteWebModules([]);
|
||||||
|
setWebModulesLoading(false);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}
|
||||||
|
if (platformModules === null) {
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}
|
||||||
|
if (platformModules.length === 0) {
|
||||||
|
setLocalWebModules([]);
|
||||||
|
setRemoteWebModules([]);
|
||||||
|
setWebModulesLoading(false);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWebModules() {
|
||||||
|
const local = await loadInstalledWebModules(platformModules);
|
||||||
|
if (cancelled) return;
|
||||||
|
setLocalWebModules(local);
|
||||||
|
setWebModulesLoading(false);
|
||||||
|
const remote = await loadRemoteWebModules(platformModules, local);
|
||||||
|
if (!cancelled) setRemoteWebModules(remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadWebModules().catch((error) => {
|
||||||
|
console.error("Failed to load platform WebUI modules", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocalWebModules([]);
|
||||||
|
setRemoteWebModules([]);
|
||||||
|
setWebModulesLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
if (!platformPublicModules?.length) {
|
||||||
|
setLocalPublicWebModules([]);
|
||||||
|
setRemotePublicWebModules([]);
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPublicWebModules() {
|
||||||
|
const local = await loadInstalledPublicWebModules(platformPublicModules);
|
||||||
|
if (cancelled) return;
|
||||||
|
setLocalPublicWebModules(local);
|
||||||
|
const remote = await loadRemotePublicWebModules(platformPublicModules, local);
|
||||||
|
if (!cancelled) setRemotePublicWebModules(remote);
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadPublicWebModules().catch((error) => {
|
||||||
|
console.error("Failed to load public WebUI modules", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setLocalPublicWebModules([]);
|
||||||
|
setRemotePublicWebModules([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {cancelled = true;};
|
||||||
|
}, [platformPublicModules]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const preferences = auth?.user.ui_preferences ?? DEFAULT_UI_PREFERENCES;
|
const preferences = auth?.user.ui_preferences ?? DEFAULT_UI_PREFERENCES;
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
@@ -279,30 +354,6 @@ export default function App() {
|
|||||||
auth?.user.ui_preferences?.theme
|
auth?.user.ui_preferences?.theme
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!auth || !platformModules?.length) {
|
|
||||||
setRemoteWebModules([]);
|
|
||||||
return () => {cancelled = true;};
|
|
||||||
}
|
|
||||||
loadRemoteWebModules(platformModules, localWebModules).
|
|
||||||
then((modules) => {if (!cancelled) setRemoteWebModules(modules);}).
|
|
||||||
catch(() => {if (!cancelled) setRemoteWebModules([]);});
|
|
||||||
return () => {cancelled = true;};
|
|
||||||
}, [auth?.user?.id, auth?.active_tenant?.id, auth?.tenant.id, platformModules, localWebModules]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
if (!platformPublicModules?.length) {
|
|
||||||
setRemotePublicWebModules([]);
|
|
||||||
return () => {cancelled = true;};
|
|
||||||
}
|
|
||||||
loadRemotePublicWebModules(platformPublicModules, localPublicWebModules).
|
|
||||||
then((modules) => {if (!cancelled) setRemotePublicWebModules(modules);}).
|
|
||||||
catch(() => {if (!cancelled) setRemotePublicWebModules([]);});
|
|
||||||
return () => {cancelled = true;};
|
|
||||||
}, [platformPublicModules, localPublicWebModules]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!auth) return;
|
if (!auth) return;
|
||||||
|
|
||||||
@@ -380,7 +431,7 @@ export default function App() {
|
|||||||
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
<PlatformViewProvider modules={publicWebModules} projection={null}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={null} onSettingsChange={updateSettings} onAuthChange={updateAuth} publicMode navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
<ModuleLoadBoundary resetKey={location.pathname}>
|
||||||
<Routes>
|
<Routes>
|
||||||
{publicRoutes.map((route) =>
|
{publicRoutes.map((route) =>
|
||||||
<Route
|
<Route
|
||||||
@@ -391,7 +442,7 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
<Route path="*" element={<PublicLandingPage settings={settings} maintenanceMode={maintenanceMode} onLogin={handlePublicLogin} />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</ModuleLoadBoundary>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
</UnsavedChangesProvider>
|
</UnsavedChangesProvider>
|
||||||
</PlatformViewProvider>
|
</PlatformViewProvider>
|
||||||
@@ -424,7 +475,7 @@ export default function App() {
|
|||||||
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
<PlatformViewProvider modules={webModules} projection={viewProjection}>
|
||||||
<UnsavedChangesProvider>
|
<UnsavedChangesProvider>
|
||||||
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
<AppShell settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} navItems={navItems} maintenanceMode={maintenanceMode} backendReachable={backendReachable}>
|
||||||
<Suspense fallback={<div className="content-pad"><p className="muted">i18n:govoplan-core.loading_module.50161f3c</p></div>}>
|
<ModuleLoadBoundary resetKey={location.pathname} loading={webModulesLoading}>
|
||||||
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
<Routes key={(auth.active_tenant ?? auth.tenant).id}>
|
||||||
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
|
||||||
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
{!dashboardModuleInstalled && <Route path="/dashboard" element={<DashboardPage />} />}
|
||||||
@@ -455,7 +506,7 @@ export default function App() {
|
|||||||
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
<Route path="/settings" element={<SettingsPage settings={settings} auth={auth} onSettingsChange={updateSettings} onAuthChange={updateAuth} />} />
|
||||||
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</ModuleLoadBoundary>
|
||||||
{reloginMessage &&
|
{reloginMessage &&
|
||||||
<LoginModal
|
<LoginModal
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
|||||||
76
webui/src/components/ModuleLoadBoundary.tsx
Normal file
76
webui/src/components/ModuleLoadBoundary.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { Component, Suspense, type ErrorInfo, type ReactNode } from "react";
|
||||||
|
import Button from "./Button";
|
||||||
|
import DismissibleAlert from "./DismissibleAlert";
|
||||||
|
import LoadingIndicator from "./LoadingIndicator";
|
||||||
|
|
||||||
|
type ModuleLoadBoundaryProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
resetKey: string;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ModuleLoadBoundaryState = {
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class ModuleLoadBoundary extends Component<
|
||||||
|
ModuleLoadBoundaryProps,
|
||||||
|
ModuleLoadBoundaryState
|
||||||
|
> {
|
||||||
|
state: ModuleLoadBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ModuleLoadBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||||
|
console.error("GovOPlaN module route failed to load", error, info);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(previousProps: ModuleLoadBoundaryProps) {
|
||||||
|
if (
|
||||||
|
previousProps.resetKey !== this.props.resetKey &&
|
||||||
|
this.state.error
|
||||||
|
) {
|
||||||
|
this.setState({ error: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.props.loading) {
|
||||||
|
return <ModuleLoadProgress />;
|
||||||
|
}
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<div className="content-pad module-load-error">
|
||||||
|
<DismissibleAlert tone="danger" dismissible={false}>
|
||||||
|
<p>i18n:govoplan-core.the_resource_could_not_be_loaded.0d1b6cbf</p>
|
||||||
|
<Button type="button" onClick={() => window.location.reload()}>
|
||||||
|
i18n:govoplan-core.reload.cce71553
|
||||||
|
</Button>
|
||||||
|
</DismissibleAlert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={<ModuleLoadProgress />}
|
||||||
|
>
|
||||||
|
{this.props.children}
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleLoadProgress() {
|
||||||
|
return (
|
||||||
|
<div className="content-pad module-load-progress">
|
||||||
|
<LoadingIndicator
|
||||||
|
label="i18n:govoplan-core.loading_module.50161f3c"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
<span>i18n:govoplan-core.loading_module.50161f3c</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
import { Activity, Bell, BookUser, Building2, CalendarClock, CalendarDays, ClipboardPenLine, DatabaseZap, Folder, Form, Inbox, LayoutDashboard, LayoutTemplate, Mail, Mails, RadioTower, Shield, ShieldCheck, Users, Waypoints, Workflow as WorkflowIcon, type LucideIcon } from "lucide-react";
|
||||||
import installedWebModules from "virtual:govoplan-installed-modules";
|
import installedWebModuleLoaderSource from "virtual:govoplan-installed-modules";
|
||||||
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
|
import type { AuthInfo, DashboardWidgetContribution, DashboardWidgetsUiCapability, EffectiveViewProjection, PlatformModuleInfo, PlatformNavItem, PlatformPublicModuleInfo, PlatformViewSurface, PlatformWebModule } from "../types";
|
||||||
import {
|
import {
|
||||||
hasUiCapability as hasUiCapabilityForModules,
|
hasUiCapability as hasUiCapabilityForModules,
|
||||||
@@ -21,12 +21,20 @@ import {
|
|||||||
export const fallbackDashboardNavItem: PlatformNavItem =
|
export const fallbackDashboardNavItem: PlatformNavItem =
|
||||||
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
{ to: "/dashboard", label: "i18n:govoplan-core.dashboard.d87f47b4", iconName: "dashboard", order: 10 };
|
||||||
|
|
||||||
|
type InstalledWebModuleLoader = {
|
||||||
|
packageName: string;
|
||||||
|
load: () => Promise<{ default: PlatformWebModule }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const installedWebModuleLoaders =
|
||||||
|
installedWebModuleLoaderSource as unknown as InstalledWebModuleLoader[];
|
||||||
|
|
||||||
export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
export function shellNavItemsForModules(modules: PlatformWebModule[]): PlatformNavItem[] {
|
||||||
return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem];
|
return moduleInstalledForModules("dashboard", modules) ? [] : [fallbackDashboardNavItem];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const localModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
||||||
const localModules: PlatformWebModule[] = installedWebModules;
|
const loadedLocalModules = new Map<string, PlatformWebModule>();
|
||||||
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
const remoteModuleCache = new Map<string, Promise<PlatformWebModule | null>>();
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -214,44 +222,39 @@ function publicModuleInfo(info: PlatformPublicModuleInfo): PlatformModuleInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveInstalledPublicWebModules(
|
export async function loadInstalledPublicWebModules(
|
||||||
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
platformModules: PlatformPublicModuleInfo[] | null | undefined
|
||||||
): PlatformWebModule[] {
|
): Promise<PlatformWebModule[]> {
|
||||||
if (!platformModules?.length) return [];
|
if (!platformModules?.length) return [];
|
||||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
const loaded = await loadInstalledWebModules(platformModules.map(publicModuleInfo));
|
||||||
return platformModules.flatMap((info) => {
|
return loaded.filter((module) => module.publicRoutes?.length);
|
||||||
const local = localById.get(info.id);
|
|
||||||
if (!local) return [];
|
|
||||||
const resolved = applyServerMetadata(local, publicModuleInfo(info));
|
|
||||||
return resolved.publicRoutes?.length ? [resolved] : [];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRemotePublicWebModules(
|
export async function loadRemotePublicWebModules(
|
||||||
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
platformModules: PlatformPublicModuleInfo[] | null | undefined,
|
||||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledPublicWebModules(platformModules)
|
alreadyLoaded: PlatformWebModule[] = []
|
||||||
): Promise<PlatformWebModule[]> {
|
): Promise<PlatformWebModule[]> {
|
||||||
if (!platformModules?.length) return [];
|
if (!platformModules?.length) return [];
|
||||||
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
const loaded = await loadRemoteWebModules(platformModules.map(publicModuleInfo), alreadyLoaded);
|
||||||
return loaded.filter((module) => module.publicRoutes?.length);
|
return loaded.filter((module) => module.publicRoutes?.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveInstalledWebModules(platformModules: PlatformModuleInfo[] | null | undefined): PlatformWebModule[] {
|
export async function loadInstalledWebModules(
|
||||||
if (!platformModules?.length) return localModules;
|
platformModules: PlatformModuleInfo[] | null | undefined
|
||||||
|
): Promise<PlatformWebModule[]> {
|
||||||
|
if (!platformModules?.length) return [];
|
||||||
|
|
||||||
const localById = new Map(localModules.map((module) => [module.id, module]));
|
const enabledModules = platformModules.filter((module) => module.enabled);
|
||||||
return platformModules.
|
const resolved = await Promise.all(enabledModules.map(async (info) => {
|
||||||
filter((module) => module.enabled).
|
const local = await loadInstalledWebModule(info);
|
||||||
map((module) => {
|
return local ? applyServerMetadata(local, info) : null;
|
||||||
const local = localById.get(module.id);
|
}));
|
||||||
return local ? applyServerMetadata(local, module) : null;
|
return resolved.filter((module): module is PlatformWebModule => module !== null);
|
||||||
}).
|
|
||||||
filter((module): module is PlatformWebModule => module !== null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRemoteWebModules(
|
export async function loadRemoteWebModules(
|
||||||
platformModules: PlatformModuleInfo[] | null | undefined,
|
platformModules: PlatformModuleInfo[] | null | undefined,
|
||||||
alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules))
|
alreadyLoaded: PlatformWebModule[] = [])
|
||||||
: Promise<PlatformWebModule[]> {
|
: Promise<PlatformWebModule[]> {
|
||||||
if (!platformModules?.length) return [];
|
if (!platformModules?.length) return [];
|
||||||
const loadedIds = new Set(alreadyLoaded.map((module) => module.id));
|
const loadedIds = new Set(alreadyLoaded.map((module) => module.id));
|
||||||
@@ -274,6 +277,56 @@ alreadyLoaded: PlatformWebModule[] = resolveInstalledWebModules(platformModules)
|
|||||||
return loaded.filter((module): module is PlatformWebModule => module !== null);
|
return loaded.filter((module): module is PlatformWebModule => module !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadInstalledWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
|
||||||
|
if (!info.frontend) return null;
|
||||||
|
const packageName = info.frontend?.package_name;
|
||||||
|
if (packageName) {
|
||||||
|
const loader = installedWebModuleLoaders.find((candidate) => candidate.packageName === packageName);
|
||||||
|
return loader ? loadInstalledWebModulePackage(loader, info.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy manifests may omit package_name. Keep compatibility without making
|
||||||
|
// every modern module eager: only this fallback imports all installed descriptors.
|
||||||
|
const candidates = await Promise.all(
|
||||||
|
installedWebModuleLoaders.map((loader) => loadInstalledWebModulePackage(loader))
|
||||||
|
);
|
||||||
|
return candidates.find((candidate) => candidate?.id === info.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstalledWebModulePackage(
|
||||||
|
loader: InstalledWebModuleLoader,
|
||||||
|
expectedModuleId?: string
|
||||||
|
): Promise<PlatformWebModule | null> {
|
||||||
|
let promise = localModuleCache.get(loader.packageName);
|
||||||
|
if (!promise) {
|
||||||
|
promise = loader.load().
|
||||||
|
then((imported) => {
|
||||||
|
const module = imported.default;
|
||||||
|
if (!isPlatformWebModule(module)) {
|
||||||
|
throw new Error(`${loader.packageName} does not export a PlatformWebModule`);
|
||||||
|
}
|
||||||
|
loadedLocalModules.set(loader.packageName, module);
|
||||||
|
return module;
|
||||||
|
}).
|
||||||
|
catch((error) => {
|
||||||
|
localModuleCache.delete(loader.packageName);
|
||||||
|
console.warn("GovOPlaN installed WebUI module was not loaded:", loader.packageName, error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
localModuleCache.set(loader.packageName, promise);
|
||||||
|
}
|
||||||
|
const module = await promise;
|
||||||
|
if (module && expectedModuleId && module.id !== expectedModuleId) {
|
||||||
|
console.warn(
|
||||||
|
"GovOPlaN installed WebUI package id mismatch:",
|
||||||
|
loader.packageName,
|
||||||
|
`expected ${expectedModuleId}, received ${module.id}`
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return module;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
|
async function loadRemoteWebModule(info: PlatformModuleInfo): Promise<PlatformWebModule | null> {
|
||||||
const frontend = info.frontend;
|
const frontend = info.frontend;
|
||||||
if (!frontend?.asset_manifest) return null;
|
if (!frontend?.asset_manifest) return null;
|
||||||
@@ -387,26 +440,26 @@ function constantTimeEqual(left: string, right: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function installedLocalWebModules(): PlatformWebModule[] {
|
export function installedLocalWebModules(): PlatformWebModule[] {
|
||||||
return localModules;
|
return [...loadedLocalModules.values()];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T | null {
|
export function uiCapability<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T | null {
|
||||||
return uiCapabilityForModules<T>(capabilityName, modules);
|
return uiCapabilityForModules<T>(capabilityName, modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = localModules): T[] {
|
export function uiCapabilities<T = unknown>(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): T[] {
|
||||||
return uiCapabilitiesForModules<T>(capabilityName, modules);
|
return uiCapabilitiesForModules<T>(capabilityName, modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = localModules): boolean {
|
export function hasUiCapability(capabilityName: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
|
||||||
return hasUiCapabilityForModules(capabilityName, modules);
|
return hasUiCapabilityForModules(capabilityName, modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = localModules): boolean {
|
export function moduleInstalled(moduleId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
|
||||||
return moduleInstalledForModules(moduleId, modules);
|
return moduleInstalledForModules(moduleId, modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = localModules): boolean {
|
export function moduleIntegrationEnabled(moduleId: string, dependencyId: string, modules: PlatformWebModule[] = installedLocalWebModules()): boolean {
|
||||||
return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules);
|
return moduleIntegrationEnabledForModules(moduleId, dependencyId, modules);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +472,7 @@ export function publicRouteContributionsForModules(modules: PlatformWebModule[])
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function dashboardWidgetsForModules(
|
export function dashboardWidgetsForModules(
|
||||||
modules: PlatformWebModule[] = localModules,
|
modules: PlatformWebModule[] = installedLocalWebModules(),
|
||||||
projection?: EffectiveViewProjection | null
|
projection?: EffectiveViewProjection | null
|
||||||
): DashboardWidgetContribution[] {
|
): DashboardWidgetContribution[] {
|
||||||
const catalogue = viewSurfaceCatalogueForModules(modules);
|
const catalogue = viewSurfaceCatalogueForModules(modules);
|
||||||
@@ -446,7 +499,7 @@ export function navItemsForModules(
|
|||||||
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
sort((left, right) => (left.order ?? 100) - (right.order ?? 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
export function visibleNavItems(auth: AuthInfo | null | undefined, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): PlatformNavItem[] {
|
||||||
return navItemsForModules(modules, projection).filter((item) => {
|
return navItemsForModules(modules, projection).filter((item) => {
|
||||||
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
if (item.allOf?.length && !item.allOf.every((scope) => hasScope(auth, scope))) return false;
|
||||||
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
if (item.anyOf?.length && !hasAnyScope(auth, item.anyOf)) return false;
|
||||||
@@ -454,6 +507,6 @@ export function visibleNavItems(auth: AuthInfo | null | undefined, modules: Plat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = localModules, projection?: EffectiveViewProjection | null): string {
|
export function firstAccessibleRoute(auth: AuthInfo, modules: PlatformWebModule[] = installedLocalWebModules(), projection?: EffectiveViewProjection | null): string {
|
||||||
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
|
return visibleNavItems(auth, modules, projection)[0]?.to ?? "/dashboard";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2204,6 +2204,29 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-load-progress {
|
||||||
|
display: flex;
|
||||||
|
min-height: 120px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-load-error .alert {
|
||||||
|
max-width: 640px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-load-error .alert-message {
|
||||||
|
display: grid;
|
||||||
|
justify-items: start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.module-load-error .alert-message p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* Collapsible cards */
|
/* Collapsible cards */
|
||||||
.card-collapsible .card-header {
|
.card-collapsible .card-header {
|
||||||
|
|||||||
9
webui/src/vite-env.d.ts
vendored
9
webui/src/vite-env.d.ts
vendored
@@ -4,7 +4,10 @@
|
|||||||
declare module "virtual:govoplan-installed-modules" {
|
declare module "virtual:govoplan-installed-modules" {
|
||||||
import type { PlatformWebModule } from "@govoplan/core-webui";
|
import type { PlatformWebModule } from "@govoplan/core-webui";
|
||||||
|
|
||||||
const installedWebModules: PlatformWebModule[];
|
const installedWebModuleLoaders: Array<{
|
||||||
export { installedWebModules };
|
packageName: string;
|
||||||
export default installedWebModules;
|
load: () => Promise<{ default: PlatformWebModule }>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export default installedWebModuleLoaders;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const require = createRequire(import.meta.url);
|
|||||||
const webuiRoot = dirname(fileURLToPath(import.meta.url));
|
const webuiRoot = dirname(fileURLToPath(import.meta.url));
|
||||||
const installedModulesVirtualId = "virtual:govoplan-installed-modules";
|
const installedModulesVirtualId = "virtual:govoplan-installed-modules";
|
||||||
const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
|
const resolvedInstalledModulesVirtualId = `\0${installedModulesVirtualId}`;
|
||||||
|
const installedModuleVirtualPrefix = "virtual:govoplan-installed-module/";
|
||||||
|
const resolvedInstalledModuleVirtualPrefix = `\0${installedModuleVirtualPrefix}`;
|
||||||
|
|
||||||
const defaultWebModulePackages = [
|
const defaultWebModulePackages = [
|
||||||
"@govoplan/access-webui",
|
"@govoplan/access-webui",
|
||||||
@@ -31,6 +33,7 @@ const defaultWebModulePackages = [
|
|||||||
"@govoplan/postbox-webui",
|
"@govoplan/postbox-webui",
|
||||||
"@govoplan/risk-compliance-webui",
|
"@govoplan/risk-compliance-webui",
|
||||||
"@govoplan/scheduling-webui",
|
"@govoplan/scheduling-webui",
|
||||||
|
"@govoplan/search-webui",
|
||||||
"@govoplan/views-webui",
|
"@govoplan/views-webui",
|
||||||
"@govoplan/workflow-webui"
|
"@govoplan/workflow-webui"
|
||||||
];
|
];
|
||||||
@@ -61,21 +64,42 @@ function availableWebModuleSpecifiers(): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function govoplanInstalledModulesPlugin(): Plugin {
|
function govoplanInstalledModulesPlugin(): Plugin {
|
||||||
|
const specifierByModuleVirtualId = new Map(
|
||||||
|
availableWebModuleSpecifiers().map((specifier) => [
|
||||||
|
`${installedModuleVirtualPrefix}${encodeURIComponent(specifier)}`,
|
||||||
|
specifier
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "govoplan-installed-modules",
|
name: "govoplan-installed-modules",
|
||||||
resolveId(id: string) {
|
resolveId(id: string) {
|
||||||
if (id === installedModulesVirtualId) return resolvedInstalledModulesVirtualId;
|
if (id === installedModulesVirtualId) return resolvedInstalledModulesVirtualId;
|
||||||
|
if (specifierByModuleVirtualId.has(id)) return `\0${id}`;
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
load(id: string) {
|
load(id: string) {
|
||||||
if (id !== resolvedInstalledModulesVirtualId) return null;
|
if (id === resolvedInstalledModulesVirtualId) {
|
||||||
const imports = availableWebModuleSpecifiers();
|
const loaders = [...specifierByModuleVirtualId].map(([virtualId, specifier]) => (
|
||||||
|
`{ packageName: ${JSON.stringify(specifier)}, load: () => import(${JSON.stringify(virtualId)}) }`
|
||||||
|
));
|
||||||
return [
|
return [
|
||||||
...imports.map((specifier, index) => `import module${index} from ${JSON.stringify(specifier)};`),
|
`export const installedWebModuleLoaders = [${loaders.join(", ")}];`,
|
||||||
`export const installedWebModules = [${imports.map((_specifier, index) => `module${index}`).join(", ")}];`,
|
"export default installedWebModuleLoaders;"
|
||||||
"export default installedWebModules;"
|
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
if (!id.startsWith(resolvedInstalledModuleVirtualPrefix)) return null;
|
||||||
|
const publicVirtualId = id.slice(1);
|
||||||
|
const specifier = specifierByModuleVirtualId.get(publicVirtualId);
|
||||||
|
if (!specifier) return null;
|
||||||
|
const moduleEntry = join(packageDirectory(specifier), "src", "module.ts");
|
||||||
|
if (!existsSync(moduleEntry)) {
|
||||||
|
throw new Error(`${specifier} does not expose src/module.ts`);
|
||||||
|
}
|
||||||
|
// Import the contribution descriptor directly. Package root barrels may
|
||||||
|
// intentionally re-export pages and would otherwise defeat route splitting.
|
||||||
|
return `export { default } from ${JSON.stringify(moduleEntry)};`;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,9 +112,8 @@ export default defineConfig({
|
|||||||
include: ["@xyflow/react"]
|
include: ["@xyflow/react"]
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
// Full-product builds include the host shell plus all installed module wiring.
|
manifest: true,
|
||||||
// Dedicated route/vendor splitting belongs in a separate performance pass.
|
chunkSizeWarningLimit: 500
|
||||||
chunkSizeWarningLimit: 1200
|
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
preserveSymlinks: false,
|
preserveSymlinks: false,
|
||||||
@@ -132,6 +155,7 @@ export default defineConfig({
|
|||||||
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-postbox/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-risk-compliance/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-scheduling/webui', import.meta.url)),
|
||||||
|
fileURLToPath(new URL('../../govoplan-search/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
|
fileURLToPath(new URL('../../govoplan-views/webui', import.meta.url)),
|
||||||
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
fileURLToPath(new URL('../../govoplan-workflow/webui', import.meta.url))
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user