diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1e8c79e1a..b21867320 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,15 +22,6 @@ jobs: with: fetch-depth: 0 - - name: Cache Bun dependencies - id: cache - uses: actions/cache@v5 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-build- - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -39,15 +30,6 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Cache nx build cache - id: cache-nx - uses: actions/cache@v5 - with: - path: node_modules/.cache/nx - key: ${{ runner.os }}-nx-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-nx- - - name: Setup version run: bun run postinstall @@ -93,6 +75,9 @@ jobs: url: ${{ secrets.STATUS_URL }} data: '{ "chat_url": "${{ secrets.CHAT_URL }}", "name": "${{ github.repository }}", "commit": "${{ github.sha }}", "pipeline_name": "backoffice", "branch": "${{ github.ref }}", "status": "processing", "url": "https://github.com/${{ github.repository }}/runs/${{ github.run_id }}" }' + # Do not restore an Nx cache for this build. Nx does not hash the + # gitignored src/env/version.ts, so a cached build could deploy + # the version info of an earlier commit. - name: Build application run: bun run build -- --verbose --configuration=${{ steps.build_ext.outputs.configuration }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 67daf9203..b228d7d1a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,7 +1,10 @@ name: "Code scanning - action" on: + # Pull requests are scanned by the pull_request trigger. Match the BUILD + # branches so that a pull request push is not scanned twice. push: + branches: [develop, release/**, rc/**, release] pull_request: schedule: - cron: '0 10 * * 4' @@ -10,7 +13,7 @@ jobs: CodeQL-Build: runs-on: ubuntu-latest - timeout-minutes: 3 + timeout-minutes: 10 steps: - name: Checkout repository @@ -28,16 +31,7 @@ jobs: - name: Initialize CodeQL uses: github/codeql-action/init@v3 - - name: Setup Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: '1.3.11' - - - name: Install dependencies - run: bun install --frozen-lockfile - - - name: Setup version - run: bun run postinstall - + # JavaScript and TypeScript extraction reads the source files only, so the + # dependencies are not installed. - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2720780b3..3989eafb6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,17 @@ name: TEST on: pull_request: branches: [develop] + paths-ignore: + - 'docs/**' + - '**/*.md' workflow_call: +# Cancel superseded runs of a pull request. BUILD calls this workflow for +# pushes; those runs get a unique group, so they never queue or cancel. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.ref || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: lint: runs-on: ubuntu-latest @@ -12,15 +21,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Cache Bun dependencies - id: cache - uses: actions/cache@v5 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-build- - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -38,15 +38,6 @@ jobs: steps: - uses: actions/checkout@v6 - - name: Cache Bun dependencies - id: cache - uses: actions/cache@v5 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-build- - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -55,14 +46,14 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + # Nx skips the tests when their inputs match a cached run. - name: Cache nx build cache - id: cache-nx uses: actions/cache@v5 with: - path: node_modules/.cache/nx - key: ${{ runner.os }}-nx-${{ hashFiles('**/bun.lock') }} + path: .nx/cache + key: ${{ runner.os }}-nx-test-${{ github.sha }} restore-keys: | - ${{ runner.os }}-nx- + ${{ runner.os }}-nx-test- - name: Setup version run: bun run postinstall @@ -73,18 +64,13 @@ jobs: e2e-test: runs-on: ubuntu-latest timeout-minutes: 25 + env: + # Pull requests to develop and pushes to develop run Chromium only. + # Other branches run every browser. + CHROMIUM_ONLY: ${{ github.base_ref == 'develop' || github.ref == 'refs/heads/develop' }} steps: - uses: actions/checkout@v6 - - name: Cache Bun dependencies - id: cache - uses: actions/cache@v5 - with: - path: ~/.bun/install/cache - key: ${{ runner.os }}-build-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-build- - - name: Setup Bun uses: oven-sh/setup-bun@v2 with: @@ -93,23 +79,31 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile - - name: Cache nx build cache - id: cache-nx - uses: actions/cache@v5 - with: - path: node_modules/.cache/nx - key: ${{ runner.os }}-nx-${{ hashFiles('**/bun.lock') }} - restore-keys: | - ${{ runner.os }}-nx- - - name: Setup version run: bun run postinstall + - name: Get Playwright version + id: playwright + run: echo "version=$(bunx playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v5 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright.outputs.version }}-${{ env.CHROMIUM_ONLY == 'true' && 'chromium' || 'all' }} + - name: Install Playwright browsers - run: bunx playwright install --with-deps + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: bunx playwright install --with-deps ${{ env.CHROMIUM_ONLY == 'true' && 'chromium' || '' }} + + # The cache holds the browsers only. The system packages still need to be installed. + - name: Install Playwright system dependencies + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: bunx playwright install-deps ${{ env.CHROMIUM_ONLY == 'true' && 'chromium' || '' }} - name: Run E2E tests - run: bunx nx e2e e2e ${{ (github.base_ref == 'develop' || github.ref == 'refs/heads/develop') && '-- --project=chromium --project=mobile-chrome' || '' }} + run: bunx nx e2e e2e ${{ env.CHROMIUM_ONLY == 'true' && '-- --project=chromium --project=mobile-chrome' || '' }} - name: Upload Playwright report uses: actions/upload-artifact@v4 diff --git a/bun.lock b/bun.lock index dae2974c3..f35dd7cce 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "@angular/material": "22.1.8", "@angular/pwa": "22.1.9", "@eslint/js": "^9.8.0", + "@fontsource-variable/roboto": "^5.3.0", "@ngneat/spectator": "^22.1.0", "@nx/angular": "23.3.0", "@nx/devkit": "23.3.0", @@ -524,6 +525,8 @@ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + "@fontsource-variable/roboto": ["@fontsource-variable/roboto@5.3.0", "", {}, "sha512-BoaaZiQf8fgtxv07KXkTwx1bKz/EoqIlTXq2OOKKXINxp6qXWdnlChqV+hSaDANS49yyUASFl3OVBMMDTCIHFA=="], + "@harperfast/extended-iterable": ["@harperfast/extended-iterable@1.0.3", "", {}, "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw=="], "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], diff --git a/config/build-icon-fonts.py b/config/build-icon-fonts.py index 642448314..92af03f36 100644 --- a/config/build-icon-fonts.py +++ b/config/build-icon-fonts.py @@ -13,14 +13,20 @@ ROOT = Path(__file__).resolve().parent.parent SOURCE = ROOT / "node_modules/material-symbols" -DESTINATION = ROOT / "public/assets" +# The fonts are bundled through src/fonts/material-symbols.css. The license +# stays in public/assets so that it ships with the app. +DESTINATION = ROOT / "src/fonts" +LICENSE_DESTINATION = ROOT / "public/assets" for style in ("rounded", "outlined", "sharp"): name = f"material-symbols-{style}.woff2" font = TTFont(SOURCE / name, recalcTimestamp=False) - # Keep optical sizing. The app uses normal weight and unfilled icons. - instantiateVariableFont(font, {"wght": 400, "FILL": 0, "GRAD": 0}, inplace=True) + # The app does not set font-variation-settings, so pin every axis to its + # default: 24px optical size, normal weight, unfilled, no grade. + instantiateVariableFont( + font, {"opsz": 24, "wght": 400, "FILL": 0, "GRAD": 0}, inplace=True + ) font.save(DESTINATION / name) print(f"{name}: {(DESTINATION / name).stat().st_size:,} bytes") -copyfile(SOURCE / "LICENSE", DESTINATION / "material-symbols-LICENSE.txt") +copyfile(SOURCE / "LICENSE", LICENSE_DESTINATION / "material-symbols-LICENSE.txt") diff --git a/docs/bundle-size.md b/docs/bundle-size.md index c8404e474..0cf71cfa6 100644 --- a/docs/bundle-size.md +++ b/docs/bundle-size.md @@ -11,12 +11,13 @@ service worker manifest as well. They can add downloads outside that budget. ## Icon fonts -The fonts in `public/assets` retain all icon names, including names supplied by -the server. They use weight 400, fill 0, and grade 0. Optical sizing remains -variable. The source is `material-symbols` 0.39.3, with its license copied beside -the fonts. +The fonts in `src/fonts` retain all icon names, including names supplied by +the server. They use optical size 24, weight 400, fill 0, and grade 0. The app +does not set `font-variation-settings`. The source is `material-symbols` 0.39.3. +The build bundles the fonts through `src/fonts/material-symbols.css` with hashed +names. The license is in `public/assets`. -After updating that package, regenerate the fonts and review the rendered icons: +After updating `material-symbols`, regenerate the fonts and review the rendered icons: ```sh python3 -m venv /tmp/backoffice-font-tools @@ -27,6 +28,10 @@ python3 -m venv /tmp/backoffice-font-tools Normal builds use the checked-in fonts and do not require Python or network access to generate them. Do not add the original variable fonts to the build asset list. +Roboto comes from `@fontsource-variable/roboto` and Fira Code from `firacode`. +The `styles` list in `project.json` bundles their CSS. Use the font family +`'Roboto Variable'` for Roboto. + ## Editors and highlighting Settings and diff editors call `loadMonaco()` before using its API. The loader @@ -34,7 +39,8 @@ shares concurrent requests, has a 30-second timeout, and permits retry. Componen must check whether they were destroyed before creating an editor after the load. `markdown-languages.ts` lists the existing web languages and aliases explicitly. -Only `github-dark` is packaged. Add new language loaders there. Importing the +Only `github-dark` is packaged. Shiki uses the JavaScript regex engine, so the +Oniguruma wasm is not downloaded. Add new language loaders there. Importing the Shiki web preset at runtime also brings its unused theme registry into the build. ## Themes and caching diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 01594119a..faaa0cd94 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -40,6 +40,11 @@ const baseURL = process.env['BASE_URL'] || 'http://localhost:4200'; export default defineConfig({ ...nxE2EPreset(__filename, { testDir: './src' }), + /* The preset uses 1 worker on CI. Each page has its own mock backend, so + tests can run in parallel. GitHub runners have 4 vCPUs; keep one free + for the dev server. */ + workers: process.env['CI'] ? 3 : undefined, + /* Test timeout - increase for mock mode as it may need longer to initialize */ timeout: 60000, diff --git a/ngsw-config.json b/ngsw-config.json index 7801230c6..4f6d665b6 100644 --- a/ngsw-config.json +++ b/ngsw-config.json @@ -30,6 +30,7 @@ "resources": { "files": [ "/assets/**", + "/media/**", "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" ] } diff --git a/nx.json b/nx.json index 7c4406a00..a623d6f84 100644 --- a/nx.json +++ b/nx.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/nx/schemas/nx-schema.json", - "defaultBase": "trunk", + "defaultBase": "develop", + "cacheDirectory": ".nx/cache", "namedInputs": { "default": ["{projectRoot}/**/*", "sharedGlobals"], "production": [ @@ -9,9 +10,11 @@ "!{projectRoot}/eslint.config.mjs", "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", "!{projectRoot}/tsconfig.spec.json", - "!{projectRoot}/src/test-setup.[jt]s" + "!{projectRoot}/src/test-setup.[jt]s", + "!{projectRoot}/docs/**/*", + "!{projectRoot}/user-stories/**/*" ], - "sharedGlobals": ["{workspaceRoot}/.github/workflows/ci.yml"] + "sharedGlobals": [] }, "targetDefaults": { "@angular/build:application": { diff --git a/package.json b/package.json index b4d6555dc..e28285840 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "nx serve", "build": "nx build", "test": "nx test", - "test:ci": "nx test backoffice --runInBand", + "test:ci": "nx test backoffice", "lint": "nx lint backoffice", "e2e": "nx e2e backoffice" }, @@ -40,6 +40,7 @@ "@angular/material": "22.1.8", "@angular/pwa": "22.1.9", "@eslint/js": "^9.8.0", + "@fontsource-variable/roboto": "^5.3.0", "@ngneat/spectator": "^22.1.0", "@nx/angular": "23.3.0", "@nx/devkit": "23.3.0", diff --git a/project.json b/project.json index 4415c3c74..1eb407199 100644 --- a/project.json +++ b/project.json @@ -26,21 +26,6 @@ "input": "node_modules/@placeos/ts-client/dist/", "output": "/" }, - { - "glob": "**/*.woff2", - "input": "node_modules/firacode/distr", - "output": "/assets/firacode" - }, - { - "glob": "**/*.woff", - "input": "node_modules/firacode/distr", - "output": "/assets/firacode" - }, - { - "glob": "**/*.css", - "input": "node_modules/firacode/distr", - "output": "/assets/firacode" - }, { "glob": "**/*", "input": "node_modules/monaco-editor/min/", @@ -57,7 +42,14 @@ "glob": "*worker.js" } ], - "styles": ["./src/styles.css", "./src/material-theme.scss"] + "styles": [ + "./node_modules/@fontsource-variable/roboto/index.css", + "./node_modules/@fontsource-variable/roboto/wght-italic.css", + "./src/fonts/material-symbols.css", + "./node_modules/firacode/distr/fira_code.css", + "./src/styles.css", + "./src/material-theme.scss" + ] }, "configurations": { "production": { @@ -65,7 +57,7 @@ "budgets": [ { "type": "initial", - "maximumWarning": "1mb", + "maximumWarning": "750kb", "maximumError": "1.5mb" }, { diff --git a/public/assets/material-symbols-outlined.woff2 b/public/assets/material-symbols-outlined.woff2 deleted file mode 100644 index b01195dcd..000000000 Binary files a/public/assets/material-symbols-outlined.woff2 and /dev/null differ diff --git a/public/assets/material-symbols-rounded.woff2 b/public/assets/material-symbols-rounded.woff2 deleted file mode 100644 index d2e74781c..000000000 Binary files a/public/assets/material-symbols-rounded.woff2 and /dev/null differ diff --git a/public/assets/material-symbols-sharp.woff2 b/public/assets/material-symbols-sharp.woff2 deleted file mode 100644 index ad98d22d6..000000000 Binary files a/public/assets/material-symbols-sharp.woff2 and /dev/null differ diff --git a/src/app/admin/cluster-details/cluster-details.component.ts b/src/app/admin/cluster-details/cluster-details.component.ts index 8035d39d2..f3398b680 100644 --- a/src/app/admin/cluster-details/cluster-details.component.ts +++ b/src/app/admin/cluster-details/cluster-details.component.ts @@ -85,7 +85,9 @@ import { AdminClusterNodeComponent } from './cluster-node.component'; export class PlaceClusterDetailsComponent extends AsyncHandler { /** List of available clusters on this instance of engine */ public cluster_list = signal([]); - public cluster_nodes = signal({}); + public cluster_nodes = signal< + HashMap> + >({}); /** Map of clusters to CPU usage history */ public usage_history: HashMap< HashMap> @@ -100,7 +102,8 @@ export class PlaceClusterDetailsComponent extends AsyncHandler { } private async updateClusters() { - if (this.loading()) return; + // Skip a tick while hidden or while the last request is in flight + if (this.loading() || document.hidden) return; this.loading.set(true); const response = await queryClusters({ include_status: false }).catch( () => ({ data: [] as PlaceCluster[] }), @@ -108,7 +111,9 @@ export class PlaceClusterDetailsComponent extends AsyncHandler { const list = response.data || []; this.cluster_list.set(list); const date = Date.now(); - const node_map = this.cluster_nodes(); + // A new object, so the signal notifies and removed clusters drop out + const node_map: HashMap> = + {}; this.cluster_list().forEach((cluster) => { if (!this.usage_history[cluster.id]) this.usage_history[cluster.id] = {}; diff --git a/src/app/admin/cluster-details/cluster-task-list.component.ts b/src/app/admin/cluster-details/cluster-task-list.component.ts index 1ca8898cf..5b4085291 100644 --- a/src/app/admin/cluster-details/cluster-task-list.component.ts +++ b/src/app/admin/cluster-details/cluster-task-list.component.ts @@ -242,7 +242,15 @@ export class PlaceClusterTaskListComponent } public ngOnInit() { - this.interval('poll', () => this.updateProcessList(), 15 * 1000); + this.interval( + 'poll', + () => { + // Skip a tick while hidden or while the last request is in flight + if (document.hidden || this.loading()) return; + this.updateProcessList(); + }, + 15 * 1000, + ); this.updateProcessList(); } @@ -309,11 +317,14 @@ export class PlaceClusterTaskListComponent } public async updateProcessList() { - if (!this.cluster()) return; + const cluster = this.cluster(); + if (!cluster) return; this.loading.set(true); - const list = await queryProcesses(this.cluster().id, { + const list = await queryProcesses(cluster.id, { include_status: true, }).catch(() => []); + // The page moved to another cluster while this was loading + if (cluster !== this.cluster()) return; this.process_list.set( list.sort((a, b) => b.module_instances - a.module_instances), ); diff --git a/src/app/admin/database-details.component.ts b/src/app/admin/database-details.component.ts index 8a7a69433..0974d2e1a 100644 --- a/src/app/admin/database-details.component.ts +++ b/src/app/admin/database-details.component.ts @@ -4,7 +4,6 @@ import { PlaceSourceResyncStatus, PlaceZone, queryZones, - showZone, sourceResync, sourceResyncStatus, } from '@placeos/ts-client'; @@ -13,6 +12,7 @@ import { MatRippleModule } from '@angular/material/core'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { AsyncHandler } from '../common/async-handler.class'; +import { mapLimit } from '../common/cascade-delete'; import { readError } from '../common/errors'; import { csvToJson, downloadFile, jsonToCsv } from '../common/general'; import { @@ -25,6 +25,12 @@ import { ZoneTreeExportModalComponent } from './zone-tree-export-modal.component /** How often a running MQTT re-sync is checked on */ const MQTT_STATUS_POLL_MS = 2000; +/** Ceiling on pages followed for one zone query */ +const MAX_ZONE_PAGES = 100; +/** Parent ids sent in one zone query, to keep the URL short */ +const PARENT_IDS_PER_QUERY = 50; +/** Zone requests sent at once during an export or import */ +const ZONE_CONCURRENCY = 4; type ZoneTreeExportItem = Record & { id: string; @@ -81,7 +87,10 @@ function zoneToImportItem( async function queryAllZones(query_params: Record = {}) { let response = await queryZones({ ...query_params, limit: 500 }); const zones = [...response.data]; - while (response.next) { + for (let page = 1; response.next; page++) { + // A partial export would look complete, so fail instead + if (page >= MAX_ZONE_PAGES) + throw new Error(`Zone list exceeds ${MAX_ZONE_PAGES} pages`); response = await response.next(); zones.push(...response.data); } @@ -309,14 +318,20 @@ export class PlaceDatabaseDetailsComponent /** Follows a running re-sync and says how it ended */ private _watchMqttStatus() { + let checking = false; this.interval( 'mqtt_status', async () => { + // Skip a tick while hidden or while the last check is in flight + if (checking || document.hidden) return; + checking = true; let status: PlaceSourceResyncStatus; try { status = await sourceResyncStatus(); } catch { return; + } finally { + checking = false; } this.mqtt_status.set(status); if (status.state === 'complete') { @@ -375,11 +390,7 @@ export class PlaceDatabaseDetailsComponent private async exportSelectedZoneTrees(parents: PlaceZone[]) { this.exporting_zones.set(true); try { - const zones: ZoneTreeExportItem[] = []; - const visited = new Set(); - for (const parent of parents) { - await this.addZoneTreeToExport(parent.id, zones, visited); - } + const zones = await this.zoneTreeExport(parents); downloadFile( `zone-tree-export-${new Date().toISOString()}.csv`, jsonToCsv(zones, ZONE_TREE_CSV_FIELDS), @@ -393,19 +404,33 @@ export class PlaceDatabaseDetailsComponent this.exporting_zones.set(false); } - private async addZoneTreeToExport( - zone_id: string, - zones: ZoneTreeExportItem[], - visited: Set, - ) { - if (visited.has(zone_id)) return; - visited.add(zone_id); - const zone = await showZone(zone_id); - const children = await queryAllZones({ parent_id: zone_id }); - zones.push(zoneToExportItem(zone)); - for (const child of children) { - await this.addZoneTreeToExport(child.id, zones, visited); + /** + * The selected zones and every zone beneath them, walked one tree level + * at a time so parents always come before their children. + */ + private async zoneTreeExport(parents: PlaceZone[]) { + const zones: ZoneTreeExportItem[] = []; + const visited = new Set(); + let level = parents; + // Each pass only keeps unvisited zones, so a cyclic parent_id ends it + while (level.length) { + const ids: string[] = []; + for (const zone of level) { + if (!zone?.id || visited.has(zone.id)) continue; + visited.add(zone.id); + ids.push(zone.id); + zones.push(zoneToExportItem(zone)); + } + const chunks: string[][] = []; + for (let i = 0; i < ids.length; i += PARENT_IDS_PER_QUERY) { + chunks.push(ids.slice(i, i + PARENT_IDS_PER_QUERY)); + } + const children = await mapLimit(chunks, ZONE_CONCURRENCY, (chunk) => + queryAllZones({ parent_id: chunk.join(',') }), + ); + level = children.flat(); } + return zones; } private async importZoneTree(zones: ZoneTreeExportItem[]) { @@ -413,27 +438,57 @@ export class PlaceDatabaseDetailsComponent notifyWarn('Invalid zone tree CSV file.'); return; } + const pending = zones.filter((zone) => !!zone.id); + const file_ids = new Set(pending.map((zone) => zone.id)); + const children = new Map(); + for (const zone of pending) { + if (!zone.parent_id || !file_ids.has(zone.parent_id)) continue; + children.set(zone.parent_id, [ + ...(children.get(zone.parent_id) || []), + zone, + ]); + } + // Zones whose parent is not in the file go in at the top + let level = pending.filter( + (zone) => !zone.parent_id || !file_ids.has(zone.parent_id), + ); const id_map = new Map(); + let created = 0; try { - for (const zone of zones) { - if (!zone.id) continue; - const parent_id = zone.parent_id - ? id_map.get(zone.parent_id) || '' - : ''; - const created_zone = await addZone( - zoneToImportItem(zone, parent_id), - ); - id_map.set(zone.id, created_zone.id); + // Create a tree level at a time, as children need their parent's new id + for ( + let depth = 0; + level.length && depth < pending.length; + depth++ + ) { + await mapLimit(level, ZONE_CONCURRENCY, async (zone) => { + const parent_id = zone.parent_id + ? id_map.get(zone.parent_id) || '' + : ''; + const created_zone = await addZone( + zoneToImportItem(zone, parent_id), + ); + id_map.set(zone.id, created_zone.id); + created++; + }); + level = level.flatMap((zone) => children.get(zone.id) || []); } } catch (err) { // Zones created before the error stay, so tell the user how far it got notifyError( - `Error importing zone tree. Created ${id_map.size} of ${ + `Error importing zone tree. Created ${created} of ${ zones.length } zones before the error. Error: ${await readError(err)}`, ); return; } - notifySuccess(`Imported ${id_map.size} zones.`); + const skipped = pending.length - created; + notifySuccess( + `Imported ${created} zones.${ + skipped + ? ` Skipped ${skipped} zones with circular parent ids.` + : '' + }`, + ); } } diff --git a/src/app/common/actions.ts b/src/app/common/actions.ts index 5c833f3bb..60ce11875 100644 --- a/src/app/common/actions.ts +++ b/src/app/common/actions.ts @@ -63,6 +63,7 @@ import { planZoneCascade, } from './cascade-delete'; import { + clearSupportModuleCache, querySupportModules, querySupportSystems as querySystems, querySupportZones as queryZones, @@ -183,8 +184,12 @@ const modules: ItemActions = { fields: ['id', 'name', 'custom_name', 'module_name'].join(','), }), show: (_) => showModule(_), - save: (item) => (item.id ? updateModule(item.id, item) : addModule(item)), - remove: (item) => removeModule(item.id), + // The support group module list is cached, so drop it once this lands + save: (item) => + (item.id ? updateModule(item.id, item) : addModule(item)).finally( + clearSupportModuleCache, + ), + remove: (item) => removeModule(item.id).finally(clearSupportModuleCache), itemConstructor: PlaceModule, loadModal: () => import('../modules/module-form.component').then( diff --git a/src/app/common/cascade-delete.ts b/src/app/common/cascade-delete.ts index e36c9604d..149e97ecf 100644 --- a/src/app/common/cascade-delete.ts +++ b/src/app/common/cascade-delete.ts @@ -106,22 +106,32 @@ const emptyPlan = (): CascadePlan => ({ steps: [], }); -/** Runs `fn` over `list` with at most `limit` requests in flight. */ -async function mapLimit( +/** + * Runs `fn` over `list` in order with at most `limit` requests in flight. + * After the first failure no new items start; it rejects with that error once + * the requests in flight settle, so callers see everything that happened. + */ +export async function mapLimit( list: T[], limit: number, fn: (item: T) => Promise, ): Promise { const results: R[] = new Array(list.length); let next_index = 0; + let failure: { error: unknown } | null = null; const worker = async () => { - while (next_index < list.length) { + while (!failure && next_index < list.length) { const index = next_index++; - results[index] = await fn(list[index]); + try { + results[index] = await fn(list[index]); + } catch (error) { + failure ||= { error }; + } } }; const size = Math.min(limit, list.length); await Promise.all(new Array(size).fill(0).map(() => worker())); + if (failure) throw failure.error; return results; } @@ -463,27 +473,34 @@ export async function planDomainCascade( return plan; } +/** System removals run at once. Each is independent of the others. */ +const SYSTEM_REMOVAL_CONCURRENCY = 4; + /** - * Executes a plan's steps in order. Steps run sequentially — each system - * removal cascades work on the server, and sequential execution gives honest - * progress and lets a partial failure be reported precisely. + * Executes a plan's steps in order. Neighbouring system removals do not + * depend on each other, so they run a few at a time; every other step runs on + * its own. Each system removal cascades work on the server, which is why the + * pool stays small. * * The run stops at the first failure. Steps are ordered so that later ones * depend on earlier ones having gone: `planDomainCascade` appends the org zone * after the systems inside it, so carrying on past a failed system removal - * would delete the zone out from under it. Everything after the failure is - * reported as skipped rather than attempted. + * would delete the zone out from under it. No new step starts after a + * failure, removals already in flight finish, and everything not attempted is + * reported as skipped. */ export async function runCascade( plan: CascadePlan, progress: (message: string) => void = () => undefined, ): Promise { const outcome: CascadeOutcome = { removed: [], failures: [], skipped: [] }; - const total = plan.steps.length; - for (const [index, step] of plan.steps.entries()) { + const steps = plan.steps; + const total = steps.length; + const runStep = async (index: number) => { + const step = steps[index]; if (outcome.failures.length) { outcome.skipped.push(step.resource); - continue; + return; } progress( i18n('CASCADE.PROGRESS', { @@ -498,6 +515,20 @@ export async function runCascade( } catch (error) { outcome.failures.push({ resource: step.resource, error }); } + }; + // `start` grows by at least one each pass, so this ends after `total` + for (let start = 0; start < total; ) { + let end = start + 1; + if (steps[start].resource.type === 'system') { + while (end < total && steps[end].resource.type === 'system') end++; + } + const indexes = Array.from( + { length: end - start }, + (_, offset) => start + offset, + ); + // `runStep` records failures rather than throwing + await mapLimit(indexes, SYSTEM_REMOVAL_CONCURRENCY, runStep); + start = end; } return outcome; } diff --git a/src/app/common/item.service.ts b/src/app/common/item.service.ts index 497073cf8..30e3a878b 100644 --- a/src/app/common/item.service.ts +++ b/src/app/common/item.service.ts @@ -41,6 +41,7 @@ import { waitForEvent, waitForSignalValue } from './signals'; import { canAccessSection, canUseSupportAction, + clearSupportModuleCache, hasSupportRole, hasSupportSubsystem, isSubsystemUser, @@ -59,6 +60,14 @@ const receiptItems = (resources: CascadeResource[]) => name: resource.name, })); +/** Resource types whose items carry settings */ +const SETTINGS_TYPES = new Set([ + 'drivers', + 'modules', + 'systems', + 'zones', +]); + export type ResourceType = | 'domains' | 'drivers' @@ -105,8 +114,16 @@ export class ActiveItemService extends AsyncHandler { private _scope_version = 0; /** Id of the latest `setItem` call, so stale responses are dropped */ private _item_request = 0; + /** Id of the latest settings refresh, so stale responses are dropped */ + private _settings_request = 0; + private _items_changed = signal(0); public readonly count = this._count.asReadonly(); + /** + * Changes after any item is saved, duplicated, bulk added or removed, so + * views that cache data derived from many items know to reload it + */ + public readonly items_changed = this._items_changed.asReadonly(); public get total() { return this._count(); @@ -217,20 +234,32 @@ export class ActiveItemService extends AsyncHandler { return this.timeout('setItem', () => this.setItem(id)); this._loading.set(true); this._active_item.set(null); - const item = await this.actions - .show(id) - .catch(() => notifyError(`Error loading ${id}`)); + const actions = this.actions; + // Load settings alongside the item and set it once, as state + // services reload everything each time the item changes + const [item, settings] = await Promise.all([ + actions + .show(id) + .catch(() => notifyError(`Error loading ${id}`)), + SETTINGS_TYPES.has(this._type) ? this.loadSettings(id) : null, + ]); if ( scope_version !== this._scope_version || request !== this._item_request ) return; - this._active_item.set(item as PlaceResource); + this._active_item.set( + (item && settings + ? new actions.itemConstructor({ + ...(item as PlaceResource), + settings, + }) + : item) as PlaceResource, + ); const name = this._type[0].toUpperCase() + this._type.slice(1); this._name.set(name); this._settings.title = name; this._show_options.set(false); - this.updateSettings(); this._loading.set(false); } } @@ -272,9 +301,10 @@ export class ActiveItemService extends AsyncHandler { save: actions.save, }, }); - ref.afterClosed().subscribe(() => - this._settings.post('disable_uploads', false), - ); + ref.afterClosed().subscribe(() => { + this._settings.post('disable_uploads', false); + this._items_changed.update((count) => count + 1); + }); } public async edit( @@ -556,32 +586,45 @@ export class ActiveItemService extends AsyncHandler { }); ref.componentInstance.event.subscribe((e: DialogEvent) => { if (e.reason === 'done' && e.metadata?.[0]) { - this._active_item.set(e.metadata[0] as PlaceResource); + // Clear first so the copy is shown with its own settings + this._active_item.set(null); this.replaceItem(e.metadata[0] as unknown as Identity); } }); } } + /** + * Show a saved item in place of the active one with the same id, or as + * the active item when there is none. + */ public replaceItem(item: Identity | PlaceResource) { - if ( - item?.id && - (!this.active_item || this.active_item.id === item.id) - ) { - this._active_item.set(item as PlaceResource); - const list = this._list().filter( - (i) => (i as Identity).id !== item.id, - ); - list.push(item); - list.sort((a, b) => - (a as Identity).name?.localeCompare((b as Identity).name), - ); - this.updateSettings(); - this._list.set(list); - } + this._items_changed.update((count) => count + 1); + const current = this.active_item as PlaceResource & { + settings?: PlaceSettings[]; + }; + if (!item?.id || (current && current.id !== item.id)) return; + // Save responses have blank settings, so show the loaded ones until + // the refresh below confirms them + this._active_item.set( + (current?.settings && this.actions?.itemConstructor + ? new this.actions.itemConstructor({ + ...item, + settings: current.settings, + }) + : item) as PlaceResource, + ); + const list = this._list().filter((i) => (i as Identity).id !== item.id); + list.push(item); + list.sort((a, b) => + (a as Identity).name?.localeCompare((b as Identity).name), + ); + this._list.set(list); + if (SETTINGS_TYPES.has(this._type)) this.refreshSettings(`${item.id}`); } public removeItem(item: unknown) { + this._items_changed.update((count) => count + 1); if ((item as Identity).id) { const list = this._list().filter( (i) => (i as Identity).id !== (item as Identity).id, @@ -607,6 +650,8 @@ export class ActiveItemService extends AsyncHandler { if (old_type !== this._type) { this._include_deleted.set(false); this._list_version++; + // Entering a section starts from fresh data + clearSupportModuleCache(); log('Service', `Item type set to ${this._type}`); this._next_query.set(null); this._active_item.set(null); @@ -698,13 +743,11 @@ export class ActiveItemService extends AsyncHandler { })), ); this._count.set(resp.total); + const incoming = new Set( + resp.data.map((item) => (item as Identity).id), + ); const list = this._list().filter( - (i) => - !resp.data.find( - (item) => - (item as Identity).id === - (i as Identity).id, - ), + (i) => !incoming.has((i as Identity).id), ); const new_list = list.concat(resp.data); new_list.sort((a, b) => @@ -720,29 +763,59 @@ export class ActiveItemService extends AsyncHandler { ); } - private async updateSettings() { - const item = this.active_item; - if (item && (item as PlaceResource & { settings?: unknown }).settings) { - let settings = (await querySettings({ parent_id: item.id })).data; - if (item !== this.active_item) return; - // Remove duplicate encryption_level - settings = new Array(5).fill(0).map( - (_, idx) => - settings.find((_) => _.encryption_level === idx) || - new PlaceSettings({ - encryption_level: idx, - }), + /** + * Reload the active item's settings. Sets the item again only when they + * changed, as state services reload everything each time it is set. + */ + private async refreshSettings(id: string) { + const request = ++this._settings_request; + const settings = await this.loadSettings(id); + const active = this.active_item as PlaceResource & { + settings?: PlaceSettings[]; + }; + if ( + !settings || + request !== this._settings_request || + active?.id !== id || + !this.actions?.itemConstructor + ) + return; + // Masked levels have a blank settings_string, so also compare the + // save time and keys + const unchanged = settings.every((level, idx) => { + const shown = active.settings?.[idx]; + return ( + level.id === shown?.id && + level.updated_at === shown?.updated_at && + level.settings_string === shown?.settings_string && + `${level.keys}` === `${shown?.keys}` ); + }); + if (unchanged) return; + this._active_item.set( + new this.actions.itemConstructor({ + ...active, + settings, + }) as PlaceResource, + ); + } - settings.sort((a, b) => a.encryption_level - b.encryption_level); - if (this.actions?.itemConstructor) { - this._active_item.set( - new this.actions.itemConstructor({ - ...item, - settings, - }) as PlaceResource, - ); - } - } + /** + * Settings of each encryption level for the item, with a blank entry for + * missing levels. Resolves `null` if they cannot be loaded. + */ + private async loadSettings(id: string) { + const response = await querySettings({ parent_id: id }).catch( + () => null, + ); + if (!response) return null; + // Remove duplicate encryption_level + return new Array(5) + .fill(0) + .map( + (_, idx) => + response.data.find((_) => _.encryption_level === idx) || + new PlaceSettings({ encryption_level: idx }), + ); } } diff --git a/src/app/common/opened-tabs.ts b/src/app/common/opened-tabs.ts new file mode 100644 index 000000000..4da94ea54 --- /dev/null +++ b/src/app/common/opened-tabs.ts @@ -0,0 +1,41 @@ +import { computed, inject, linkedSignal, Signal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter, map } from 'rxjs'; + +/** + * Tabs the user has opened for the active item, read from routes shaped + * `/
//`. Resets when `id` changes, so state services can + * hold back full lists until their tab is first shown. Call in an injection + * context. + */ +export function openedTabs(id: Signal) { + const router = inject(Router); + const url = toSignal( + router.events.pipe( + filter( + (event): event is NavigationEnd => + event instanceof NavigationEnd, + ), + map((event) => event.urlAfterRedirects), + ), + { initialValue: router.url }, + ); + const tab = computed(() => url().split(/[?#]/)[0].split('/')[3]); + const opened = linkedSignal< + { id: string | undefined; tab: string }, + { id: string | undefined; tabs: Set } + >({ + source: () => ({ id: id(), tab: tab() }), + computation: (source, previous) => ({ + id: source.id, + tabs: new Set([ + ...(previous && previous.value.id === source.id + ? previous.value.tabs + : []), + source.tab, + ]), + }), + }); + return computed(() => opened().tabs); +} diff --git a/src/app/common/settings.service.ts b/src/app/common/settings.service.ts index d5f93f573..cdc940adb 100644 --- a/src/app/common/settings.service.ts +++ b/src/app/common/settings.service.ts @@ -172,6 +172,8 @@ export class SettingsService extends AsyncHandler { const current_theme = this.get('theme'); if (current_theme === theme) return; this.saveUserSetting('theme', theme); + // Unsaved settings are not signals, so post the change for views + this.post('theme', theme); localStorage.setItem('PLACEOS.theme', theme); this._applyTheme(); } diff --git a/src/app/common/support-access.ts b/src/app/common/support-access.ts index 4b9d08782..2d14ddb29 100644 --- a/src/app/common/support-access.ts +++ b/src/app/common/support-access.ts @@ -17,7 +17,7 @@ let storage_key = ''; const selected_id = signal(''); const access = signal<{ - user: Pick | null; + user: Pick | null; groups: PlaceCurrentGroup[]; }>({ user: null, groups: [] }); @@ -89,18 +89,20 @@ export async function collectPages(response: QueryResponse) { return data; } -/** Load effective memberships before publishing the signed-in user. */ -export async function loadSupportAccess(user: PlaceUser) { - access.set({ user: null, groups: [] }); - selected_id.set(''); - storage_key = `BACKOFFICE.support_group.${user.authority_id}.${user.id}`; - const groups = await ( - user.sys_admin - ? collectPages(queryGroups({ limit: 200 })).then((groups) => - groups.map((group) => ({ group, permissions: 255 })), - ) - : currentGroups({ subsystem: 'support' }) - ).catch(() => []); +/** Id of the latest `loadSupportAccess` call, so stale group lists are dropped */ +let load_version = 0; + +function savedGroupId() { + try { + return localStorage.getItem(storage_key) || ''; + } catch { + /* Storage is optional. */ + return ''; + } +} + +/** Publish the user with their support groups and restore the saved group. */ +function publishAccess(user: PlaceUser, groups: PlaceCurrentGroup[]) { const available = groups .filter( ({ group, permissions }) => @@ -108,12 +110,7 @@ export async function loadSupportAccess(user: PlaceUser) { ) .sort((a, b) => a.group.name.localeCompare(b.group.name)); access.set({ user, groups: available }); - let saved = ''; - try { - saved = localStorage.getItem(storage_key) || ''; - } catch { - /* Storage is optional. */ - } + const saved = savedGroupId(); selected_id.set( available.some(({ group }) => group.id === saved) ? saved @@ -123,6 +120,41 @@ export async function loadSupportAccess(user: PlaceUser) { ); } +/** + * Load effective memberships for the signed-in user. Resolves once access is + * usable. Sys admins can open every route without groups, so unless a saved + * group scopes their first queries, they are published at once and the group + * list fills in when it arrives. + */ +export async function loadSupportAccess(user: PlaceUser) { + const version = ++load_version; + const previous = access(); + clearSupportModuleCache(); + storage_key = `BACKOFFICE.support_group.${user.authority_id}.${user.id}`; + const groups = user.sys_admin + ? collectPages(queryGroups({ limit: 1000 })).then((groups) => + groups.map((group) => ({ group, permissions: 255 })), + ) + : currentGroups({ subsystem: 'support' }); + if (user.sys_admin && !savedGroupId()) { + // Keep the old list on reconnect so the group picker does not blink + access.set({ + user, + groups: previous.user?.id === user.id ? previous.groups : [], + }); + selected_id.set(''); + groups.then( + (list) => version === load_version && publishAccess(user, list), + () => undefined, + ); + return; + } + access.set({ user: null, groups: [] }); + selected_id.set(''); + const list = await groups.catch(() => []); + if (version === load_version) publishAccess(user, list); +} + /** Membership bits enable actions. The API checks zone grants on each request. */ export function canUseSupportAction(permission: number) { return active_groups().some( @@ -171,19 +203,29 @@ export function querySupportZones( }); } -/** The modules API has no group filter, so list modules through the group's systems. */ -export async function querySupportModules( - options: Parameters[0] = {}, -): QueryResponse { - if (!selected_id() || options.control_system_id) - return queryModules(options); - const requested_group = selected_id(); - const systems = await collectPages(querySupportSystems({ limit: 200 })); +/** Module set of one support group, shared by list loads and searches */ +let module_cache: { group_id: string; modules: Promise } | null = + null; + +/** + * Drop the cached support group modules. Called when the user, group or + * resource section changes, and after a module is saved or removed. + */ +export function clearSupportModuleCache() { + module_cache = null; +} + +/** Walk the group's systems for their modules. Stops early once superseded. */ +async function loadGroupModules(entry: { + group_id: string; +}): Promise { + const superseded = () => + module_cache !== entry || selected_id() !== entry.group_id; + const systems = await collectPages(querySupportSystems({ limit: 1000 })); const modules: PlaceModule[] = []; // Bound concurrent requests when a group contains many systems. for (let index = 0; index < systems.length; index += 8) { - if (requested_group !== selected_id()) - return { data: [], total: 0, next: null }; + if (superseded()) return []; const pages = await Promise.all( systems.slice(index, index + 8).map((system) => collectPages( @@ -203,10 +245,33 @@ export async function querySupportModules( ); modules.push(...pages.flat()); } + return [...new Map(modules.map((module) => [module.id, module])).values()]; +} + +/** + * The modules API has no group filter, so list modules through the group's + * systems. The walk runs once per group and searches filter it in the browser. + * `queryModules` ignores other filters with `control_system_id`, so only `q` + * applies here. + */ +export async function querySupportModules( + options: Parameters[0] = {}, +): QueryResponse { + if (!selected_id() || options.control_system_id) + return queryModules(options); + const group_id = selected_id(); + let entry = module_cache; + if (entry?.group_id !== group_id) { + const next = { group_id, modules: null as Promise }; + module_cache = entry = next; + next.modules = loadGroupModules(next); + // A failed walk must not be reused by the next search + next.modules.catch(() => { + if (module_cache === next) module_cache = null; + }); + } const search = (options.q || '').toLowerCase(); - const data = [ - ...new Map(modules.map((module) => [module.id, module])).values(), - ].filter( + const data = (await entry.modules).filter( (module) => !search || [module.name, module.custom_name, module.id].some((value) => diff --git a/src/app/groups/group-state.service.ts b/src/app/groups/group-state.service.ts index 3e277b18f..dcc1ee91c 100644 --- a/src/app/groups/group-state.service.ts +++ b/src/app/groups/group-state.service.ts @@ -17,11 +17,12 @@ import { updateGroupUser, updateGroupZone, } from '@placeos/ts-client'; -import { escapeHtml } from '../common/general'; import { describeError } from '../common/errors'; +import { escapeHtml } from '../common/general'; import { ActiveItemService } from '../common/item.service'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess } from '../common/notifications'; +import { openedTabs } from '../common/opened-tabs'; import { waitForEvent } from '../common/signals'; import { openConfirmModal } from '../overlays/confirm-modal.component'; import { GroupBulkAddModalComponent } from './group-bulk-add-modal.component'; @@ -45,77 +46,95 @@ export class GroupStateService { this._zones.isLoading(), ); + private readonly _group_id = computed(() => { + const item = this.item(); + return item instanceof PlaceGroup ? item.id : undefined; + }); + // Member lists only load once their tab is opened. Until then a single + // row query gives the tab count. + private readonly _opened_tabs = openedTabs(this._group_id); + private readonly _load_users = computed(() => + this._opened_tabs().has('users'), + ); + private readonly _load_zones = computed(() => + this._opened_tabs().has('zones'), + ); + private readonly _users = resource({ - params: () => ({ item: this.item(), changed: this._changed() }), + params: () => ({ + item: this.item(), + changed: this._changed(), + full: this._load_users(), + }), loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceGroup)) return [] as PlaceGroupUser[]; + const { item, full } = params; + const empty = { data: [] as PlaceGroupUser[], total: 0 }; + if (!(item instanceof PlaceGroup)) return empty; const response = await queryGroupUsers({ group_id: item.id, - limit: 1000, + limit: full ? 1000 : 1, }).catch((error) => { notifyError( i18n('GROUPS.USERS_LOAD_ERROR', { error: describeError(error), }), ); - return { data: [] as PlaceGroupUser[] }; + return empty; }); - return response.data.sort((a, b) => - (a.user?.name || a.user_id).localeCompare( - b.user?.name || b.user_id, + if (!full) return { ...empty, total: response.total }; + return { + total: response.total, + data: [...response.data].sort((a, b) => + (a.user?.name || a.user_id).localeCompare( + b.user?.name || b.user_id, + ), ), - ); + }; }, }); - public readonly users = computed(() => this._users.value() || []); + public readonly users = computed(() => this._users.value()?.data || []); private readonly _zones = resource({ - params: () => ({ item: this.item(), changed: this._changed() }), + params: () => ({ + item: this.item(), + changed: this._changed(), + full: this._load_zones(), + }), loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceGroup)) return [] as PlaceGroupZone[]; + const { item, full } = params; + const empty = { data: [] as PlaceGroupZone[], total: 0 }; + if (!(item instanceof PlaceGroup)) return empty; const response = await queryGroupZones({ group_id: item.id, - limit: 1000, + limit: full ? 1000 : 1, }).catch((error) => { notifyError( i18n('GROUPS.ZONES_LOAD_ERROR', { error: describeError(error), }), ); - return { data: [] as PlaceGroupZone[] }; + return empty; }); - return response.data.sort((a, b) => - (a.zone?.name || a.zone_id).localeCompare( - b.zone?.name || b.zone_id, + if (!full) return { ...empty, total: response.total }; + return { + total: response.total, + data: [...response.data].sort((a, b) => + (a.zone?.name || a.zone_id).localeCompare( + b.zone?.name || b.zone_id, + ), ), - ); + }; }, }); - public readonly zones = computed(() => this._zones.value() || []); - - private readonly _counts = resource({ - params: () => ({ item: this.item(), changed: this._changed() }), - loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceGroup)) return {}; - const [users, zones] = await Promise.all([ - queryGroupUsers({ group_id: item.id, limit: 1 }) - .then((response) => response.total) - .catch(() => 0), - queryGroupZones({ group_id: item.id, limit: 1 }) - .then((response) => response.total) - .catch(() => 0), - ]); - return { users, zones }; - }, - }); + public readonly zones = computed(() => this._zones.value()?.data || []); public readonly counts = computed<{ users?: number; zones?: number }>( - () => this._counts.value() || { users: 0, zones: 0 }, + () => ({ + users: this._users.value()?.total || 0, + zones: this._zones.value()?.total || 0, + }), ); public get active_item() { diff --git a/src/app/modules/module-form.component.ts b/src/app/modules/module-form.component.ts index bc5abdf0f..209faf599 100644 --- a/src/app/modules/module-form.component.ts +++ b/src/app/modules/module-form.component.ts @@ -21,6 +21,7 @@ import { updateModule, } from '@placeos/ts-client'; import { + clearSupportModuleCache, isSubsystemUser, querySupportSystems as querySystems, } from '../common/support-access'; @@ -379,9 +380,7 @@ export class ModuleFormComponent extends AsyncHandler implements OnInit { public readonly form = form(this.formModel, applyModuleFormSchema); public readonly loading = signal(null); /** Selected driver. Only changes when a new driver is picked. */ - private readonly _selected_driver = computed( - () => this.formModel().driver, - ); + private readonly _selected_driver = computed(() => this.formModel().driver); /** ID of the driver whose defaults have been applied to the form */ private _defaults_driver_id = ''; public heading = i18n( @@ -530,6 +529,7 @@ export class ModuleFormComponent extends AsyncHandler implements OnInit { form_item as unknown as PlaceModule, ) : addModule(form_item as unknown as PlaceModule)); + clearSupportModuleCache(); this._dialog_ref.disableClose = false; this.event.emit({ reason: 'done', metadata: { item: _item } }); notifySuccess(i18n(`${this._name}.SAVE_SUCCESS`)); diff --git a/src/app/overlays/bulk-item-modal/list.component.ts b/src/app/overlays/bulk-item-modal/list.component.ts index eb799e781..0637da60e 100644 --- a/src/app/overlays/bulk-item-modal/list.component.ts +++ b/src/app/overlays/bulk-item-modal/list.component.ts @@ -91,13 +91,15 @@ import { TranslatePipe } from '../../ui/translate.pipe'; } } -
+ +
@@ -170,7 +172,16 @@ import { TranslatePipe } from '../../ui/translate.pipe'; `, - styles: [``], + styles: [ + ` + :host { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + } + `, + ], imports: [ FormsModule, MatRippleModule, @@ -244,12 +255,11 @@ export class ListComponent { ); } + /** Replace only the edited row. Other rows keep their references. */ public updateItem(index: number, field: string, value: unknown) { - this.list.set( - this.list().map((item, item_index) => - item_index === index ? { ...item, [field]: value } : item, - ), - ); + const list = [...this.list()]; + list[index] = { ...list[index], [field]: value }; + this.list.set(list); } public removeItem(index: number) { diff --git a/src/app/systems/system-form.component.ts b/src/app/systems/system-form.component.ts index f8adc1ef4..65bc6727f 100644 --- a/src/app/systems/system-form.component.ts +++ b/src/app/systems/system-form.component.ts @@ -49,6 +49,9 @@ import { generateSystemFormModel, } from './systems.utilities'; +/** Most timezone options to render. Typing narrows the full list. */ +const TIMEZONE_OPTION_LIMIT = 50; + @Component({ selector: 'system-form', template: ` @@ -664,11 +667,12 @@ export class SystemFormComponent extends AsyncHandler implements OnInit { public readonly separators: number[] = [ENTER, COMMA, SPACE]; private _timezone = computed(() => this.formModel().timezone || ''); - public filtered_timezones = computed(() => - this.timezones.filter((_tz) => - _tz.toLowerCase().includes(this._timezone().toLowerCase()), - ), - ); + public filtered_timezones = computed(() => { + const search = this._timezone().toLowerCase(); + return this.timezones + .filter((_tz) => _tz.toLowerCase().includes(search)) + .slice(0, TIMEZONE_OPTION_LIMIT); + }); constructor() { super(); diff --git a/src/app/systems/system-state.service.ts b/src/app/systems/system-state.service.ts index bde3f1592..87556043a 100644 --- a/src/app/systems/system-state.service.ts +++ b/src/app/systems/system-state.service.ts @@ -14,7 +14,6 @@ import { PlaceZone, put, queryModules, - querySettings, queryTriggers, removeSystemModule, removeSystemTrigger, @@ -74,10 +73,9 @@ export class SystemStateService extends AsyncHandler { loader: async ({ params }) => { const { item } = params; if (!(item instanceof PlaceSystem)) return []; + // The active item service already loaded the system's own settings return isSubsystemUser() - ? querySettings({ parent_id: item.id }) - .then((response) => response.data) - .catch(() => []) + ? item.settings.filter((setting) => !!setting?.id) : systemSettings(item.id).catch(() => []); }, }); @@ -93,18 +91,12 @@ export class SystemStateService extends AsyncHandler { if (!(item instanceof PlaceSystem)) return {}; this.setLoading('settings', true); try { - const [triggers, metadata] = await Promise.all([ - listSystemTriggers(item.id) - .then((response) => response.total) - .catch(() => 0), - listMetadata(item.id) - .then((response) => response.length) - .catch(() => 0), - ]); + const metadata = await listMetadata(item.id) + .then((response) => response.length) + .catch(() => 0); return { devices: item.modules.length, zones: item.zones.length, - triggers, metadata, }; } finally { @@ -118,15 +110,11 @@ export class SystemStateService extends AsyncHandler { zones?: number; triggers?: number; metadata?: number; - }>( - () => - this._counts.value() || { - devices: 0, - zones: 0, - triggers: 0, - metadata: 0, - }, - ); + }>(() => ({ + ...(this._counts.value() || { devices: 0, zones: 0, metadata: 0 }), + // Counted from the trigger list so it is only requested once + triggers: this._triggers.value()?.total || 0, + })); private readonly _module_resource = resource({ params: () => ({ item: this.item(), changed: this._change() }), @@ -249,22 +237,23 @@ export class SystemStateService extends AsyncHandler { params: () => ({ item: this.item(), changed: this._change() }), loader: async ({ params }) => { const { item } = params; - if (!(item instanceof PlaceSystem)) return [] as PlaceTrigger[]; + if (!(item instanceof PlaceSystem)) + return { data: [] as PlaceTrigger[], total: 0 }; this.setLoading('triggers', true); try { - const response = await listSystemTriggers(item.id).catch( - () => ({ - data: [], - }), - ); - return response.data; + return await listSystemTriggers(item.id).catch(() => ({ + data: [] as PlaceTrigger[], + total: 0, + })); } finally { this.setLoading('triggers', false); } }, }); /** Signal for triggers associated with system */ - public readonly triggers = computed(() => this._triggers.value() || []); + public readonly triggers = computed( + () => this._triggers.value()?.data || [], + ); /** Signal of loading states */ public readonly loading = this._loading.asReadonly(); @@ -648,9 +637,10 @@ export class SystemStateService extends AsyncHandler { if (!added) return; this.timeout('join', async () => { const system = await showSystem(this.active_item.id); + // A new item reloads everything, so only fall back to `changed` if (system) this._state.replaceItem(system as unknown as Identity); + else this.changed(); notifySuccess(`Successfully added module to system.`); - this.changed(); }); } diff --git a/src/app/ui/basic-line-graph.component.ts b/src/app/ui/basic-line-graph.component.ts index f53f8aa59..a031d99eb 100644 --- a/src/app/ui/basic-line-graph.component.ts +++ b/src/app/ui/basic-line-graph.component.ts @@ -1,12 +1,12 @@ import { - AfterViewInit, + afterRenderEffect, + ChangeDetectionStrategy, Component, ElementRef, inject, input, viewChild, } from '@angular/core'; -import { AsyncHandler } from '../common/async-handler.class'; import { SettingsService } from '../common/settings.service'; import { Point } from '../common/types'; @@ -34,11 +34,9 @@ const COLORS: [string, string][] = [ } `, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class BasicLineGraphComponent - extends AsyncHandler - implements AfterViewInit -{ +export class BasicLineGraphComponent { private _settings = inject(SettingsService); private _element = inject>(ElementRef); @@ -51,25 +49,28 @@ export class BasicLineGraphComponent return this._settings.get('theme') === 'dark'; } - public ngAfterViewInit() { - this._setupCanvas(); - requestAnimationFrame(() => this._drawGraph()); + private _canvas_sized = false; + + constructor() { + // Draw after render each time the lines change. There is no timer. + // A theme change shows on the next lines update. + afterRenderEffect(() => this._drawGraph(this.lines())); } - private _setupCanvas() { - const canvas_el = this._canvas_el()?.nativeElement; - if (!canvas_el) { - return this.timeout('setup', () => this._setupCanvas()); - } + private _setupCanvas(canvas_el: HTMLCanvasElement) { const container_box = this._element.nativeElement.getBoundingClientRect(); canvas_el.width = container_box.width * 2; canvas_el.height = container_box.height * 2; } - private _drawGraph() { + private _drawGraph(lines: Point[][]) { const _canvas_el = this._canvas_el(); if (!_canvas_el?.nativeElement) return; + if (!this._canvas_sized) { + this._setupCanvas(_canvas_el.nativeElement); + this._canvas_sized = true; + } const ctx = _canvas_el.nativeElement.getContext('2d'); if (!ctx) return; let { width, height } = _canvas_el.nativeElement; @@ -106,14 +107,14 @@ export class BasicLineGraphComponent ctx.fillText('60s', axis_start.x, height + 4); // Draw Lines - for (const line of this.lines()) { + lines.forEach((line, index) => this._drawLine( ctx, [axis_start.x, 0, width - axis_start.x, height - axis_start.y], line, - COLORS[this.lines().indexOf(line) % COLORS.length], - ); - } + COLORS[index % COLORS.length], + ), + ); ctx.strokeStyle = color; ctx.lineWidth = 2; @@ -130,9 +131,6 @@ export class BasicLineGraphComponent ctx.stroke(); ctx.restore(); - this.timeout('draw', () => - requestAnimationFrame(() => this._drawGraph()), - ); } private _drawLine( diff --git a/src/app/ui/custom-fields/settings-field.component.ts b/src/app/ui/custom-fields/settings-field.component.ts index dbc234864..2c7abf635 100644 --- a/src/app/ui/custom-fields/settings-field.component.ts +++ b/src/app/ui/custom-fields/settings-field.component.ts @@ -3,6 +3,7 @@ import { Component, DestroyRef, + effect, ElementRef, forwardRef, inject, @@ -94,21 +95,20 @@ export class SettingsFieldComponent /** API object for the monaco editor */ private editor: monaco.editor.IStandaloneCodeEditor | undefined; + /** Follows theme changes. The editor reads the theme when it is created. */ + private readonly _theme_watch = effect(() => { + // `setTheme` posts changes that `get` cannot signal on its own + this._settings.value('theme'); + const theme = this._settings.get('theme'); + if (theme === this._theme) return; + this._theme = theme; + this.editor?.updateOptions({ + theme: theme !== 'dark' ? 'vs' : 'vs-dark', + }); + }); + public ngOnInit(): void { this.timeout('resize', () => this.createEditor(), 100); - this.interval( - 'theme', - () => { - const theme = this._settings.get('theme') as string; - if (theme !== this._theme) { - this._theme = theme; - this.editor?.updateOptions({ - theme: theme !== 'dark' ? 'vs' : 'vs-dark', - }); - } - }, - 1000, - ); } public ngOnChanges(changes: SimpleChanges): void { diff --git a/src/app/ui/diff-viewer.component.ts b/src/app/ui/diff-viewer.component.ts index 8ffee09de..112798e89 100644 --- a/src/app/ui/diff-viewer.component.ts +++ b/src/app/ui/diff-viewer.component.ts @@ -22,14 +22,13 @@ import { TranslatePipe } from './translate.pipe'; template: ` @if (load_error()) {

{{ 'COMMON.EDITOR_LOAD_ERROR' | translate }}

- }
`, @@ -67,9 +66,9 @@ export class DiffViewerComponent } } - /** Update sizing of the editor after window has resized */ - public resizeEditor() { - this.timeout('resize', () => this._createEditor(), 100); + /** Retry creating the editor after Monaco failed to load */ + public reload() { + this._createEditor(); } private async _createEditor() { @@ -92,6 +91,8 @@ export class DiffViewerComponent fontFamily: `"Fira Code", monospace`, theme: !this._users.dark_mode ? 'vs' : 'vs-dark', readOnly: true, + // Monaco watches the container size, so resizes need no rebuild + automaticLayout: true, }, ); this.subscription('editor', () => this._editor.dispose()); diff --git a/src/app/ui/forms/settings-form.component.ts b/src/app/ui/forms/settings-form.component.ts index 19a2b86e2..d6a24f833 100644 --- a/src/app/ui/forms/settings-form.component.ts +++ b/src/app/ui/forms/settings-form.component.ts @@ -4,6 +4,7 @@ import { effect, inject, input, + linkedSignal, model, OnInit, signal, @@ -306,17 +307,32 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit { return count; }); - /** Whether a settings group has errors */ - public readonly has_errors = computed(() => { - this.changed(); - for (const key in this.form()) { - if (this.settingError(+key.replace('settings', ''))) { - return true; + /** + * YAML error for each form key, e.g. `settings0`. + * Only levels whose text changed are parsed again. + */ + private readonly _setting_errors = linkedSignal< + HashMap, + HashMap<{ yaml: string } | null> + >({ + source: this.form, + computation: (form, previous) => { + const errors: HashMap<{ yaml: string } | null> = {}; + for (const key in form) { + errors[key] = + previous && previous.source[key] === form[key] + ? previous.value[key] + : validateYAML({ value: form[key] } as never); } - } - return false; + return errors; + }, }); + /** Whether a settings group has errors */ + public readonly has_errors = computed(() => + Object.values(this._setting_errors()).some(Boolean), + ); + public readonly levels = computed(() => { const levels: Identity[] = [ { @@ -395,9 +411,7 @@ export class SettingsFormComponent extends AsyncHandler implements OnInit { } public settingError(level: number) { - return validateYAML({ - value: this.settingValue(level), - } as never) as { yaml?: string } | null; + return this._setting_errors()[`settings${level}`] ?? null; } public updateSetting(level: number, value: string) { diff --git a/src/app/ui/forms/trigger-condition-form/time-form.component.ts b/src/app/ui/forms/trigger-condition-form/time-form.component.ts index 5116d2721..d2358a48d 100644 --- a/src/app/ui/forms/trigger-condition-form/time-form.component.ts +++ b/src/app/ui/forms/trigger-condition-form/time-form.component.ts @@ -33,6 +33,9 @@ import { IconComponent } from '../../icon.component'; import { SettingsToggleComponent } from '../../settings-toggle.component'; import { TranslatePipe } from '../../translate.pipe'; +/** Most timezone options to render. Typing narrows the full list. */ +const TIMEZONE_OPTION_LIMIT = 50; + @Component({ selector: 'trigger-condition-time-form', template: ` @@ -413,7 +416,9 @@ export class TriggerConditionTimeFormComponent public updateTimezoneList(tz: string) { const tz_lower = (tz || '').toLowerCase(); this.timezones.set( - TIMEZONES_IANA.filter((_) => _.toLowerCase().includes(tz_lower)), + TIMEZONES_IANA.filter((_) => + _.toLowerCase().includes(tz_lower), + ).slice(0, TIMEZONE_OPTION_LIMIT), ); } diff --git a/src/app/ui/icon.component.ts b/src/app/ui/icon.component.ts index 3b64c277f..83ad4a704 100644 --- a/src/app/ui/icon.component.ts +++ b/src/app/ui/icon.component.ts @@ -1,4 +1,4 @@ -import { Component, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; import { ApplicationIcon } from '../common/types'; import { SafePipe } from './pipes/safe.pipe'; @@ -29,6 +29,7 @@ import { SafePipe } from './pipes/safe.pipe'; `, ], imports: [SafePipe], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class IconComponent { public readonly className = input('material-symbols-rounded'); diff --git a/src/app/ui/item-sidebar.component.ts b/src/app/ui/item-sidebar.component.ts index d1445df11..82e780f47 100644 --- a/src/app/ui/item-sidebar.component.ts +++ b/src/app/ui/item-sidebar.component.ts @@ -18,7 +18,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { NavigationEnd, Router, RouterModule } from '@angular/router'; +import { Router, RouterModule } from '@angular/router'; import { PlaceDriverRole, PlaceGroup, @@ -28,7 +28,6 @@ import { PlaceZone, queryGroups, } from '@placeos/ts-client'; -import { filter } from 'rxjs'; import { AsyncHandler } from '../common/async-handler.class'; import { ActiveItemService } from '../common/item.service'; import { toSignal } from '../common/signals'; @@ -382,11 +381,6 @@ export class ItemSidebarComponent private readonly _route_change = toSignal(this._router.events, { initialValue: null, }); - /** Changes once per completed navigation, not for every router event */ - private readonly _navigation_end = toSignal( - this._router.events.pipe(filter((e) => e instanceof NavigationEnd)), - { initialValue: null }, - ); public readonly title = input('Systems'); public readonly route = input('systems'); @@ -468,8 +462,10 @@ export class ItemSidebarComponent this._route_change(); this.subroute.set(this._router.url.split('/')[3] || ''); }); + // Groups only change through the item service, so reload the + // hierarchy after a save or removal rather than on every navigation effect(() => { - this._navigation_end(); + this._service.items_changed(); if (this.route() === 'groups') void this.loadGroupHierarchy(); }); effect(() => { diff --git a/src/app/ui/pipes/markdown.pipe.ts b/src/app/ui/pipes/markdown.pipe.ts index 97e1dad9c..2156141cf 100644 --- a/src/app/ui/pipes/markdown.pipe.ts +++ b/src/app/ui/pipes/markdown.pipe.ts @@ -5,14 +5,16 @@ import { createBundledHighlighter, createSingletonShorthands, } from 'shiki/core'; -import { createOnigurumaEngine } from 'shiki/engine/oniguruma'; +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript'; import { markdownLanguages } from './markdown-languages'; // Keep existing language support, but package only the theme we render. +// The JavaScript regex engine avoids the Oniguruma wasm download. Forgiving +// mode skips any grammar pattern it cannot convert instead of failing. const createHighlighter = createBundledHighlighter({ langs: markdownLanguages, themes: { 'github-dark': () => import('shiki/themes/github-dark.mjs') }, - engine: () => createOnigurumaEngine(import('shiki/wasm')), + engine: () => createJavaScriptRegexEngine({ forgiving: true }), }); const { codeToHtml } = createSingletonShorthands(createHighlighter); diff --git a/src/app/ui/simple-table.component.ts b/src/app/ui/simple-table.component.ts index 3711f2dcd..36f6c0b56 100644 --- a/src/app/ui/simple-table.component.ts +++ b/src/app/ui/simple-table.component.ts @@ -5,15 +5,20 @@ import { } from '@angular/cdk/scrolling'; import { NgTemplateOutlet } from '@angular/common'; import { + ChangeDetectionStrategy, Component, + DestroyRef, + ElementRef, Signal, TemplateRef, computed, effect, + inject, input, linkedSignal, output, signal, + untracked, viewChild, } from '@angular/core'; import { MatCheckboxModule } from '@angular/material/checkbox'; @@ -33,6 +38,14 @@ export interface TableColumn { type TableData = T[] | Signal | Subscribable; +/** Shared collator for the default sort. Same order as `localeCompare`. */ +const COLLATOR = new Intl.Collator(); + +/** Text key for the default sort of non-number values */ +function sortKey(value: unknown): string { + return typeof value === 'string' ? value : (JSON.stringify(value) ?? ''); +} + @Component({ selector: 'simple-table', template: ` @@ -218,8 +231,7 @@ type TableData = T[] | Signal | Subscribable; " [class.border-b]="i !== data_length() - 1" [style.background]="color()[i]" - (mouseenter)="enter_row.emit(i)" - (touchstart)="enter_row.emit(i)" + [attr.data-row]="i" > = T[] | Signal | Subscribable; [class.border-b]="i !== data_length() - 1" [class.border-r]="j !== active_columns().length - 1" [class.width]="column.size" - (mouseenter)="enter_row.emit(i)" - (touchstart)="enter_row.emit(i)" + [attr.data-row]="i" [style.background]="color()[i]" > @switch (columnType(column)) { @@ -318,6 +329,7 @@ type TableData = T[] | Signal | Subscribable; IconComponent, NgTemplateOutlet, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class SimpleTableComponent> { // eslint-disable-next-line @angular-eslint/no-input-rename @@ -360,7 +372,8 @@ export class SimpleTableComponent> { // Computed data view with filtering and sorting public readonly data_view = computed(() => { - let data = [...this._data()]; + // Returned as is when there is no filter and no sort + let data = this._data(); const filter_str = this.filter()?.toLowerCase(); const current_sort = this.sort(); @@ -379,24 +392,27 @@ export class SimpleTableComponent> { // Apply sort if (current_sort && data.length) { - const type = typeof data[0][current_sort.key]; - const default_fn = - type === 'number' - ? (a, b) => a - b - : (a, b) => { - const a_value = JSON.stringify(a); - const b_value = JSON.stringify(b); - return a_value?.localeCompare(b_value); - }; - data = data.sort((a, b) => { - const sort_fn = - this.column(current_sort.key)?.sort_fn || default_fn; - const result = sort_fn( - a[current_sort.key], - b[current_sort.key], + const { key, reverse } = current_sort; + const direction = reverse ? -1 : 1; + const sort_fn = this.column(key)?.sort_fn; + if (sort_fn) { + data = [...data].sort( + (a, b) => direction * sort_fn(a[key], b[key]), ); - return current_sort.reverse ? -result : result; - }); + } else if (typeof data[0][key] === 'number') { + data = [...data].sort((a, b) => direction * (a[key] - b[key])); + } else { + // Build each text key once, not once per comparison + const keyed = data.map((row) => ({ + row, + sort_key: sortKey(row[key]), + })); + keyed.sort( + (a, b) => + direction * COLLATOR.compare(a.sort_key, b.sort_key), + ); + data = keyed.map((item) => item.row); + } } return data; @@ -414,7 +430,33 @@ export class SimpleTableComponent> { return column.content as TemplateRef; } + private readonly _el = inject>(ElementRef); + private _hover_row = -1; + constructor() { + // One native listener for row hover. Template listeners on each cell + // would schedule an app tick per event; this emits only on a new row. + const el = this._el.nativeElement; + const onEnter = (event: Event) => { + const cell = (event.target as Element).closest?.('[data-row]'); + const row = cell ? +cell.getAttribute('data-row') : -1; + if ( + row < 0 || + (event.type === 'mouseover' && row === this._hover_row) + ) + return; + this._hover_row = row; + this.enter_row.emit(row); + }; + const onLeave = () => (this._hover_row = -1); + el.addEventListener('mouseover', onEnter); + el.addEventListener('touchstart', onEnter, { passive: true }); + el.addEventListener('mouseleave', onLeave); + inject(DestroyRef).onDestroy(() => { + el.removeEventListener('mouseover', onEnter); + el.removeEventListener('touchstart', onEnter); + el.removeEventListener('mouseleave', onLeave); + }); // Flex layouts can gain their final height after the viewport is created. effect((onCleanup) => { const viewport = this._viewport(); @@ -459,19 +501,33 @@ export class SimpleTableComponent> { ); }); - // Reset page and update pagination info when data view changes + // A new filter or sort changes which row sits at each index, + // so clear the selection and go back to the top. effect(() => { - const data = this.data_view(); - const page_size_value = this.page_size(); - - this.selected.set([]); - this.page.set(0); - this._viewport()?.scrollToIndex(0); + this.filter(); + this.sort(); + untracked(() => { + this.selected.set([]); + this.page.set(0); + this._viewport()?.scrollToIndex(0); + }); + }); - if (page_size_value) { - this.total_count.set(data.length); - this.total_pages.set(Math.ceil(data.length / page_size_value)); - } + // New data (e.g. a poll) keeps the selection and page where possible + effect(() => { + const count = this.data_view().length; + const page_size_value = this.page_size(); + untracked(() => { + const selected = this.selected(); + if (selected.some((i) => i >= count)) { + this.selected.set(selected.filter((i) => i < count)); + } + if (!page_size_value) return; + const pages = Math.ceil(count / page_size_value); + this.total_count.set(count); + this.total_pages.set(pages); + if (this.page() >= pages) this.page.set(Math.max(pages - 1, 0)); + }); }); } diff --git a/src/app/ui/virtual-scroll.component.ts b/src/app/ui/virtual-scroll.component.ts index bed592ca3..45a387f62 100644 --- a/src/app/ui/virtual-scroll.component.ts +++ b/src/app/ui/virtual-scroll.component.ts @@ -1,6 +1,7 @@ import { NgTemplateOutlet, SlicePipe } from '@angular/common'; import { AfterViewInit, + ChangeDetectionStrategy, Component, computed, ElementRef, @@ -16,11 +17,7 @@ import { @Component({ selector: 'virtual-scroll', template: ` -
+
([]); @@ -80,6 +78,7 @@ export class VirtualScrollComponent implements AfterViewInit, OnDestroy { private _scroll_container_el = viewChild>('scroll_container'); private _resize_observer: ResizeObserver; + private readonly _on_scroll = () => this.updateOffsets(); public ngAfterViewInit() { this.updateContainer(); @@ -87,18 +86,30 @@ export class VirtualScrollComponent implements AfterViewInit, OnDestroy { this.updateContainer(), ); this._resize_observer.observe(this._el.nativeElement); + // Native listener so scroll events do not schedule a tick on their own + this._scroll_container_el()?.nativeElement.addEventListener( + 'scroll', + this._on_scroll, + { passive: true }, + ); } public ngOnDestroy() { this._resize_observer?.disconnect(); + this._scroll_container_el()?.nativeElement.removeEventListener( + 'scroll', + this._on_scroll, + ); } + /** Update the first visible item. Only writes state when it changes. */ public updateOffsets() { const el = this._scroll_container_el()?.nativeElement; if (!el) return; - const scroll_top = el.scrollTop; - this.offset.set(Math.floor(scroll_top / this.item_size())); - this.scrolled.emit([this.offset(), this.offset() + this.range()]); + const offset = Math.floor(el.scrollTop / this.item_size()); + if (offset === this.offset()) return; + this.offset.set(offset); + this.scrolled.emit([offset, offset + this.range()]); } public updateContainer() { diff --git a/src/app/users/users-state.service.ts b/src/app/users/users-state.service.ts index 7b17ef819..018528bf4 100644 --- a/src/app/users/users-state.service.ts +++ b/src/app/users/users-state.service.ts @@ -12,8 +12,8 @@ import { reviveUser, updateGroupUser, } from '@placeos/ts-client'; -import { escapeHtml } from '../common/general'; import { describeError } from '../common/errors'; +import { escapeHtml } from '../common/general'; import { ActiveItemService } from '../common/item.service'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess } from '../common/notifications'; @@ -44,6 +44,7 @@ export class UsersStateService { public readonly item = this._service.item; + // The metadata tab loads its own list, so only the count is needed here private readonly _counts = resource({ params: () => ({ item: this._service.active_item$(), @@ -72,20 +73,6 @@ export class UsersStateService { () => this._counts.value() || { metadata: 0, groups: 0 }, ); - private readonly _metadata = resource({ - params: () => ({ - item: this.item(), - change: this._change(), - }), - loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceUser)) return []; - return listMetadata(item.id).catch((_err) => []); - }, - }); - - public readonly metadata = computed(() => this._metadata.value() || []); - private readonly _groups = resource({ params: () => ({ item: this.item(), change: this._change() }), loader: async ({ params }) => { @@ -291,8 +278,4 @@ export class UsersStateService { private changed() { this._change.set(Date.now()); } - - constructor() { - setTimeout(() => this.changed(), 1000); - } } diff --git a/src/app/users/users.service.ts b/src/app/users/users.service.ts index f5989bfbe..2feeb84e7 100644 --- a/src/app/users/users.service.ts +++ b/src/app/users/users.service.ts @@ -16,6 +16,9 @@ import { current_user, setCurrentUser } from '../common/user-state'; type ServiceItem = PlaceUser; +/** Attempts `load` makes before it waits for the next reconnect */ +const MAX_LOAD_ATTEMPTS = 12; + @Service() export class BackofficeUsersService extends AsyncHandler { private _settings = inject(SettingsService); @@ -83,19 +86,28 @@ export class BackofficeUsersService extends AsyncHandler { return (await queryUsers(query_params)).data; } - public load(): Promise { + /** + * Load the signed-in user and their support access. Retries with backoff + * while the user is not available, and gives up after + * `MAX_LOAD_ATTEMPTS` until the next reconnect calls it again. + */ + public load(attempt = 0): Promise { return new Promise((resolve) => { this.state.set('loading'); + const retry = () => { + if (attempt + 1 >= MAX_LOAD_ATTEMPTS) { + this.state.set('error'); + return resolve(); + } + this.timeout( + 'load', + () => this.load(attempt + 1).then(() => resolve()), + Math.min(600 * 2 ** attempt, 30 * 1000), + ); + }; currentUser() .then(async (user) => { - if (!user) { - this.timeout( - 'load', - () => this.load().then((_) => resolve()), - 600, - ); - return; - } + if (!user) return retry(); await loadSupportAccess(user); setCurrentUser(user); this.state.set('success'); @@ -104,13 +116,7 @@ export class BackofficeUsersService extends AsyncHandler { const _current_theme = this.dark_mode; resolve(); }) - .catch(() => - this.timeout( - 'load', - () => this.load().then((_) => resolve()), - 600, - ), - ); + .catch(retry); }); } diff --git a/src/app/zones/zone-children.component.ts b/src/app/zones/zone-children.component.ts index b6836e390..c7f3d8684 100644 --- a/src/app/zones/zone-children.component.ts +++ b/src/app/zones/zone-children.component.ts @@ -14,7 +14,7 @@ import { ZonesStateService } from './zones-state.service'; @Component({ selector: 'zone-children', template: ` -
+
@@ -36,7 +36,8 @@ import { ZonesStateService } from './zones-state.service'; [class.opacity-0]="!loading()" />
{{ data }} @if (!data) { diff --git a/src/app/zones/zone-form.component.ts b/src/app/zones/zone-form.component.ts index de776175e..e1a257f5b 100644 --- a/src/app/zones/zone-form.component.ts +++ b/src/app/zones/zone-form.component.ts @@ -45,6 +45,9 @@ import { IconComponent } from '../ui/icon.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { applyZoneFormSchema, generateZoneFormModel } from './zones.utilites'; +/** Most timezone options to render. Typing narrows the full list. */ +const TIMEZONE_OPTION_LIMIT = 50; + @Component({ selector: 'zone-form', template: ` @@ -359,9 +362,9 @@ export class ZoneFormComponent extends AsyncHandler implements OnInit { ); public readonly filtered_timezones = computed(() => { const search = this.timezone().toLowerCase(); - return this.timezones.filter((_tz) => - _tz.toLowerCase().includes(search), - ); + return this.timezones + .filter((_tz) => _tz.toLowerCase().includes(search)) + .slice(0, TIMEZONE_OPTION_LIMIT); }); public readonly loading = signal(null); public readonly heading = i18n( diff --git a/src/app/zones/zone-systems.component.ts b/src/app/zones/zone-systems.component.ts index 765244316..b897fccbb 100644 --- a/src/app/zones/zone-systems.component.ts +++ b/src/app/zones/zone-systems.component.ts @@ -14,7 +14,7 @@ import { ZonesStateService } from './zones-state.service'; @Component({ selector: 'zone-systems', template: ` -
+
@@ -36,7 +36,8 @@ import { ZonesStateService } from './zones-state.service'; [class.opacity-0]="!loading()" /> - - - - - - diff --git a/src/material-theme.scss b/src/material-theme.scss index 38e49053c..84ec1cf6c 100644 --- a/src/material-theme.scss +++ b/src/material-theme.scss @@ -12,7 +12,10 @@ $theme: mat.m2-define-light-theme( A400 ), ), - typography: mat.m2-define-typography-config(), + typography: mat.m2-define-typography-config( + $font-family: + "'Roboto Variable', Roboto, 'Helvetica Neue', sans-serif", + ), density: 0, ) ); diff --git a/src/styles.css b/src/styles.css index 7a6de5bd2..6cfd83729 100644 --- a/src/styles.css +++ b/src/styles.css @@ -36,7 +36,9 @@ --error: #e53935; --bg-dark: #f0f0f0; --bg: #212121; - --font: 'Roboto', 'Verdana', 'Helvetica Neue', Arial, sans-serif; + --font: + 'Roboto Variable', 'Roboto', 'Verdana', 'Helvetica Neue', Arial, + sans-serif; --heading-font: 'Young', var(--font); --mono-font: 'Fira Code', monospace; @@ -358,7 +360,7 @@ body { body { background: #424242; margin: 0; - font-family: Roboto, 'Helvetica Neue', sans-serif; + font-family: 'Roboto Variable', Roboto, 'Helvetica Neue', sans-serif; } .font-heading { @@ -776,7 +778,7 @@ table:not([role='grid']) { \*=============================================================*/ /* Typography */ .font-heading { - font-family: 'Young', 'Roboto', sans-serif; + font-family: 'Young', 'Roboto Variable', 'Roboto', sans-serif; font-weight: 500; } diff --git a/src/tests/common/cascade-delete.spec.ts b/src/tests/common/cascade-delete.spec.ts index 56819bf91..05e8c4a5e 100644 --- a/src/tests/common/cascade-delete.spec.ts +++ b/src/tests/common/cascade-delete.spec.ts @@ -590,6 +590,45 @@ describe('cascade-delete', () => { expect(messages[0]).toContain('"total":2'); }); + it('removes systems a few at a time and the zone after them all', async () => { + let running = 0; + let peak = 0; + const order: string[] = []; + const system = (id: string) => ({ + resource: { type: 'system' as const, id, name: id }, + run: async () => { + running++; + peak = Math.max(peak, running); + await new Promise((resolve) => setTimeout(resolve, 1)); + running--; + order.push(id); + }, + }); + const plan = { + scope: [], + summary: [], + warnings: [], + steps: [ + ...['s1', 's2', 's3', 's4', 's5', 's6'].map(system), + { + resource: { + type: 'zone' as const, + id: 'zone-1', + name: 'zone', + }, + run: async () => { + order.push('zone'); + }, + }, + ], + }; + const outcome = await runCascade(plan); + expect(peak).toBe(4); + expect(order).toHaveLength(7); + expect(order[6]).toBe('zone'); + expect(outcome.removed).toHaveLength(7); + }); + it('stops at the first failure and skips the rest', async () => { // Steps are ordered by dependency — a domain plan puts the org zone // after the systems inside it — so carrying on past a failure would diff --git a/src/tests/common/item-group-switch.spec.ts b/src/tests/common/item-group-switch.spec.ts index cefdbf8f2..5a0e89315 100644 --- a/src/tests/common/item-group-switch.spec.ts +++ b/src/tests/common/item-group-switch.spec.ts @@ -12,14 +12,26 @@ import { BackofficeUsersService } from '../../app/users/users.service'; const mocks = vi.hoisted(() => ({ query: vi.fn(), show: vi.fn(), + settings: vi.fn(async () => ({ data: [] as object[] })), group: 'group-1', })); -vi.mock('@placeos/ts-client', () => - vi.importActual('@placeos/ts-client/dist/index.es.js'), -); +vi.mock('@placeos/ts-client', async () => ({ + ...(await vi.importActual( + '@placeos/ts-client/dist/index.es.js', + )), + querySettings: mocks.settings, +})); vi.mock('../../app/common/actions', () => ({ ACTIONS: { - systems: { query: mocks.query, show: mocks.show }, + systems: { + query: mocks.query, + show: mocks.show, + itemConstructor: class { + constructor(data: object) { + Object.assign(this, data); + } + }, + }, users: { query: mocks.query, show: mocks.show }, }, })); @@ -27,6 +39,7 @@ vi.mock('../../app/common/support-access', () => ({ hasSupportRole: () => false, hasSupportSubsystem: () => true, canAccessSection: () => true, + clearSupportModuleCache: () => undefined, selected_support_group_id: () => mocks.group, selectSupportGroup: (id: string) => { mocks.group = id; @@ -177,3 +190,56 @@ it('reloads users with the deleted filter and ignores an older response', async expect(service.count()).toBe(0); service.ngOnDestroy(); }); + +it('shows a saved item with its current settings', async () => { + vi.useFakeTimers(); + mocks.query.mockResolvedValue({ data: [], total: 0, next: null }); + mocks.show.mockResolvedValue({ id: 'sys-1', name: 'Room' }); + mocks.settings + .mockResolvedValueOnce({ + data: [{ encryption_level: 0, settings_string: 'a: 1' }], + }) + .mockResolvedValueOnce({ + data: [{ encryption_level: 0, settings_string: 'a: 2' }], + }); + const router = { + url: '/systems/sys-1', + events: new Subject(), + parseUrl: (url: string) => new DefaultUrlSerializer().parse(url), + navigate: vi.fn(async () => true), + }; + const user = signal({ sys_admin: false, support: false }); + TestBed.configureTestingModule({ + providers: [ + ActiveItemService, + { provide: Router, useValue: router }, + { provide: MatDialog, useValue: {} }, + { provide: SettingsService, useValue: {} }, + { + provide: HotkeysService, + useValue: { listen: () => () => undefined }, + }, + { + provide: BackofficeUsersService, + useValue: { user, current: user }, + }, + ], + }); + const service = TestBed.inject(ActiveItemService); + await vi.advanceTimersByTimeAsync(350); + await service.setItem('sys-1'); + const settingsValue = () => + ( + service.active_item as unknown as { + settings: { settings_string: string }[]; + } + ).settings[0].settings_string; + expect(settingsValue()).toBe('a: 1'); + // Settings saved since the item loaded must not be lost on a rename + service.replaceItem({ id: 'sys-1', name: 'Renamed' }); + expect(service.active_item?.name).toBe('Renamed'); + await vi.advanceTimersByTimeAsync(1); + expect(service.active_item?.name).toBe('Renamed'); + expect(settingsValue()).toBe('a: 2'); + service.ngOnDestroy(); +}); diff --git a/src/tests/common/support-access.spec.ts b/src/tests/common/support-access.spec.ts index 8552c17d2..ec07614df 100644 --- a/src/tests/common/support-access.spec.ts +++ b/src/tests/common/support-access.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { canAccessSection, canUseSupportAction, + clearSupportModuleCache, hasSupportSubsystem, isSubsystemUser, loadSupportAccess, @@ -11,6 +12,7 @@ import { querySupportZones, selectSupportGroup, selected_support_group_id, + support_groups, } from '../../app/common/support-access'; const api = vi.hoisted(() => ({ @@ -179,6 +181,9 @@ describe('selected support group', () => { next: null, }); await loadSupportAccess(new PlaceUser({ sys_admin: true })); + // Admins are published at once and their groups arrive after + expect(canAccessSection('admin')).toBe(true); + await vi.waitFor(() => expect(support_groups()).toHaveLength(1)); expect(selected_support_group_id()).toBe(''); selectSupportGroup('group-1'); querySupportSystems({ q: 'room' }); @@ -195,13 +200,11 @@ describe('selected support group', () => { it('lists modules through group systems and removes shared duplicates', async () => { api.currentGroups.mockResolvedValue([membership(1)]); await loadSupportAccess(new PlaceUser()); - const next = vi - .fn() - .mockResolvedValue({ - data: [{ id: 'sys-2' }], - total: 2, - next: null, - }); + const next = vi.fn().mockResolvedValue({ + data: [{ id: 'sys-2' }], + total: 2, + next: null, + }); api.querySystems.mockResolvedValue({ data: [{ id: 'sys-1' }], total: 2, @@ -224,7 +227,7 @@ describe('selected support group', () => { const result = await querySupportModules({ q: 'screen' }); expect(result.data.map(({ id }) => id)).toEqual(['mod-1']); expect(api.querySystems).toHaveBeenCalledWith({ - limit: 200, + limit: 1000, subsystem: 'support', group_id: 'group-1', }); @@ -236,6 +239,33 @@ describe('selected support group', () => { }); }); + it('walks the group systems once for every search on the same group', async () => { + api.currentGroups.mockResolvedValue([membership(1)]); + await loadSupportAccess(new PlaceUser()); + api.querySystems.mockResolvedValue({ + data: [{ id: 'sys-1' }], + total: 1, + next: null, + }); + api.queryModules.mockResolvedValue({ + data: [ + { id: 'mod-1', name: 'Screen' }, + { id: 'mod-2', name: 'Light' }, + ], + total: 2, + next: null, + }); + const all = await querySupportModules({}); + const lights = await querySupportModules({ q: 'light' }); + expect(all.data).toHaveLength(2); + expect(lights.data.map(({ id }) => id)).toEqual(['mod-2']); + expect(api.querySystems).toHaveBeenCalledOnce(); + expect(api.queryModules).toHaveBeenCalledOnce(); + clearSupportModuleCache(); + await querySupportModules({}); + expect(api.queryModules).toHaveBeenCalledTimes(2); + }); + it('does not show any sections before a user has access', async () => { expect( ['systems', 'modules', 'zones', 'users', 'admin'].filter( diff --git a/src/tests/groups/group-state.service.spec.ts b/src/tests/groups/group-state.service.spec.ts index 6f6318557..827c1ec4a 100644 --- a/src/tests/groups/group-state.service.spec.ts +++ b/src/tests/groups/group-state.service.spec.ts @@ -5,13 +5,14 @@ import { } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { MatDialog } from '@angular/material/dialog'; +import { NavigationEnd, Router } from '@angular/router'; import { PlaceGroup, PlaceGroupUser, PlaceGroupZone, PlaceUser, } from '@placeos/ts-client'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ActiveItemService } from '../../app/common/item.service'; import { GroupStateService } from '../../app/groups/group-state.service'; @@ -77,6 +78,10 @@ describe('group membership actions', () => { const active = signal(null); const close = vi.fn(); const loading = vi.fn(); + const router = { + url: '/groups/group-1/users', + events: new Subject(), + }; let service: GroupStateService; beforeEach(() => { @@ -100,6 +105,7 @@ describe('group membership actions', () => { useValue: { item: active, active_item: group }, }, { provide: MatDialog, useValue: { open: mocks.open } }, + { provide: Router, useValue: router }, ], }); service = TestBed.inject(GroupStateService); @@ -129,6 +135,23 @@ describe('group membership actions', () => { }); }); + it('only counts members until their tab is opened', async () => { + const url = '/groups/group-1/about'; + router.events.next(new NavigationEnd(1, url, url)); + mocks.queryGroupUsers.mockResolvedValue({ + data: [new PlaceGroupUser({ user_id: 'a-user' })], + total: 7, + }); + active.set(group); + await TestBed.inject(ApplicationRef).whenStable(); + expect(service.users()).toEqual([]); + expect(service.counts()).toEqual({ users: 7, zones: 0 }); + expect(mocks.queryGroupUsers).toHaveBeenCalledExactlyOnceWith({ + group_id: 'group-1', + limit: 1, + }); + }); + it('reports failed membership queries and recovers to empty lists', async () => { mocks.queryGroupUsers.mockRejectedValue(new Error('Offline')); mocks.queryGroupZones.mockRejectedValue(new Error('Offline')); diff --git a/src/tests/ui/components/simple-table.component.spec.ts b/src/tests/ui/components/simple-table.component.spec.ts index 11f77c76a..3a45b402b 100644 --- a/src/tests/ui/components/simple-table.component.spec.ts +++ b/src/tests/ui/components/simple-table.component.spec.ts @@ -70,6 +70,50 @@ describe('SimpleTableComponent', () => { expect(component.selected()).toEqual([]); }); + it('keeps the selection when the data refreshes', async () => { + const data = signal([ + { id: '1', name: 'Item 1' }, + { id: '2', name: 'Item 2' }, + ]); + fixture.componentRef.setInput('data', data); + fixture.componentRef.setInput('selectable', true); + fixture.detectChanges(); + await fixture.whenStable(); + component.select(1, true); + data.set([{ id: '1', name: 'Item 1' }, ...data().slice(1)]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.selected()).toEqual([1]); + data.set([{ id: '1', name: 'Item 1' }]); + fixture.detectChanges(); + await fixture.whenStable(); + expect(component.selected()).toEqual([]); + }); + + it('emits enter_row once per hovered row', async () => { + fixture.componentRef.setInput('data', [ + { id: '1', name: 'Item 1', value: 1 }, + { id: '2', name: 'Item 2', value: 2 }, + ]); + fixture.componentRef.setInput('columns', [ + { key: 'name', name: 'Name' }, + { key: 'value', name: 'Value' }, + ]); + fixture.detectChanges(); + await fixture.whenStable(); + const rows: number[] = []; + component.enter_row.subscribe((row) => rows.push(row)); + const cells = fixture.nativeElement.querySelectorAll( + '[data-row]', + ) as NodeListOf; + const hover = (el: HTMLElement) => + el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); + hover(cells[0]); + hover(cells[1]); + hover(cells[2]); + expect(rows).toEqual([0, 1]); + }); + describe('default state', () => { it('should have empty selected array', () => { expect(component.selected()).toEqual([]); @@ -356,6 +400,28 @@ describe('SimpleTableComponent', () => { expect(values).toEqual([30, 20, 10]); }); + it('should sort mixed values by their text form', async () => { + const data = [ + { id: '1', name: 'b' }, + { id: '2', name: undefined }, + { id: '3', name: 'a' }, + ]; + fixture.componentRef.setInput('data', data); + fixture.componentRef.setInput('columns', test_columns); + fixture.componentRef.setInput('sortable', true); + fixture.detectChanges(); + await fixture.whenStable(); + + component.setSort('name'); + component.setSort('name'); + fixture.detectChanges(); + await fixture.whenStable(); + + const ids = component.data_view().map((d) => d.id); + expect(ids).toEqual(['1', '3', '2']); + expect(data.map((d) => d.id)).toEqual(['1', '2', '3']); + }); + it('should use custom sort function when provided', async () => { const columns_with_sort: TableColumn[] = [ { diff --git a/src/tests/users/user-lifecycle.spec.ts b/src/tests/users/user-lifecycle.spec.ts index bbffdd237..7a1dece13 100644 --- a/src/tests/users/user-lifecycle.spec.ts +++ b/src/tests/users/user-lifecycle.spec.ts @@ -29,6 +29,7 @@ vi.mock('../../app/common/support-access', () => ({ hasSupportRole: () => false, hasSupportSubsystem: () => false, isSubsystemUser: () => false, + clearSupportModuleCache: () => undefined, })); vi.mock('../../app/users/users.service', () => ({ BackofficeUsersService: class {},