From 690e4c19cabbdf5e293aa050713ef48752ab53b1 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 15:30:03 +1100 Subject: [PATCH 1/5] perf: drop redundant requests and upload buffering - Uploads pass the File to the chunked upload library at once. They no longer read the whole file into memory and copy it to an unused blob. Remove the blob-util dependency. - User lookups share requests that are in flight. - Driver and module tab counts use the totals the state services already load, not a second count query. - The user group list loads when its tab opens. Until then a single row query gives the count. - The default locale reuses its bundled mappings instead of fetching the same file again. - Remove the unused API key users query. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 3 - package.json | 1 - src/app/admin/api-keys/api-keys.service.ts | 23 ------- src/app/common/locale.service.ts | 33 ++++++--- src/app/common/uploads.ts | 79 ++++++++++------------ src/app/drivers/driver-state.service.ts | 13 ++-- src/app/drivers/drivers.component.ts | 29 +------- src/app/modules/module-state.service.ts | 16 +++-- src/app/modules/modules.component.ts | 30 ++------ src/app/ui/pipes/user.pipe.ts | 19 +++--- src/app/users/users-state.service.ts | 68 +++++++++++-------- src/tests/ui/pipes/user.pipe.spec.ts | 11 +++ 12 files changed, 150 insertions(+), 175 deletions(-) diff --git a/bun.lock b/bun.lock index f35dd7cce..1be7ca360 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,6 @@ "@xterm/xterm": "^5.5.0", "angular-eslint": "22.6.0", "autoprefixer": "^10.4.16", - "blob-util": "^2.0.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "eslint": "^9.8.0", @@ -1109,8 +1108,6 @@ "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], - "blob-util": ["blob-util@2.0.2", "", {}, "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ=="], - "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], diff --git a/package.json b/package.json index e28285840..b9c8bece4 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,6 @@ "@xterm/xterm": "^5.5.0", "angular-eslint": "22.6.0", "autoprefixer": "^10.4.16", - "blob-util": "^2.0.2", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", "eslint": "^9.8.0", diff --git a/src/app/admin/api-keys/api-keys.service.ts b/src/app/admin/api-keys/api-keys.service.ts index 458ffda72..7a63b0620 100644 --- a/src/app/admin/api-keys/api-keys.service.ts +++ b/src/app/admin/api-keys/api-keys.service.ts @@ -6,7 +6,6 @@ import { PlaceDomain, PlaceUser, query, - queryUsers, remove, showUser, update, @@ -38,7 +37,6 @@ export class APIKeyService { private _dialog = inject(MatDialog); private _admin_data = inject(AdminDataService); - private _search = signal(''); private _domain = this._admin_data.selectedDomain('api-keys'); private _last_key = signal(null); private _change = signal(0); @@ -84,23 +82,6 @@ export class APIKeyService { this._available_keys.hasValue() ? this._available_keys.value() : [], ); - private readonly _users = resource({ - params: () => ({ - domain: this._domain(), - q: this._search(), - change: this._change(), - }), - loader: async ({ params }) => { - const { domain, q } = params; - if (!domain) return [] as PlaceUser[]; - return (await queryUsers({ authority_id: domain.id, q })).data; - }, - }); - - public readonly users = computed(() => - this._users.hasValue() ? this._users.value() : [], - ); - public setDomain(domain: PlaceDomain) { this._admin_data.setDomain('api-keys', domain); } @@ -109,10 +90,6 @@ export class APIKeyService { return this._admin_data.selectDefaultDomain('api-keys'); } - public setSearch(s: string) { - this._search.set(s); - } - public async newKey() { const ref = this._dialog.open(APIKeyModalComponent, { data: { domain: this._domain() }, diff --git a/src/app/common/locale.service.ts b/src/app/common/locale.service.ts index 25a067fe6..c1e56c3fa 100644 --- a/src/app/common/locale.service.ts +++ b/src/app/common/locale.service.ts @@ -206,19 +206,14 @@ export class LocaleService { ); if (!existing.expiry || existing.expiry < Date.now()) { localStorage.removeItem(`${STORE_KEY}.${locale}`); - const resp = await fetch(`${this.locale_folder}/${locale}.json`); - if (!resp.ok) { + const base_locale_values = await this._baseMappings(locale); + if (!base_locale_values) { delete this._load_promises[locale]; - return console.error( - `Failed to loaded locale file for "${locale}".`, - resp, - ); + return; } - const locale_data = await resp.json(); const locale_override_data = this.zone_id ? await showMetadata(this.zone_id, `locale_${locale}`) : { details: {} }; - const base_locale_values = removeNesting(locale_data); const override_locale_values = removeNesting( locale_override_data.details as Record, ); @@ -242,4 +237,26 @@ export class LocaleService { } delete this._load_promises[locale]; } + + /** + * Mappings from the locale file. The default locale reuses the bundled + * copy that `ready` loads. Returns `null` if the file fails to load. + */ + private async _baseMappings( + locale: string, + ): Promise | null> { + if (locale === this._default_locale) { + await this.ready; + return this._default_mappings; + } + const resp = await fetch(`${this.locale_folder}/${locale}.json`); + if (!resp.ok) { + console.error( + `Failed to loaded locale file for "${locale}".`, + resp, + ); + return null; + } + return removeNesting(await resp.json()); + } } diff --git a/src/app/common/uploads.ts b/src/app/common/uploads.ts index c4b500bb3..5f23a5322 100644 --- a/src/app/common/uploads.ts +++ b/src/app/common/uploads.ts @@ -8,8 +8,6 @@ import { token } from '@placeos/ts-client'; import { randomInt } from './general'; import { SubscriptionLike } from './signals'; -import * as blobUtil from 'blob-util'; - /** Token last passed to the upload service */ let upload_token = ''; @@ -78,51 +76,42 @@ export function uploadFile( subscribe(next, error, complete) { let cancelled = false; let state_subscription: SubscriptionLike | null = null; - const fileReader = new FileReader(); - fileReader.addEventListener( - 'loadend', - async (e: ProgressEvent) => { + // Pass the File as is. The upload library reads it in chunks. + (async () => { + syncUploadToken(); + const upload = await uploadNewFile(file, { + permissions, + public: is_public, + } as Record); + if (cancelled) return; + const upload_details: UploadDetails = { + id: randomInt(9999_9999_9999), + name: file.name, + progress: 0, + link: uploadURL(upload.id), + formatted_size: humanReadableByteCount(file.size), + size: file.size, + upload: upload, + }; + state_subscription = upload.state.subscribe((state) => { if (cancelled) return; - const arrayBuffer = e.target.result; - const _blob = blobUtil.arrayBufferToBlob( - arrayBuffer as ArrayBuffer, - file.type, - ); - syncUploadToken(); - const upload = await uploadNewFile(file, { - permissions, - public: is_public, - } as Record); - const upload_details: UploadDetails = { - id: randomInt(9999_9999_9999), - name: file.name, - progress: 0, - link: uploadURL(upload.id), - formatted_size: humanReadableByteCount(file.size), - size: file.size, - upload: upload, - }; - state_subscription = upload.state.subscribe((state) => { - if (cancelled) return; - upload_details.progress = state.progress; - upload_details.link = uploadURL(upload.id); - next(upload_details); - if (state.status === 'FAILED') { - error?.({ - ...upload_details, - error: 'Upload failed', - }); - state_subscription?.unsubscribe(); - } - if (state.status === 'COMPLETED') { - complete?.(); - state_subscription?.unsubscribe(); - } - }); + upload_details.progress = state.progress; + upload_details.link = uploadURL(upload.id); next(upload_details); - }, - ); - fileReader.readAsArrayBuffer(file); + if (state.status === 'FAILED') { + error?.({ + ...upload_details, + error: 'Upload failed', + }); + state_subscription?.unsubscribe(); + } + if (state.status === 'COMPLETED') { + complete?.(); + state_subscription?.unsubscribe(); + } + }); + next(upload_details); + })(); return { unsubscribe: () => { cancelled = true; diff --git a/src/app/drivers/driver-state.service.ts b/src/app/drivers/driver-state.service.ts index 62d7c46d2..1545fa4bd 100644 --- a/src/app/drivers/driver-state.service.ts +++ b/src/app/drivers/driver-state.service.ts @@ -56,15 +56,20 @@ export class DriverStateService { private readonly _modules = resource({ params: () => ({ item: this.item(), changed: this._modules_change() }), loader: async ({ params: { item } }) => { - if (!(item instanceof PlaceDriver)) return [] as PlaceModule[]; + if (!(item instanceof PlaceDriver)) + return { data: [] as PlaceModule[], total: 0 }; const response = await queryModules({ driver_id: item.id, - }).catch(() => ({ data: [] })); - return response.data; + }).catch(() => ({ data: [] as PlaceModule[], total: 0 })); + return { data: response.data, total: response.total }; }, }); - public readonly modules = computed(() => this._modules.value() || []); + public readonly modules = computed(() => this._modules.value()?.data || []); + /** Total modules of the driver, or `undefined` while loading */ + public readonly modules_total = computed(() => + this._modules.isLoading() ? undefined : this._modules.value()?.total, + ); private readonly _docs = resource({ params: () => this.item(), diff --git a/src/app/drivers/drivers.component.ts b/src/app/drivers/drivers.component.ts index 9ce319d42..d5ea5c212 100644 --- a/src/app/drivers/drivers.component.ts +++ b/src/app/drivers/drivers.component.ts @@ -1,8 +1,7 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; -import { PlaceDriver, queryModules } from '@placeos/ts-client'; import { extensionsForItem } from '../common/api'; import { PlaceDebugService } from '../common/debug.service'; import { ActiveItemService } from '../common/item.service'; @@ -128,7 +127,7 @@ export class DriversComponent { public readonly loading = this._service.loading; public readonly scroll = signal(0); public readonly open_menu = signal(false); - public readonly device_count = signal(0); + public readonly device_count = this._drivers.modules_total; public readonly docs = this._drivers.docs; public readonly extensions = computed(() => extensionsForItem(this.item(), this.name), @@ -169,28 +168,4 @@ export class DriversComponent { public readonly showUpdateList = () => this._drivers.showUpdateList(); public readonly newItem = () => this._service.create(); - - constructor() { - effect(() => { - const item = this.item(); - // Reload the count after a module is removed - this._drivers.modules_change(); - this.device_count.set(undefined); - this.loadValues(item); - }); - } - - protected async loadValues(item: PlaceDriver) { - if (!item) return; - const query: Record = { - offset: 0, - limit: 1, - driver_id: item.id, - }; - this.device_count.set( - await queryModules(query) - .then(({ total }) => total) - .catch(() => 0), - ); - } } diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts index 95517bf29..f7d9ebf11 100644 --- a/src/app/modules/module-state.service.ts +++ b/src/app/modules/module-state.service.ts @@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog'; import { moduleSettings, PlaceModule, + PlaceSystem, showDriver, showEdge, showSystem, @@ -72,13 +73,14 @@ export class ModuleStateService { private readonly _system_list = resource({ params: () => this.item(), loader: async ({ params: item }) => { - if (!(item instanceof PlaceModule)) return []; + if (!(item instanceof PlaceModule)) + return { data: [] as PlaceSystem[], total: 0 }; this._loading.set(true); try { const response = await querySystems({ module_id: item.id, - }).catch(() => ({ data: [] })); - return response.data; + }).catch(() => ({ data: [] as PlaceSystem[], total: 0 })); + return { data: response.data, total: response.total }; } finally { this._loading.set(false); } @@ -86,7 +88,13 @@ export class ModuleStateService { }); /** Systems associated with the active module */ public readonly system_list = computed( - () => this._system_list.value() || [], + () => this._system_list.value()?.data || [], + ); + /** Total systems of the active module, or `undefined` while loading */ + public readonly system_total = computed(() => + this._system_list.isLoading() + ? undefined + : this._system_list.value()?.total, ); public get active_item(): PlaceModule { diff --git a/src/app/modules/modules.component.ts b/src/app/modules/modules.component.ts index a28761f1e..7cfe0511d 100644 --- a/src/app/modules/modules.component.ts +++ b/src/app/modules/modules.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, effect, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; @@ -7,10 +7,7 @@ import { extensionsForItem } from '../common/api'; import { PlaceDebugService } from '../common/debug.service'; import { ActiveItemService } from '../common/item.service'; import { i18n } from '../common/locale.service'; -import { - isSubsystemUser, - querySupportSystems as querySystems, -} from '../common/support-access'; +import { isSubsystemUser } from '../common/support-access'; import { DebugOutputComponent } from '../ui/debug-output.component'; import { IconComponent } from '../ui/icon.component'; import { ItemDetailsSkeletonComponent } from '../ui/item-details-skeleton.component'; @@ -20,6 +17,7 @@ import { ItemSidebarComponent } from '../ui/item-sidebar.component'; import { ItemTablistComponent } from '../ui/item-tablist.component'; import { SidebarMenuComponent } from '../ui/sidebar-menu.component'; import { TranslatePipe } from '../ui/translate.pipe'; +import { ModuleStateService } from './module-state.service'; @Component({ selector: 'new-modules-view', @@ -114,6 +112,7 @@ import { TranslatePipe } from '../ui/translate.pipe'; export class ModulesComponent { private _service = inject(ActiveItemService); private _debug = inject(PlaceDebugService); + private _module_state = inject(ModuleStateService); public readonly name = 'modules'; public readonly item = computed( @@ -121,7 +120,7 @@ export class ModulesComponent { ); public readonly loading = this._service.loading; /** Number of systems for the active device */ - public readonly system_count = signal(undefined); + public readonly system_count = this._module_state.system_total; public readonly open_menu = signal(false); public readonly scroll = signal(0); public readonly debug_position = this._debug.position; @@ -152,23 +151,4 @@ export class ModulesComponent { ...this.extensions(), ].filter((tab) => !isSubsystemUser() || !['history'].includes(tab.id)), ); - - constructor() { - effect(() => { - const item = this.item(); - this.system_count.set(undefined); - this.loadValues(item); - }); - } - - protected async loadValues(item: PlaceModule) { - if (!item) return; - const query: Record = { - offset: 0, - limit: 1, - module_id: item.id, - }; - // Get system count - this.system_count.set((await querySystems(query)).total); - } } diff --git a/src/app/ui/pipes/user.pipe.ts b/src/app/ui/pipes/user.pipe.ts index 599d8818d..9f0e9005f 100644 --- a/src/app/ui/pipes/user.pipe.ts +++ b/src/app/ui/pipes/user.pipe.ts @@ -1,20 +1,23 @@ import { Pipe, PipeTransform } from '@angular/core'; import { PlaceUser, showUser } from '@placeos/ts-client'; -const USERS: PlaceUser[] = []; +/** User lookups by ID, email or card number. Requests in flight are shared. */ +const USERS = new Map>(); @Pipe({ name: 'user', }) export class UserPipe implements PipeTransform { - public async transform(id: string): Promise { - if (!id) return {} as PlaceUser; - let user = USERS.find( - (_) => _.id === id || _.email === id || _.card_number === id, - ); + public transform(id: string): Promise { + if (!id) return Promise.resolve({} as PlaceUser); + let user = USERS.get(id); if (!user) { - user = await showUser(id); - USERS.push(user); + // Drop a failed lookup so that a later render can try again + user = showUser(id).catch(() => { + USERS.delete(id); + return {} as PlaceUser; + }); + USERS.set(id, user); } return user; } diff --git a/src/app/users/users-state.service.ts b/src/app/users/users-state.service.ts index 018528bf4..54f9b6fe8 100644 --- a/src/app/users/users-state.service.ts +++ b/src/app/users/users-state.service.ts @@ -17,6 +17,7 @@ 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 { GroupBulkAddModalComponent, @@ -38,66 +39,79 @@ export class UsersStateService { public readonly loading = computed( () => this._saving() || - this._counts.isLoading() || + this._metadata_count.isLoading() || this._groups.isLoading(), ); public readonly item = this._service.item; // The metadata tab loads its own list, so only the count is needed here - private readonly _counts = resource({ + private readonly _metadata_count = resource({ params: () => ({ item: this._service.active_item$(), change: this._change(), }), loader: async ({ params }) => { const { item } = params; - if (!(item instanceof PlaceUser)) return {}; - const details = await Promise.all([ - listMetadata(item.id) - .then((d) => d.length) - .catch((_err) => 0), - queryGroupUsers({ user_id: item.id, limit: 1 }) - .then((response) => response.total) - .catch(() => 0), - ]); - const [metadata, groups] = details; - return { - metadata, - groups, - }; + if (!(item instanceof PlaceUser)) return 0; + return listMetadata(item.id) + .then((d) => d.length) + .catch((_err) => 0); }, }); public readonly counts = computed<{ metadata?: number; groups?: number }>( - () => this._counts.value() || { metadata: 0, groups: 0 }, + () => ({ + metadata: this._metadata_count.value() || 0, + groups: this._groups.value()?.total || 0, + }), + ); + + private readonly _user_id = computed(() => { + const item = this.item(); + return item instanceof PlaceUser ? item.id : undefined; + }); + // The group list only loads once its tab is opened. Until then a single + // row query gives the tab count. + private readonly _opened_tabs = openedTabs(this._user_id); + private readonly _load_groups = computed(() => + this._opened_tabs().has('groups'), ); private readonly _groups = resource({ - params: () => ({ item: this.item(), change: this._change() }), + params: () => ({ + item: this.item(), + change: this._change(), + full: this._load_groups(), + }), loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceUser)) return [] as PlaceGroupUser[]; + const { item, full } = params; + const empty = { data: [] as PlaceGroupUser[], total: 0 }; + if (!(item instanceof PlaceUser)) return empty; const response = await queryGroupUsers({ user_id: item.id, - limit: 1000, + limit: full ? 1000 : 1, }).catch((error) => { notifyError( i18n('USERS.GROUPS_LOAD_ERROR', { error: describeError(error), }), ); - return { data: [] as PlaceGroupUser[] }; + return empty; }); - return response.data.sort((a, b) => - (a.group?.name || a.group_id).localeCompare( - b.group?.name || b.group_id, + if (!full) return { ...empty, total: response.total }; + return { + total: response.total, + data: [...response.data].sort((a, b) => + (a.group?.name || a.group_id).localeCompare( + b.group?.name || b.group_id, + ), ), - ); + }; }, }); - public readonly groups = computed(() => this._groups.value() || []); + public readonly groups = computed(() => this._groups.value()?.data || []); public get active_item(): PlaceUser { return this._service.active_item as unknown as PlaceUser; diff --git a/src/tests/ui/pipes/user.pipe.spec.ts b/src/tests/ui/pipes/user.pipe.spec.ts index 8e678e9db..51868dbd4 100644 --- a/src/tests/ui/pipes/user.pipe.spec.ts +++ b/src/tests/ui/pipes/user.pipe.spec.ts @@ -59,6 +59,17 @@ describe('UserPipe', () => { expect(second_call_count).toBe(first_call_count); }); + it('should share a request that is in flight', async () => { + const { showUser } = await import('@placeos/ts-client'); + + await Promise.all([ + pipe.transform('user-789'), + new UserPipe().transform('user-789'), + ]); + + expect(showUser).toHaveBeenCalledTimes(1); + }); + it('should return user with expected properties', async () => { const result = await pipe.transform('test-user'); expect(result).toHaveProperty('id'); From 78ac4824b06af4ce6754e67b74e49c0990fdf8eb Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 15:30:23 +1100 Subject: [PATCH 2/5] perf(systems): load tab lists on demand and reload only what changed Opening a system sent 7 requests, including the full module list, and any action reloaded all 5 lists. - Load the module, zone and trigger lists when their tab opens. The item gives the module and zone counts, and a single row query gives the trigger count. - Settings, modules and triggers each have their own change signal. Module actions reload modules, editing a module also reloads the associated settings, and trigger actions reload triggers. Zones and the metadata count reload when the item changes. Co-Authored-By: Claude Opus 5.5 --- src/app/systems/system-state.service.ts | 104 +++++++++---- .../systems/system-state.service.spec.ts | 139 ++++++++++++++++++ 2 files changed, 214 insertions(+), 29 deletions(-) create mode 100644 src/tests/systems/system-state.service.spec.ts diff --git a/src/app/systems/system-state.service.ts b/src/app/systems/system-state.service.ts index 87556043a..36c0bd45e 100644 --- a/src/app/systems/system-state.service.ts +++ b/src/app/systems/system-state.service.ts @@ -31,6 +31,7 @@ import { calculateModuleIndex } from '../common/api'; import { AsyncHandler } from '../common/async-handler.class'; import { PlaceDebugService } from '../common/debug.service'; import { escapeHtml, unique } from '../common/general'; +import { openedTabs } from '../common/opened-tabs'; import { isSubsystemUser, querySupportSystems as querySystems, @@ -66,10 +67,33 @@ export class SystemStateService extends AsyncHandler { private _loading = signal>({}); private _modules = signal([]); private _last_module_system = ''; - private _change = signal(0); + // Each list reloads only after an action that changes it + private _settings_change = signal(0); + private _modules_change = signal(0); + private _triggers_change = signal(0); + + private readonly _system_id = computed(() => { + const item = this.item(); + return item instanceof PlaceSystem ? item.id : undefined; + }); + // Tab lists load once their tab is opened. The item gives the module and + // zone counts, and a single row query gives the trigger count. + private readonly _opened_tabs = openedTabs(this._system_id); + private readonly _load_modules = computed(() => + this._opened_tabs().has('modules'), + ); + private readonly _load_zones = computed(() => + this._opened_tabs().has('zones'), + ); + private readonly _load_triggers = computed(() => + this._opened_tabs().has('triggers'), + ); private readonly _associated_settings = resource({ - params: () => ({ item: this.item(), changed: this._change() }), + params: () => ({ + item: this.item(), + changed: this._settings_change(), + }), loader: async ({ params }) => { const { item } = params; if (!(item instanceof PlaceSystem)) return []; @@ -85,9 +109,8 @@ export class SystemStateService extends AsyncHandler { ); private readonly _counts = resource({ - params: () => ({ item: this.item(), changed: this._change() }), - loader: async ({ params }) => { - const { item } = params; + params: () => this.item(), + loader: async ({ params: item }) => { if (!(item instanceof PlaceSystem)) return {}; this.setLoading('settings', true); try { @@ -117,10 +140,14 @@ export class SystemStateService extends AsyncHandler { })); private readonly _module_resource = resource({ - params: () => ({ item: this.item(), changed: this._change() }), + params: () => ({ + item: this.item(), + changed: this._modules_change(), + load: this._load_modules(), + }), loader: async ({ params, abortSignal }) => { - const { item } = params; - if (!(item instanceof PlaceSystem)) { + const { item, load } = params; + if (!(item instanceof PlaceSystem) || !load) { this._last_module_system = ''; this._modules.set([]); return [] as PlaceModule[]; @@ -199,10 +226,11 @@ export class SystemStateService extends AsyncHandler { ); private readonly _zones = resource({ - params: () => ({ item: this.item(), changed: this._change() }), + params: () => ({ item: this.item(), load: this._load_zones() }), loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceSystem)) return [] as PlaceZone[]; + const { item, load } = params; + if (!(item instanceof PlaceSystem) || !load) + return [] as PlaceZone[]; this.setLoading('zones', true); try { const response = isSubsystemUser() @@ -234,17 +262,22 @@ export class SystemStateService extends AsyncHandler { public readonly zones = computed(() => this._zones.value() || []); private readonly _triggers = resource({ - params: () => ({ item: this.item(), changed: this._change() }), + params: () => ({ + item: this.item(), + changed: this._triggers_change(), + full: this._load_triggers(), + }), loader: async ({ params }) => { - const { item } = params; - if (!(item instanceof PlaceSystem)) - return { data: [] as PlaceTrigger[], total: 0 }; + const { item, full } = params; + const empty = { data: [] as PlaceTrigger[], total: 0 }; + if (!(item instanceof PlaceSystem)) return empty; this.setLoading('triggers', true); try { - return await listSystemTriggers(item.id).catch(() => ({ - data: [] as PlaceTrigger[], - total: 0, - })); + const response = await listSystemTriggers( + item.id, + full ? {} : { limit: 1 }, + ).catch(() => empty); + return full ? response : { ...empty, total: response.total }; } finally { this.setLoading('triggers', false); } @@ -283,7 +316,7 @@ export class SystemStateService extends AsyncHandler { }); if (!error) { notifySuccess(`Successfully started system`); - this.timeout('change', () => this.changed()); + this.timeout('change', () => this.modulesChanged()); } details.close(); } @@ -307,7 +340,7 @@ export class SystemStateService extends AsyncHandler { }); if (!error) { notifySuccess(`Successfully stopped system`); - this.timeout('change', () => this.changed()); + this.timeout('change', () => this.modulesChanged()); } details.close(); } @@ -341,7 +374,9 @@ export class SystemStateService extends AsyncHandler { public async editModule(device: PlaceModule) { await this._state.edit(device).catch((_err) => null); - this.changed(); + // Module settings also show in the associated settings + this.modulesChanged(); + this._settings_change.update((count) => count + 1); } public async addModuleToSystem(device: PlaceModule) { @@ -382,7 +417,7 @@ export class SystemStateService extends AsyncHandler { ); throw _e; }); - this.changed(); + this.modulesChanged(); ref.close(); notifySuccess( `Successfully added module to system "${ @@ -415,7 +450,7 @@ export class SystemStateService extends AsyncHandler { const t = await this.addTrigger( ref.componentInstance.item as PlaceTrigger, ); - this.changed(); + this.triggersChanged(); return t; } catch (err) { notifyError( @@ -433,7 +468,7 @@ export class SystemStateService extends AsyncHandler { important: false, trigger_id: trigger.id, } as Record); - this.timeout('change', () => this.changed()); + this.timeout('change', () => this.triggersChanged()); return t; } @@ -471,7 +506,7 @@ export class SystemStateService extends AsyncHandler { ref.close(); if (!trig) return trigger; notifySuccess(`Successfully updated trigger settings.`); - this.timeout('change', () => this.changed()); + this.timeout('change', () => this.triggersChanged()); return trig; } } @@ -496,7 +531,7 @@ export class SystemStateService extends AsyncHandler { details.close(); } notifySuccess(`Successfully removed trigger from system.`); - this.changed(); + this.triggersChanged(); } public async reorderModules(fst: number, snd: number) { @@ -751,7 +786,7 @@ export class SystemStateService extends AsyncHandler { ); (device as PlaceModule & { running: boolean }).running = !device.running; - this.changed(); + this.modulesChanged(); } /** View Results of the execute */ @@ -772,7 +807,18 @@ export class SystemStateService extends AsyncHandler { this._loading.update((state) => ({ ...state, [key]: value })); } + /** Reload the settings, module and trigger lists of the active system */ private changed() { - this._change.set(Date.now()); + this._settings_change.update((count) => count + 1); + this.modulesChanged(); + this.triggersChanged(); + } + + private modulesChanged() { + this._modules_change.update((count) => count + 1); + } + + private triggersChanged() { + this._triggers_change.update((count) => count + 1); } } diff --git a/src/tests/systems/system-state.service.spec.ts b/src/tests/systems/system-state.service.spec.ts new file mode 100644 index 000000000..90e5b6aff --- /dev/null +++ b/src/tests/systems/system-state.service.spec.ts @@ -0,0 +1,139 @@ +import { + ApplicationRef, + provideZonelessChangeDetection, + signal, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NavigationEnd, Router } from '@angular/router'; +import { PlaceModule, PlaceSystem } from '@placeos/ts-client'; +import { Subject } from 'rxjs'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlaceDebugService } from '../../app/common/debug.service'; +import { ActiveItemService } from '../../app/common/item.service'; +import { SystemStateService } from '../../app/systems/system-state.service'; + +const mocks = vi.hoisted(() => ({ + listMetadata: vi.fn(), + listSystemTriggers: vi.fn(), + listSystemZones: vi.fn(), + queryModules: vi.fn(), + startModule: vi.fn(), + systemSettings: vi.fn(), +})); +vi.mock('@placeos/ts-client', async () => ({ + ...(await vi.importActual( + '@placeos/ts-client/dist/index.es.js', + )), + ...mocks, +})); +vi.mock('../../app/common/item.service', () => ({ + ActiveItemService: class {}, +})); +vi.mock('../../app/common/debug.service', () => ({ + PlaceDebugService: class {}, +})); +vi.mock('../../app/common/support-access', () => ({ + isSubsystemUser: () => false, + querySupportSystems: vi.fn(), +})); +vi.mock('../../app/common/notifications', () => ({ + notifyError: vi.fn(), + notifySuccess: vi.fn(), +})); +vi.mock('../../app/overlays/confirm-modal.component', () => ({ + openConfirmModal: vi.fn(), +})); +vi.mock('../../app/overlays/select-item-modal.component', () => ({ + SelectItemModalComponent: class {}, +})); +vi.mock('../../app/overlays/view-response-modal.component', () => ({ + ViewResponseModalComponent: class {}, +})); +vi.mock('../../app/systems/system-trigger-form.component', () => ({ + SystemTriggerFormComponent: class {}, +})); + +describe('SystemStateService', () => { + const system = new PlaceSystem({ + id: 'sys-1', + modules: ['mod-1'], + zones: ['zone-1'], + }); + const active = signal(null); + let router: { url: string; events: Subject }; + let service: SystemStateService; + + const stable = () => TestBed.inject(ApplicationRef).whenStable(); + const openTab = (tab: string) => { + router.url = `/systems/sys-1/${tab}`; + router.events.next(new NavigationEnd(1, router.url, router.url)); + }; + + beforeEach(() => { + vi.resetAllMocks(); + active.set(null); + router = { url: '/systems/sys-1/about', events: new Subject() }; + mocks.listMetadata.mockResolvedValue([]); + mocks.listSystemTriggers.mockResolvedValue({ data: [], total: 3 }); + mocks.listSystemZones.mockResolvedValue({ data: [] }); + mocks.queryModules.mockResolvedValue({ + data: [new PlaceModule({ id: 'mod-1' })], + }); + mocks.startModule.mockResolvedValue(undefined); + mocks.systemSettings.mockResolvedValue([]); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + SystemStateService, + { + provide: ActiveItemService, + useValue: { item: active, active_item: system }, + }, + { provide: PlaceDebugService, useValue: {} }, + { provide: MatDialog, useValue: { open: vi.fn() } }, + { provide: Router, useValue: router }, + ], + }); + service = TestBed.inject(SystemStateService); + }); + + it('loads tab lists only once their tab is opened', async () => { + service.counts(); + service.modules(); + service.zones(); + active.set(system); + await stable(); + + expect(mocks.queryModules).not.toHaveBeenCalled(); + expect(mocks.listSystemZones).not.toHaveBeenCalled(); + expect(mocks.listSystemTriggers).toHaveBeenCalledWith('sys-1', { + limit: 1, + }); + expect(service.counts().triggers).toBe(3); + + openTab('modules'); + await stable(); + + expect(mocks.queryModules).toHaveBeenCalledTimes(1); + expect(service.modules().map(({ id }) => id)).toEqual(['mod-1']); + }); + + it('reloads only the module list after a module power change', async () => { + openTab('modules'); + service.counts(); + service.modules(); + service.associated_settings(); + active.set(system); + await stable(); + vi.clearAllMocks(); + + await service.toggleModulePower(service.modules()[0]); + await stable(); + + expect(mocks.queryModules).toHaveBeenCalledTimes(1); + expect(mocks.listSystemTriggers).not.toHaveBeenCalled(); + expect(mocks.systemSettings).not.toHaveBeenCalled(); + expect(mocks.listMetadata).not.toHaveBeenCalled(); + }); +}); From 5bde15c58d937968c78fd2077397158a14d781df Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 15:30:23 +1100 Subject: [PATCH 3/5] perf: page support zone searches and chunk the resource email lookup - Support zone searches ask for pages of 1000. Each group loaded every page one after another at the default size, so a 2000 zone group took about 20 requests per search. - The resource import check sent up to 1000 emails in one URL (about 30 KB) and read one page. Send 100 emails per request, read every page and match through a map. Co-Authored-By: Claude Opus 5.5 --- src/app/admin/resource-imports.component.ts | 43 +++++++++++++-------- src/app/common/support-access.ts | 3 +- src/tests/common/support-access.spec.ts | 1 + 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/src/app/admin/resource-imports.component.ts b/src/app/admin/resource-imports.component.ts index 7727e8b70..81ff49dd2 100644 --- a/src/app/admin/resource-imports.component.ts +++ b/src/app/admin/resource-imports.component.ts @@ -8,14 +8,15 @@ import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { addSystem, query, querySystemsWithEmails } from '@placeos/ts-client'; -import { escapeHtml } from '../common/general'; import { readError } from '../common/errors'; +import { escapeHtml } from '../common/general'; import { i18n } from '../common/locale.service'; import { notifyError, notifySuccess, notifyWarn, } from '../common/notifications'; +import { collectPages } from '../common/support-access'; import { openConfirmModal } from '../overlays/confirm-modal.component'; import { IconComponent } from '../ui/icon.component'; import { SimpleTableComponent } from '../ui/simple-table.component'; @@ -25,6 +26,8 @@ import { forEachWithLimit } from './signage-plugins/signage-plugins.utilities'; /** Max number of systems created at once */ const IMPORT_LIMIT = 5; +/** Emails per systems lookup. 100 emails make a URL of about 3 KB. */ +const EMAIL_CHUNK_SIZE = 100; export interface ExternalResource { id: string; @@ -326,23 +329,31 @@ export class ResourceImportsComponent implements OnInit { system_id: '', })); const emails = list.map((_) => _.email).filter((_) => _); - const { data } = emails.length - ? await querySystemsWithEmails({ in: emails.join(',') }).catch( - async (err) => { - notifyError( - `Failed to check imported resources. Error: ${await readError(err)}`, - ); - return { data: [] }; - }, - ) - : { data: [] }; - for (const resource of list) { - const system = data.find( - (_) => _.email?.toLowerCase() === resource.email.toLowerCase(), + const system_ids = new Map(); + try { + // Send the emails in chunks to keep each URL under common 8 KB limits + for (let i = 0; i < emails.length; i += EMAIL_CHUNK_SIZE) { + const systems = await collectPages( + querySystemsWithEmails({ + in: emails.slice(i, i + EMAIL_CHUNK_SIZE).join(','), + }), + ); + for (const system of systems) { + const email = system.email?.toLowerCase(); + if (email && !system_ids.has(email)) + system_ids.set(email, system.id); + } + } + } catch (err) { + notifyError( + `Failed to check imported resources. Error: ${await readError(err)}`, ); - if (system) { + } + for (const resource of list) { + const system_id = system_ids.get(resource.email.toLowerCase()); + if (system_id) { resource.imported = true; - resource.system_id = system.id; + resource.system_id = system_id; } } this.resource_list.set(list); diff --git a/src/app/common/support-access.ts b/src/app/common/support-access.ts index 2d14ddb29..1e7628025 100644 --- a/src/app/common/support-access.ts +++ b/src/app/common/support-access.ts @@ -191,7 +191,8 @@ export function querySupportZones( ); return Promise.all( groups.map(async ({ group }) => { - const query = { ...options, group_id: group.id }; + // Ask for large pages, as the pages load one after another + const query = { limit: 1000, ...options, group_id: group.id }; return collectPages(queryZones(query)); }), ).then((results) => { diff --git a/src/tests/common/support-access.spec.ts b/src/tests/common/support-access.spec.ts index ec07614df..30a4cdae9 100644 --- a/src/tests/common/support-access.spec.ts +++ b/src/tests/common/support-access.spec.ts @@ -115,6 +115,7 @@ describe('support subsystem access', () => { expect(next).toHaveBeenCalledOnce(); expect(api.queryZones).toHaveBeenCalledTimes(1); expect(api.queryZones).toHaveBeenCalledWith({ + limit: 1000, q: 'office', group_id: 'group-1', }); From 65cbd7dabebf5058a671afd4f43451372a80544a Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 15:30:23 +1100 Subject: [PATCH 4/5] perf(build): shallow BUILD checkout, smaller Monaco copy, faster e2e - BUILD fetches only the branch history and tags (about 30 MB, not 560 MB with the build/* deploy branches). The changelog is identical. - git-cliff runs alongside the build. A failure still fails the step. - Do not copy the Monaco ts, css and html workers or the translated nls files. The app uses JSON and YAML only. 14 MB to 5.7 MB. - Remove the dead src/environments/dev asset entry. - Remove the Playwright browser cache. A hit saved about 2 s. - e2e: drop the fixed 500 ms wait after each page load. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 24 +++++++++++++++--------- .github/workflows/test.yml | 21 +++------------------ e2e/src/pages/base.page.ts | 3 --- project.json | 13 +++++++------ 4 files changed, 25 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d4809a93..c8f7bebbc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,8 +19,12 @@ jobs: is_production: ${{ steps.build_ext.outputs.is_production }} steps: - uses: actions/checkout@v6 - with: - fetch-depth: 0 + + # The changelog needs the history and tags of this branch only. + # fetch-depth: 0 also downloads the build/* deploy branches (about + # 560 MB against 30 MB). + - name: Fetch branch history and tags + run: git fetch --unshallow --tags origin "$GITHUB_REF" - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -38,11 +42,6 @@ jobs: with: tool: git-cliff@2.13.1 - # Do not set GITHUB_REPO. cliff.toml uses no GitHub metadata, and - # the unauthenticated metadata requests hit the API rate limit. - - name: Generate application changelog - run: git-cliff --config cliff.toml --output "${RUNNER_TEMP}/CHANGELOG.md" - - name: Extract branch name id: branch_name shell: bash @@ -78,8 +77,15 @@ jobs: # 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 }} + # git-cliff runs alongside the build; `wait` fails the step if it fails. + # Do not set GITHUB_REPO. cliff.toml uses no GitHub metadata, and + # the unauthenticated metadata requests hit the API rate limit. + - name: Build application and changelog + run: | + git-cliff --config cliff.toml --output "${RUNNER_TEMP}/CHANGELOG.md" & + cliff_pid=$! + bun run build -- --verbose --configuration=${{ steps.build_ext.outputs.configuration }} + wait "$cliff_pid" - name: Add application changelog to build run: cp "${RUNNER_TEMP}/CHANGELOG.md" dist/backoffice/browser/CHANGELOG.md diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3989eafb6..a0597d1f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 3 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -82,26 +82,11 @@ jobs: - 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' }} - + # A browser cache saves no time here: a hit still installs the + # system packages, and a restore takes about as long as a download. - name: Install Playwright browsers - 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 ${{ env.CHROMIUM_ONLY == 'true' && '-- --project=chromium --project=mobile-chrome' || '' }} diff --git a/e2e/src/pages/base.page.ts b/e2e/src/pages/base.page.ts index 076a6b3bc..5fd963451 100644 --- a/e2e/src/pages/base.page.ts +++ b/e2e/src/pages/base.page.ts @@ -39,9 +39,6 @@ export abstract class BasePage { await this.page .waitForSelector('sidebar-menu, item-sidebar', { timeout: 30000 }) .catch(() => undefined); - - // Wait for any progress bars to complete - await this.page.waitForTimeout(500); } /** diff --git a/project.json b/project.json index 1eb407199..d63f45ec1 100644 --- a/project.json +++ b/project.json @@ -29,12 +29,13 @@ { "glob": "**/*", "input": "node_modules/monaco-editor/min/", - "output": "./assets/monaco/min/" - }, - { - "input": "src/environments/dev", - "output": "/assets", - "glob": "*.js" + "output": "./assets/monaco/min/", + "ignore": [ + "**/ts.worker-*.js", + "**/css.worker-*.js", + "**/html.worker-*.js", + "**/nls.messages.*.js" + ] }, { "input": "node_modules/ts-md5/dist/", From bd719828ce28d46ea942a60db11d49bd92a69a32 Mon Sep 17 00:00:00 2001 From: Alex Sorafumo Date: Wed, 21 Oct 2026 15:31:42 +1100 Subject: [PATCH 5/5] perf(ui): virtual rows for large lists and a lazy markdown renderer - Group membership tables (group users and zones, user groups, zone groups) use 72 px virtual rows. Each row computes its permission labels once. - The metadata history selects render 20 options and the selected one while closed. With 3000 versions, opening the modal no longer blocks for 125 ms. - Bulk add: validation and duplicate checks are computed from the list signal instead of running on every tick. The status list uses virtual rows and one signal per row instead of copying all statuses per update. - The markdown pipe loads marked and Shiki (67 KB gzip) on first use, so about pages without a description skip them. - Domain applications: a click anywhere no longer writes the secret state once per row. Co-Authored-By: Claude Opus 5.5 --- docs/bundle-size.md | 8 +- .../domains/domain-applications.component.ts | 2 + src/app/groups/group-users.component.ts | 12 +- src/app/groups/group-zones.component.ts | 12 +- .../bulk-item-modal.component.ts | 43 ++++--- .../bulk-item-modal/list.component.ts | 6 +- .../bulk-item-modal/status-list.component.ts | 110 +++++++++-------- .../metadata-history-modal.component.ts | 70 +++++++---- src/app/ui/pipes/markdown-render.ts | 105 ++++++++++++++++ src/app/ui/pipes/markdown.pipe.ts | 113 ++---------------- src/app/users/user-groups.component.ts | 12 +- src/app/zones/zone-groups.component.ts | 12 +- 12 files changed, 286 insertions(+), 219 deletions(-) create mode 100644 src/app/ui/pipes/markdown-render.ts diff --git a/docs/bundle-size.md b/docs/bundle-size.md index 0cf71cfa6..ad73ec9d3 100644 --- a/docs/bundle-size.md +++ b/docs/bundle-size.md @@ -39,10 +39,14 @@ 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. Shiki uses the JavaScript regex engine, so the -Oniguruma wasm is not downloaded. Add new language loaders there. Importing the +Add new language loaders there. Only `github-dark` is packaged. Shiki uses the +JavaScript regex engine, so the Oniguruma wasm is not downloaded. Importing the Shiki web preset at runtime also brings its unused theme registry into the build. +The markdown pipe loads `markdown-render.ts`, which holds marked and Shiki, on +first use. Pages without markdown do not download them. Do not import that file +statically. + ## Themes and caching `src/material-theme.scss` retains the indigo-pink palette and includes the Material diff --git a/src/app/domains/domain-applications.component.ts b/src/app/domains/domain-applications.component.ts index e4dc9e623..a10df3853 100644 --- a/src/app/domains/domain-applications.component.ts +++ b/src/app/domains/domain-applications.component.ts @@ -219,6 +219,8 @@ export class DomainApplicationsComponent { } public setShowSecret(id: string, value: boolean): void { + // Every row listens for window mouseup, so skip rows with no change + if (!!this.show_secret()[id] === value) return; this.show_secret.update((state) => ({ ...state, [id]: value })); } } diff --git a/src/app/groups/group-users.component.ts b/src/app/groups/group-users.component.ts index cd2592afb..87a80f437 100644 --- a/src/app/groups/group-users.component.ts +++ b/src/app/groups/group-users.component.ts @@ -37,14 +37,17 @@ import { GroupStateService } from './group-state.service'; {{ 'GROUPS.USERS_BULK' | translate }} -
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.NONE' | translate }} diff --git a/src/app/groups/group-zones.component.ts b/src/app/groups/group-zones.component.ts index 829bbb437..019c03947 100644 --- a/src/app/groups/group-zones.component.ts +++ b/src/app/groups/group-zones.component.ts @@ -37,14 +37,17 @@ import { GroupStateService } from './group-state.service'; {{ 'GROUPS.ZONES_BULK' | translate }}
-
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.DEFAULT' | translate }} diff --git a/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts b/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts index abb00f48d..2c53b8670 100644 --- a/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts +++ b/src/app/overlays/bulk-item-modal/bulk-item-modal.component.ts @@ -1,4 +1,4 @@ -import { Component, Type, inject, signal } from '@angular/core'; +import { Component, Type, computed, inject, signal } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogModule, @@ -100,7 +100,7 @@ export interface BulkItemModalData> { @case ('status') { @@ -110,8 +110,8 @@ export interface BulkItemModalData> { [(list)]="item_list" [fields]="available_fields" [required_fields]="required_fields" - [validation_errors]="validation_errors" - [duplicate_warnings]="duplicate_warnings" + [validation_errors]="validation_errors()" + [duplicate_warnings]="duplicate_warnings()" [show_start_modules]="is_system" (next)="showStatus()" (previous)="goTo('match-fields')" @@ -177,7 +177,7 @@ export class BulkItemModalComponent< { id: 'status', label: 'Create' }, ]; /** List of items to bulk add */ - public item_list: HashMap[] = []; + public readonly item_list = signal[]>([]); /** List of raw data to use for bulk add */ public data_list: HashMap[] = []; /** Whether requests are being processed */ @@ -207,13 +207,10 @@ export class BulkItemModalComponent< return this._required_fields; } - public get validation_errors(): string[] { - return this.validateItems(); - } - - public get duplicate_warnings(): string[] { - return this.findDuplicates(); - } + /** Missing required fields, checked again only when the list changes */ + public readonly validation_errors = computed(() => this.validateItems()); + /** Duplicate names or emails, checked again only when the list changes */ + public readonly duplicate_warnings = computed(() => this.findDuplicates()); constructor() { this.available_fields = this.getAvailableFields(); @@ -232,11 +229,13 @@ export class BulkItemModalComponent< } if (is_mapped) { const Resource = this._data.constr; - this.item_list = data.map((item) => { - // Values are already parsed by csvToJson/parseCSV, - // so no need for additional JSON.parse here - return new Resource(item); - }); + this.item_list.set( + data.map((item) => { + // Values are already parsed by csvToJson/parseCSV, + // so no need for additional JSON.parse here + return new Resource(item); + }), + ); this.goTo('list'); } else { // A new file may have different columns, so drop old mappings @@ -247,13 +246,11 @@ export class BulkItemModalComponent< } public showStatus() { - const validation_errors = this.validateItems(); - if (validation_errors.length > 0) { + if (this.validation_errors().length > 0) { notifyError('Fix the highlighted validation errors before saving.'); return; } - const duplicates = this.findDuplicates(); - if (duplicates.length > 0) { + if (this.duplicate_warnings().length > 0) { notifyWarn('Duplicate values were found. Saving will continue.'); } this.loading = true; @@ -338,7 +335,7 @@ export class BulkItemModalComponent< private validateItems(): string[] { const errors: string[] = []; - this.item_list.forEach((item, index) => { + this.item_list().forEach((item, index) => { for (const field of this._required_fields) { const value = item[field]; if (value === undefined || value === null || value === '') { @@ -357,7 +354,7 @@ export class BulkItemModalComponent< const dedup_field = this._data.constr === (PlaceUser as unknown) ? 'email' : 'name'; - this.item_list.forEach((item, index) => { + this.item_list().forEach((item, index) => { const key = String(item[dedup_field] || '') .toLowerCase() .trim(); diff --git a/src/app/overlays/bulk-item-modal/list.component.ts b/src/app/overlays/bulk-item-modal/list.component.ts index 0637da60e..728e6056e 100644 --- a/src/app/overlays/bulk-item-modal/list.component.ts +++ b/src/app/overlays/bulk-item-modal/list.component.ts @@ -108,7 +108,7 @@ import { TranslatePipe } from '../../ui/translate.pipe';
-
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.DEFAULT' | translate }} diff --git a/src/app/zones/zone-groups.component.ts b/src/app/zones/zone-groups.component.ts index a0f840774..6c43c4a2f 100644 --- a/src/app/zones/zone-groups.component.ts +++ b/src/app/zones/zone-groups.component.ts @@ -42,14 +42,17 @@ import { ZonesStateService } from './zones-state.service'; {{ 'ZONES.GROUPS_BULK' | translate }}
-
+
- @for (label of permissionLabels(row.permissions); track label) { + @let labels = permissionLabels(row.permissions); + @for (label of labels; track label) { {{ label | translate }} } - @if (!permissionLabels(row.permissions).length) { + @if (!labels.length) { {{ 'COMMON.DEFAULT' | translate }}