diff --git a/public/assets/locale/en-AU.json b/public/assets/locale/en-AU.json index 479f8f444..61e2f9773 100644 --- a/public/assets/locale/en-AU.json +++ b/public/assets/locale/en-AU.json @@ -1076,6 +1076,8 @@ "RESOURCE_IMPORTS_ALL_LOADING": "Importing missing resources...", "RESOURCE_IMPORTS_ALL_SUCCESS": "Successfully imported {{ count }} resources.", "RESOURCE_IMPORTS_SUCCESS": "Successfully imported resource {{ name }}.", + "RESOURCE_IMPORTS_ERROR": "Failed to import resource {{ name }}. Error: {{ error }}", + "RESOURCE_IMPORTS_ALL_ERROR": "Failed to import {{ count }} resources.", "EXTENSIONS_HEADER": "Extensions", "EXTENSIONS_ADD": "Add Extension", "EXTENSIONS_NEW": "New Extension", 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 587848bf2..e23433cbc 100644 --- a/src/app/admin/cluster-details/cluster-task-list.component.ts +++ b/src/app/admin/cluster-details/cluster-task-list.component.ts @@ -295,9 +295,10 @@ export class PlaceClusterTaskListComponent const clusters = await queryClusters({ q: id, } as Record).then((_) => _.data); - const match = clusters.find((_) => _.id === id) || clusters[0]; - console.log('Clusters:', clusters); + // The search may return other clusters, so only accept an exact id + const match = clusters.find((_) => _.id === id) || null; this.cluster.set(match); + if (!match) this.process_list.set([]); this.updateProcessList(); } diff --git a/src/app/admin/resource-imports.component.ts b/src/app/admin/resource-imports.component.ts index 2c13cff86..7915b7270 100644 --- a/src/app/admin/resource-imports.component.ts +++ b/src/app/admin/resource-imports.component.ts @@ -10,12 +10,20 @@ import { RouterModule } from '@angular/router'; import { addSystem, query, querySystemsWithEmails } from '@placeos/ts-client'; import { escapeHtml } from '../common/general'; import { i18n } from '../common/locale.service'; -import { notifySuccess, notifyWarn } from '../common/notifications'; +import { + notifyError, + notifySuccess, + notifyWarn, +} from '../common/notifications'; import { openConfirmModal } from '../overlays/confirm-modal.component'; import { IconComponent } from '../ui/icon.component'; import { SimpleTableComponent } from '../ui/simple-table.component'; import { TranslatePipe } from '../ui/translate.pipe'; import { AdminDataService } from './admin-data.service'; +import { forEachWithLimit } from './signage-plugins/signage-plugins.utilities'; + +/** Max number of systems created at once */ +const IMPORT_LIMIT = 5; export interface ExternalResource { id: string; @@ -227,38 +235,68 @@ export class ResourceImportsComponent implements OnInit { if (resp?.reason !== 'done') return; resp.loading(i18n('ADMIN.RESOURCE_IMPORTS_ALL_LOADING')); - try { - await Promise.all( - missing.map((_) => this.importResource(_, false)), + let failed = 0; + await forEachWithLimit(missing, IMPORT_LIMIT, async (resource) => { + if (!(await this.importResource(resource, false))) failed += 1; + }); + resp.close(); + if (failed) { + notifyError( + i18n('ADMIN.RESOURCE_IMPORTS_ALL_ERROR', { count: failed }), + ); + } + if (missing.length > failed) { + notifySuccess( + i18n('ADMIN.RESOURCE_IMPORTS_ALL_SUCCESS', { + count: missing.length - failed, + }), ); - } finally { - resp.close(); } - notifySuccess( - i18n('ADMIN.RESOURCE_IMPORTS_ALL_SUCCESS', { - count: missing.length, - }), - ); } - public async importResource(resource: ExternalResource, notify = true) { + /** + * Create a system for the resource. + * Returns whether the import succeeded. + */ + public async importResource( + resource: ExternalResource, + notify = true, + ): Promise { const domain = this.domain(); - if (!domain) return; + if (!domain) return false; const system = await addSystem({ name: `[${domain.name}] ${resource.display_name}`, email: resource.email, display_name: resource.display_name, capacity: resource.capacity, + }).catch((error) => { + if (notify) { + notifyError( + i18n('ADMIN.RESOURCE_IMPORTS_ERROR', { + name: resource.display_name, + error: error?.message || error, + }), + ); + } + return null; }); - if (!system) return; - resource.system_id = system.id; - resource.imported = true; - if (!notify) return; - notifySuccess( - i18n('ADMIN.RESOURCE_IMPORTS_SUCCESS', { - name: resource.display_name, - }), + if (!system) return false; + // Replace the row so the zoneless table updates + this.resource_list.update((list) => + list.map((item) => + item.email === resource.email + ? { ...item, system_id: system.id, imported: true } + : item, + ), ); + if (notify) { + notifySuccess( + i18n('ADMIN.RESOURCE_IMPORTS_SUCCESS', { + name: resource.display_name, + }), + ); + } + return true; } public async loadResourceList() { diff --git a/src/app/admin/staff-api.component.ts b/src/app/admin/staff-api.component.ts index ea96c8952..dadccc32b 100644 --- a/src/app/admin/staff-api.component.ts +++ b/src/app/admin/staff-api.component.ts @@ -8,7 +8,6 @@ import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatSelectModule } from '@angular/material/select'; import { MatTooltipModule } from '@angular/material/tooltip'; import { del, get, PlaceDomain } from '@placeos/ts-client'; -import { addDays, getUnixTime, startOfDay } from 'date-fns'; import { escapeHtml } from '../common/general'; import { notifyError, notifySuccess } from '../common/notifications'; import { HashMap } from '../common/types'; @@ -19,6 +18,7 @@ import { TranslatePipe } from '../ui/translate.pipe'; import { AdminDataService } from './admin-data.service'; import { BookingLimitsModalComponent } from './booking-limits-modal.component'; import { StaffTenantModalComponent } from './staff-tenant-modal.component'; +import { isSecretExpired, isSecretExpiring } from './staff-api.utilities'; export interface PlaceTenant { id: string; @@ -202,17 +202,9 @@ export class PlaceStaffAPIComponent implements OnInit { public readonly domain = this._admin_data.selectedDomain('staff-api'); public readonly tenants = signal([]); - public expiring(tenant: PlaceTenant): boolean { - const expiry = tenant.secret_expiry; - const after_time = getUnixTime(startOfDay(addDays(Date.now(), -30))); - return expiry && expiry >= after_time; - } - - public expired(tenant: PlaceTenant): boolean { - const expiry = tenant.secret_expiry; - const after_time = getUnixTime(Date.now()); - return expiry && expiry >= after_time; - } + public readonly expiring = (tenant: PlaceTenant) => + isSecretExpiring(tenant); + public readonly expired = (tenant: PlaceTenant) => isSecretExpired(tenant); public async ngOnInit() { this.loading.set('Loading domains...'); diff --git a/src/app/admin/staff-api.utilities.ts b/src/app/admin/staff-api.utilities.ts new file mode 100644 index 000000000..129f8b1e9 --- /dev/null +++ b/src/app/admin/staff-api.utilities.ts @@ -0,0 +1,53 @@ +import { addDays, format, getUnixTime } from 'date-fns'; +import type { BannerDetails } from '../ui/global-banner.component'; +import type { PlaceTenant } from './staff-api.component'; + +/** Days before expiry that a tenant secret counts as expiring */ +export const SECRET_EXPIRY_WARNING_DAYS = 30; + +/** Whether the tenant's secret has passed its expiry time */ +export function isSecretExpired( + tenant: Pick, + now = Date.now(), +): boolean { + const expiry = tenant.secret_expiry; + return !!expiry && expiry < getUnixTime(now); +} + +/** Whether the tenant's secret expires soon. Includes expired secrets. */ +export function isSecretExpiring( + tenant: Pick, + now = Date.now(), +): boolean { + const expiry = tenant.secret_expiry; + const warn_after = getUnixTime(addDays(now, SECRET_EXPIRY_WARNING_DAYS)); + return !!expiry && expiry < warn_after; +} + +/** + * Build one banner for all tenants with expiring secrets. + * Returns `null` when no secrets expire soon. + */ +export function tenantExpiryBanner( + tenants: PlaceTenant[], + now = Date.now(), +): BannerDetails | null { + const expiring = (tenants || []).filter((_) => isSecretExpiring(_, now)); + if (!expiring.length) return null; + const details = expiring.map( + (tenant) => + `"${tenant.name}" (${format( + tenant.secret_expiry * 1000, + "MMM do 'at' h:mma", + )})`, + ); + return { + id: `tenant_secret_expiry-${expiring + .map((_) => `${_.id}:${_.secret_expiry}`) + .join(',')}`, + type: expiring.some((_) => isSecretExpired(_, now)) ? 'error' : 'warn', + content: `Staff API tenant secrets expire soon or have expired: ${details.join( + ', ', + )}.`, + }; +} diff --git a/src/app/admin/staff-tenant-modal.component.ts b/src/app/admin/staff-tenant-modal.component.ts index 13f0ff76b..4a5b8e757 100644 --- a/src/app/admin/staff-tenant-modal.component.ts +++ b/src/app/admin/staff-tenant-modal.component.ts @@ -430,7 +430,11 @@ export class StaffTenantModalComponent { public readonly tenant = this._data.tenant; public readonly domain = this._data.domain; public readonly loading = signal(''); - public readonly show_outlook = model(false); + // The backend stores a cleared config as `{ app_id: '' }`, so check app_id + public readonly show_outlook = model( + !!((this.tenant || {}) as { outlook_config?: HashMap }).outlook_config + ?.app_id, + ); public readonly name_map = FIELD_NAME_MAPPING; public readonly outlook_fields = [ 'app_id', @@ -578,6 +582,9 @@ export class StaffTenantModalComponent { }, ['', null, undefined], ); + // staff-api ignores null, so send an empty config to clear it. + // It is stored as `{ app_id: '' }`, which reads as off. + if (!this.show_outlook()) data.outlook_config = {}; const call = this.tenant?.id ? put(`/api/staff/v1/tenants/${this.tenant.id}`, data) : post('/api/staff/v1/tenants', data); diff --git a/src/app/admin/upload-library.component.ts b/src/app/admin/upload-library.component.ts index 36cf4bc4a..7fa109f1d 100644 --- a/src/app/admin/upload-library.component.ts +++ b/src/app/admin/upload-library.component.ts @@ -84,6 +84,11 @@ export interface UploadInfo { uploaded_email: number; } +/** Interval between upload progress checks */ +const UPLOAD_POLL_MS = 250; +/** Stop checking upload progress after one hour */ +const MAX_UPLOAD_POLLS = (60 * 60 * 1000) / UPLOAD_POLL_MS; + @Component({ selector: 'upload-library', template: ` @@ -462,8 +467,16 @@ export class UploadLibraryComponent extends AsyncHandler implements OnInit { this._uploads.uploadFileWithPermissions(files[i]), ); } - const id_list = await Promise.all(uploads); + // Cancelling the permissions modal rejects that file only + const results = await Promise.allSettled(uploads); + const id_list = results + .filter((_) => _.status === 'fulfilled') + .map((_) => (_ as PromiseFulfilledResult).value); + if (!id_list.length) return; + this.loading.set(true); + let polls = 0; const checkUploads = () => { + polls += 1; const list = this._uploads.upload_list(); let success = 0; let failed = 0; @@ -474,15 +487,22 @@ export class UploadLibraryComponent extends AsyncHandler implements OnInit { if (upload.error) failed += 1; else if (upload.progress >= 100) success += 1; } - if (success + failed >= id_list.length) { + const finished = success + failed >= id_list.length; + if (finished || polls >= MAX_UPLOAD_POLLS) { if (failed) { notifyError('Failed to upload files.'); - } else if (success) { + } else if (success && finished) { notifySuccess('Succesfully uploaded files.'); } this.clearTimeout('upload_list'); + this.loading.set(false); + if (success) this.refresh.update((_) => _ + 1); } else { - this.timeout('upload_list', checkUploads, 250); + this.timeout( + 'upload_list', + checkUploads, + UPLOAD_POLL_MS, + ); } }; checkUploads(); diff --git a/src/app/app.ts b/src/app/app.ts index 15ad09416..2f153525f 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -1,13 +1,18 @@ -import { Component, inject, OnInit, signal } from '@angular/core'; +import { + Component, + computed, + DestroyRef, + inject, + OnInit, + signal, +} from '@angular/core'; import { MatSnackBar } from '@angular/material/snack-bar'; import { SwUpdate } from '@angular/service-worker'; -import { initUploads } from '@placeos/cloud-uploads'; import { get, invalidateToken, isMock, - isOnline, - token, + onlineState, } from '@placeos/ts-client'; import { @@ -16,26 +21,47 @@ import { Router, RouterOutlet, } from '@angular/router'; -import { addDays, format, getUnixTime } from 'date-fns'; import { setupCache, updateAvailable } from './common/application'; import { AsyncHandler } from './common/async-handler.class'; import { detectIE, log } from './common/general'; import { setNotifyOutlet } from './common/notifications'; import { PlaceSettings, setLoadingMessage, setupPlace } from './common/placeos'; import { SettingsService } from './common/settings.service'; -import { waitForSignalValue } from './common/signals'; +import { signalFromClient, waitForSignalValue } from './common/signals'; +import { syncUploadToken } from './common/uploads'; import { currentUser } from './common/user-state'; import { BackofficeUsersService } from './users/users.service'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { PlaceTenant } from './admin/staff-api.component'; -import { LocaleService, setTranslationService } from './common/locale.service'; +import { tenantExpiryBanner } from './admin/staff-api.utilities'; +import { + localeFromUrl, + LocaleService, + setTranslationService, +} from './common/locale.service'; import { GlobalBannerComponent } from './ui/global-banner.component'; import { GlobalLoadingComponent } from './ui/global-loading.component'; import { IconComponent } from './ui/icon.component'; import { UploadListComponent } from './ui/upload-list.component'; +/** Longest time start up waits for translations */ +const LOCALE_TIMEOUT_MS = 5000; + +/** Signal of the browser's network state. Call in an injection context. */ +function browserOnline() { + const state = signal(navigator.onLine); + const update = () => state.set(navigator.onLine); + window.addEventListener('online', update); + window.addEventListener('offline', update); + inject(DestroyRef).onDestroy(() => { + window.removeEventListener('online', update); + window.removeEventListener('offline', update); + }); + return state.asReadonly(); +} + @Component({ selector: 'placeos-root', template: ` @@ -68,7 +94,7 @@ import { UploadListComponent } from './ui/upload-list.component'; } - @if (!online && !loading()) { + @if (!online() && !loading()) {
@@ -143,9 +169,12 @@ export class AppComponent extends AsyncHandler implements OnInit { return this._users.dark_mode; } - public get online() { - return isOnline(); - } + private readonly _client_online = signalFromClient(onlineState()); + private readonly _browser_online = browserOnline(); + /** Whether PlaceOS is reachable. ts-client only flags auth failures, so also track the network. */ + public readonly online = computed( + () => this._client_online() && this._browser_online(), + ); public get is_fools_day(): boolean { return false; @@ -186,16 +215,12 @@ export class AppComponent extends AsyncHandler implements OnInit { this.timeout('wait_for_user', () => this.onInitError(), 30 * 1000); await waitForSignalValue(this._users.initialised, (_) => _); this.clearTimeout('wait_for_user'); + setLoadingMessage('Initialising locales...'); + // TranslatePipe is pure, so load translations before the shell renders + await this._initLocale(); this.loading.set(false); setLoadingMessage('Initialising upload service...'); - this.timeout('init_uploads', () => { - initUploads({ - auto_start: true, - token: token(), - endpoint: '/api/engine/v2/uploads', - worker_url: 'assets/md5_worker.js', - }); - }); + this.timeout('init_uploads', () => syncUploadToken()); // this.interval( // 'dark-mode', // () => @@ -212,8 +237,6 @@ export class AppComponent extends AsyncHandler implements OnInit { }); setLoadingMessage('Checking staff tenants...'); this._checkTenants(); - setLoadingMessage('Initialising locales...'); - this._initLocale(); } private onInitError() { @@ -224,45 +247,45 @@ export class AppComponent extends AsyncHandler implements OnInit { location.reload(); } + /** Show one banner for staff tenants with expiring secrets */ private async _checkTenants() { if (!currentUser()?.sys_admin) return; - const tenant_list: PlaceTenant[] = ( - await get('/api/staff/v1/tenants') - ).map((_) => Object.keys(_).map((i) => _[i] as PlaceTenant)); - for (const tenant of tenant_list) { - if (!tenant.secret_expiry) continue; - if (tenant.secret_expiry > getUnixTime(addDays(Date.now(), -30))) { - this._settings.post('banner', { - id: `tenant_secret_expiry-${tenant.id}`, - type: 'warn', - content: `Staff API Tenant "${ - tenant.name - }" has a secret that will expire on ${format( - tenant.secret_expiry * 1000, - "MMM do 'at' h:mma", - )}.`, - }); - } - } + const tenants = await get('/api/staff/v1/tenants').catch(() => []); + const banner = tenantExpiryBanner( + Array.isArray(tenants) ? (tenants as PlaceTenant[]) : [], + ); + if (banner) this._settings.post('banner', banner); } - private _initLocale() { + /** + * Set the locale from the URL `lang` param, storage or the browser languages. + * Resolves when translations load, or after a timeout. + */ + private async _initLocale() { + let load: Promise | undefined; try { + // Router query params are not ready yet, so read lang from the URL + const url_locale = localeFromUrl(location.search, location.hash); + if (url_locale) { + localStorage.setItem('BACKOFFICE.locale', url_locale); + } let locale = localStorage.getItem('BACKOFFICE.locale'); const locales = (this._settings.get('app.locales') as { id: string; name: string; }[]) || [{ id: 'en', name: 'English' }]; if (locale) { - this._locale?.setLocale(locale); + load = this._locale?.setLocale(locale); } else { - const list = navigator.languages; + const list = navigator.languages || []; for (const lang of list) { locale = locales.find((_) => _.id === lang)?.id; if (!locale) locale = locales.find((_) => lang.includes(_.id))?.id; + // Load the full browser tag (e.g. en-US), not the matched id, + // as locale files use full tags. if (locale) { - this._locale?.setLocale(lang); + load = this._locale?.setLocale(lang); localStorage.setItem('BACKOFFICE.locale', lang); break; } @@ -271,5 +294,11 @@ export class AppComponent extends AsyncHandler implements OnInit { } catch { // Ignore locale parsing errors } + if (!load) return; + // Do not block start up on a slow or failed locale file + await Promise.race([ + load.catch(() => undefined), + new Promise((resolve) => setTimeout(resolve, LOCALE_TIMEOUT_MS)), + ]); } } diff --git a/src/app/common/locale.service.ts b/src/app/common/locale.service.ts index b6d3b75ca..ea19bfb74 100644 --- a/src/app/common/locale.service.ts +++ b/src/app/common/locale.service.ts @@ -20,6 +20,19 @@ export function i18n( return _service.get(key, args, plural); } +/** + * Read the `lang` query param from a URL's search and hash parts. + * The hash query wins, as the router uses hash locations. + */ +export function localeFromUrl(search: string, hash: string): string | null { + const hash_query = hash.includes('?') ? hash.slice(hash.indexOf('?')) : ''; + return ( + new URLSearchParams(hash_query).get('lang') || + new URLSearchParams(search).get('lang') || + null + ); +} + declare global { interface Window { i18n: (key: string, args: Record) => string; @@ -166,7 +179,11 @@ export class LocaleService { return this._current_locale_short; } - public setLocale(locale: string) { + /** + * Set the active locale. + * Resolves when the locale mappings have loaded. + */ + public setLocale(locale: string): Promise { this._current_locale = locale; this._current_locale_short = this._current_locale.split('-')[0]; if (!this._locale_mappings[locale] && !this._load_promises[locale]) { @@ -174,6 +191,7 @@ export class LocaleService { } localStorage.setItem(`${STORE_KEY}`, locale); log('LOCALE', `Locale set to "${locale}"`); + return this._load_promises[locale] || Promise.resolve(); } private async _loadLocale(locale: string) { diff --git a/src/app/common/settings.service.ts b/src/app/common/settings.service.ts index b21f3587e..0907ebaf0 100644 --- a/src/app/common/settings.service.ts +++ b/src/app/common/settings.service.ts @@ -60,8 +60,9 @@ export class SettingsService extends AsyncHandler { (this._signals[name] as WritableSignal).set(value); } + /** Read the value for key. Tracks the key even before it is posted. */ public value(name: string): T { - return !this._signals[name] ? null : (this._signals[name]() as T); + return this.listen(name)(); } /** Page title */ diff --git a/src/app/common/support-access.ts b/src/app/common/support-access.ts index 97b31aeae..4b9d08782 100644 --- a/src/app/common/support-access.ts +++ b/src/app/common/support-access.ts @@ -77,7 +77,8 @@ export function canAccessSection(section: string) { return false; } -async function collectPages(response: QueryResponse) { +/** Load every page of a query. Throws after 1000 extra pages. */ +export async function collectPages(response: QueryResponse) { let page = await response; const data = [...page.data]; for (let i = 0; page.next && i < 1000; i++) { diff --git a/src/app/common/uploads.ts b/src/app/common/uploads.ts index ad4b9ebe3..c4b500bb3 100644 --- a/src/app/common/uploads.ts +++ b/src/app/common/uploads.ts @@ -1,13 +1,35 @@ import { humanReadableByteCount, + initUploads, uploadFile as uploadNewFile, } from '@placeos/cloud-uploads'; +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 = ''; + +/** + * Initialise the upload service with the current PlaceOS token. + * Call before each upload. The upload library has no token setter, so it is + * initialised again only when the token has changed since the last call. + */ +export function syncUploadToken() { + const current_token = token(); + if (!current_token || current_token === upload_token) return; + upload_token = current_token; + initUploads({ + auto_start: true, + token: current_token, + endpoint: '/api/engine/v2/uploads', + worker_url: 'assets/md5_worker.js', + }); +} + function uploadURL(id: string) { return `${location.origin}/api/engine/v2/uploads/${encodeURIComponent( id, @@ -66,6 +88,7 @@ export function uploadFile( arrayBuffer as ArrayBuffer, file.type, ); + syncUploadToken(); const upload = await uploadNewFile(file, { permissions, public: is_public, diff --git a/src/app/drivers/driver-docs.component.ts b/src/app/drivers/driver-docs.component.ts index 7d675a3ea..88658e5e6 100644 --- a/src/app/drivers/driver-docs.component.ts +++ b/src/app/drivers/driver-docs.component.ts @@ -1,8 +1,7 @@ import { AsyncPipe } from '@angular/common'; -import { Component, inject, OnInit } from '@angular/core'; +import { Component, inject } from '@angular/core'; import { DriverStateService } from './driver-state.service'; -import { Router } from '@angular/router'; import { IconComponent } from '../ui/icon.component'; import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; @@ -16,7 +15,7 @@ import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; class="markdown items-start" [innerHTML]="docs_string | markdown | async" >
- } @else { + } @else if (!docs_loading()) {
@@ -29,19 +28,10 @@ import { MarkdownPipe } from '../ui/pipes/markdown.pipe'; styles: [``], imports: [MarkdownPipe, IconComponent, AsyncPipe], }) -export class DriverDocsComponent implements OnInit { +export class DriverDocsComponent { private _service = inject(DriverStateService); - private _router = inject(Router); public readonly docs = this._service.docs; - - public async ngOnInit() { - const str = this.docs(); - if (str) return; - this._router.navigate([ - '/drivers', - this._service.active_item.id, - 'about', - ]); - } + /** Hide the empty state until the readme request settles */ + public readonly docs_loading = this._service.docs_loading; } diff --git a/src/app/drivers/driver-form.component.ts b/src/app/drivers/driver-form.component.ts index 53e2f1075..9e62fa054 100644 --- a/src/app/drivers/driver-form.component.ts +++ b/src/app/drivers/driver-form.component.ts @@ -548,24 +548,37 @@ export class DriverFormComponent extends AsyncHandler implements OnInit { this.loading.set(''); } + /** + * Load the repository, driver and commit for an existing driver. + * For a new driver, pre-select the repository and driver if given. + */ private async _loadDetailsFromForm() { const { id, commit, file_name, repository_id } = this.formModel(); - if (!id) return; + if (!repository_id) return; this.loading.set('DRIVERS.DETAILS_LOADING'); - const repo = await showRepository(repository_id); - const driver = { - id: file_name, - name: file_name.replace(/\//g, ' > '), - }; - this.repo.set(repo); - this.driver.set(driver); - const commit_list = await this._loadCommitList( - repository_id, - file_name, - ); - const active_commit = commit_list.find((c) => c.id === commit); - if (active_commit) this.commit.set(active_commit); - this.loading.set(''); + try { + const repo = await showRepository(repository_id); + this.repo.set(repo); + if (!file_name) return; + this.driver.set({ + id: file_name, + name: file_name.replace(/\//g, ' > '), + }); + if (!id) return; + const commit_list = await this._loadCommitList( + repository_id, + file_name, + ); + // Commit may be older than the fetched list + const active_commit = commit_list.find((c) => c.id === commit) || { + id: commit, + name: commit, + extra: null, + }; + this.commit.set(active_commit); + } finally { + this.loading.set(''); + } } private async newSettings(item: Identity, settings_string: string) { diff --git a/src/app/drivers/driver-state.service.ts b/src/app/drivers/driver-state.service.ts index f352c50bf..29143f8ee 100644 --- a/src/app/drivers/driver-state.service.ts +++ b/src/app/drivers/driver-state.service.ts @@ -8,7 +8,7 @@ import { queryModules, recompileDriver, reloadDriver, - removeSystemModule, + removeModule, updateDriver, } from '@placeos/ts-client'; import { ActiveItemService } from '../common/item.service'; @@ -26,12 +26,15 @@ export class DriverStateService { private _loading = signal(false); private _last_error = signal(null); private _poll = signal(0); + private _modules_change = signal(0); public readonly item = computed( () => this._state.item() as unknown as PlaceDriver, ); public readonly loading = this._loading.asReadonly(); + /** Bumped each time a module of the driver is removed */ + public readonly modules_change = this._modules_change.asReadonly(); private readonly _updates_available = resource({ params: () => this._poll(), @@ -40,7 +43,7 @@ export class DriverStateService { update_available: true, limit: 1, }).catch(() => ({ total: 0 })); - return response.total > 1; + return response.total > 0; }, }); @@ -49,8 +52,8 @@ export class DriverStateService { ); private readonly _modules = resource({ - params: () => this.item(), - loader: async ({ params: item }) => { + params: () => ({ item: this.item(), changed: this._modules_change() }), + loader: async ({ params: { item } }) => { if (!(item instanceof PlaceDriver)) return [] as PlaceModule[]; this._loading.set(true); try { @@ -80,6 +83,7 @@ export class DriverStateService { }); public readonly docs = computed(() => this._docs.value() || ''); + public readonly docs_loading = computed(() => this._docs.isLoading()); public get active_item() { return this._state.active_item; @@ -113,12 +117,15 @@ export class DriverStateService { ); if (details.reason !== 'done') return details.close(); details.loading('Updating driver...'); - const success = await updateDriver(item.id, { + const updated = await updateDriver(item.id, { ...item, commit: item.update_info.commit, }).catch(() => null); - if (!success) { + if (!updated) { notifyError('Failed to update driver.'); + } else { + this._state.replaceItem(updated as unknown as Identity); + this._poll.update((poll) => poll + 1); } details.close(); } @@ -177,29 +184,29 @@ export class DriverStateService { const details = await openConfirmModal( { title: 'Remove module?', - content: `Remove ${device.driver_id}?
`, + content: `Remove ${device.custom_name || device.name || device.id}?
`, extra: [ 'error', - 'Note that all associated data be deleted immediatedly.', + 'Note that all associated data be deleted immediately.', ], icon: { type: 'icon', content: 'delete' }, }, this._dialog, ); if (details.reason !== 'done') return; - const system = await removeSystemModule( - this.active_item.id, - device.id, - ).catch((err) => { - notifyError( - `Error removing module ${device.id}. Error: ${ - err.statusText || err.message || err - }`, - ); - }); + const removed = await removeModule(device.id) + .then(() => true) + .catch((err) => { + notifyError( + `Error removing module ${device.id}. Error: ${ + err.statusText || err.message || err + }`, + ); + return false; + }); details.close(); - if (!system) return; - this._state.replaceItem(system as unknown as Identity); + if (!removed) return; + this._modules_change.update((change) => change + 1); notifySuccess(`Successfully removed module.`); } } diff --git a/src/app/drivers/driver-update-list-modal.component.ts b/src/app/drivers/driver-update-list-modal.component.ts index d61bba91f..e2bfb4ac1 100644 --- a/src/app/drivers/driver-update-list-modal.component.ts +++ b/src/app/drivers/driver-update-list-modal.component.ts @@ -180,6 +180,8 @@ export class DriverUpdateListModalComponent { response.data = response.data.sort((a, b) => a.name.localeCompare(b.name), ); + // Keep total in sync with the filtered list for select all + response.total = response.data.length; this.selected_drivers.set(response.data.map((d) => d.id)); this.loading.set(''); return response; diff --git a/src/app/drivers/drivers.component.ts b/src/app/drivers/drivers.component.ts index 314371e34..9ce319d42 100644 --- a/src/app/drivers/drivers.component.ts +++ b/src/app/drivers/drivers.component.ts @@ -173,6 +173,8 @@ export class DriversComponent { 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); }); diff --git a/src/app/groups/group-form.component.ts b/src/app/groups/group-form.component.ts index 213b622c9..dec6d4e3c 100644 --- a/src/app/groups/group-form.component.ts +++ b/src/app/groups/group-form.component.ts @@ -10,7 +10,7 @@ import { signal, } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { form, FormField, submit } from '@angular/forms/signals'; +import { disabled, form, FormField, submit } from '@angular/forms/signals'; import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; @@ -252,7 +252,11 @@ export class GroupFormComponent extends AsyncHandler implements OnInit { @Output() public event = new EventEmitter(); public readonly formModel = signal(generateGroupFormModel(this._data.item)); - public readonly form = form(this.formModel, applyGroupFormSchema); + public readonly form = form(this.formModel, (path) => { + applyGroupFormSchema(path); + // Domain is fixed once the group exists, matching the user form + disabled(path.authority_id, () => !!this._data.item.id); + }); public readonly loading = signal(null); public heading = i18n( `${this._name}.${this._data.item.id ? 'EDIT' : 'NEW'}`, diff --git a/src/app/modules/module-form.component.ts b/src/app/modules/module-form.component.ts index 5f10e016e..49505aec3 100644 --- a/src/app/modules/module-form.component.ts +++ b/src/app/modules/module-form.component.ts @@ -7,6 +7,7 @@ import { OnInit, Output, signal, + untracked, } from '@angular/core'; import { form, FormField, submit } from '@angular/forms/signals'; import { @@ -356,6 +357,12 @@ 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, + ); + /** ID of the driver whose defaults have been applied to the form */ + private _defaults_driver_id = ''; public heading = i18n( `${this._name}.${this._data.item.id ? 'EDIT' : 'NEW'}`, ); @@ -443,47 +450,36 @@ export class ModuleFormComponent extends AsyncHandler implements OnInit { })); } }); + // Apply driver defaults once per selected driver so later edits stick effect(() => { - const model = this.formModel(); - const driver = model.driver; - if (!driver?.id) return; - const role = driver.role ?? PlaceDriverRole.Logic; - const udp = - driver.role === PlaceDriverRole.Service || - driver.role === PlaceDriverRole.Websocket - ? false - : model.udp; - const system = - driver.role === PlaceDriverRole.Logic - ? model.system - : undefined; - if ( - model.driver_id === driver.id && - model.name === (driver.name || driver.module_name) && - model.uri === (driver.default_uri || '') && - model.port === (driver.default_port || 1) && - model.alert_level === (driver.alert_level || 'medium') && - model.role === role && - model.udp === udp && - model.system === system - ) { - return; - } - this.formModel.update((value) => ({ - ...value, - driver_id: driver.id, - name: driver.name || driver.module_name, - uri: driver.default_uri || '', - port: driver.default_port || 1, - alert_level: driver.alert_level || 'medium', - role, - udp, - system, - })); + const driver = this._selected_driver(); + if (!driver?.id || driver.id === this._defaults_driver_id) return; + this._defaults_driver_id = driver.id; + untracked(() => + this.formModel.update((value) => ({ + ...value, + driver_id: driver.id, + name: driver.name || driver.module_name, + uri: driver.default_uri || '', + port: driver.default_port || 1, + alert_level: driver.alert_level || 'medium', + role: driver.role ?? PlaceDriverRole.Logic, + udp: + driver.role === PlaceDriverRole.Service || + driver.role === PlaceDriverRole.Websocket + ? false + : value.udp, + system: + driver.role === PlaceDriverRole.Logic + ? value.system + : undefined, + })), + ); }); } public async submit(): Promise { + if (this.loading()) return; await submit(this.form, async () => { const item = this._data.item; this.loading.set(i18n(`${this._name}.SAVING`)); diff --git a/src/app/modules/module-state.service.ts b/src/app/modules/module-state.service.ts index a902bc06e..95517bf29 100644 --- a/src/app/modules/module-state.service.ts +++ b/src/app/modules/module-state.service.ts @@ -94,8 +94,11 @@ export class ModuleStateService { } public async toggleModuleState() { - const method = this.active_item.running ? stopModule : startModule; - const error = await method(this.active_item.id) + // Capture before the await as the active module may change + const item = this.active_item as PlaceModule & { running: boolean }; + const running = item.running; + const method = running ? stopModule : startModule; + const error = await method(item.id) .then(() => null) .catch((err) => err); if (error) { @@ -103,9 +106,9 @@ export class ModuleStateService { notifyError(error); } else { notifyError( - `Failed to ${ - this.active_item.running ? 'stop' : 'start' - } device '${this.active_item.id}'.\nView Error?`, + `Failed to ${running ? 'stop' : 'start'} device '${ + item.id + }'.\nView Error?`, 'View', () => this.viewDetails(error), ); @@ -113,12 +116,9 @@ export class ModuleStateService { return; } notifySuccess( - `Module successfully ${ - this.active_item.running ? 'stopped' : 'started' - }`, + `Module successfully ${running ? 'stopped' : 'started'}`, ); - (this.active_item as PlaceModule & { running: boolean }).running = - !this.active_item.running; + item.running = !running; } /** View Results of the execute */ diff --git a/src/app/modules/module-systems.component.ts b/src/app/modules/module-systems.component.ts index 5aaadd622..6813b66ee 100644 --- a/src/app/modules/module-systems.component.ts +++ b/src/app/modules/module-systems.component.ts @@ -46,7 +46,7 @@ import { ModuleStateService } from './module-state.service'; content: name_template, }, { - key: 'installed_ui_devices', + key: 'module_count', name: 'MODULES.SYSTEMS_FIELD_MODULE_COUNT' | translate, @@ -117,7 +117,10 @@ export class ModuleSystemsComponent { public readonly system_list = computed(() => { const filter = this.filter().toLowerCase(); - const systems = this.systems(); + const systems = this.systems().map((sys) => ({ + ...sys, + module_count: sys.modules?.length || 0, + })); return filter ? systems.filter((sys) => sys.name.toLowerCase().includes(filter)) : systems; diff --git a/src/app/modules/modules.component.ts b/src/app/modules/modules.component.ts index e0ce1add7..9e8319ab5 100644 --- a/src/app/modules/modules.component.ts +++ b/src/app/modules/modules.component.ts @@ -1,4 +1,5 @@ import { Component, computed, effect, inject, signal } from '@angular/core'; +import { MatRippleModule } from '@angular/material/core'; import { MatTooltipModule } from '@angular/material/tooltip'; import { RouterModule } from '@angular/router'; import { PlaceModule } from '@placeos/ts-client'; @@ -98,7 +99,7 @@ import { TranslatePipe } from '../ui/translate.pipe'; imports: [ DebugOutputComponent, IconComponent, - IconComponent, + MatRippleModule, TranslatePipe, MatTooltipModule, RouterModule, diff --git a/src/app/overlays/auth-source-modal.component.ts b/src/app/overlays/auth-source-modal.component.ts index 5109506e6..f56fc1b6a 100644 --- a/src/app/overlays/auth-source-modal.component.ts +++ b/src/app/overlays/auth-source-modal.component.ts @@ -62,6 +62,7 @@ export type AuthSourceTypes = 'oauth' | 'saml' | 'ldap'; ) | translate " [loading]="loading()" + [disable_confirm]="!item()" (save)="save()" > @if (is_new) { @@ -224,6 +225,8 @@ export class AuthSourceModalComponent extends AsyncHandler implements OnInit { * Create item if new or update if exsiting */ public async save() { + // Nothing to save until a source type is chosen + if (!this.item()) return; if (!(await this.submitActiveForm())) { return; } 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 b57a7f277..abb00f48d 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 @@ -239,6 +239,8 @@ export class BulkItemModalComponent< }); this.goTo('list'); } else { + // A new file may have different columns, so drop old mappings + this.mappings = {}; this.data_list = data; this.goTo('match-fields'); } @@ -255,11 +257,14 @@ export class BulkItemModalComponent< notifyWarn('Duplicate values were found. Saving will continue.'); } this.loading = true; + // Keep the dialog open while items are being created + this._dialog_ref.disableClose = true; this.goTo('status'); } public done() { this.loading = false; + this._dialog_ref.disableClose = false; } public close() { diff --git a/src/app/overlays/bulk-item-modal/match-fields.component.ts b/src/app/overlays/bulk-item-modal/match-fields.component.ts index 9ca904244..544769733 100644 --- a/src/app/overlays/bulk-item-modal/match-fields.component.ts +++ b/src/app/overlays/bulk-item-modal/match-fields.component.ts @@ -1,5 +1,6 @@ import { Component, + computed, input, model, OnChanges, @@ -42,7 +43,7 @@ import { TranslatePipe } from '../../ui/translate.pipe'; Matched fields
- {{ mappedFieldCount() }} / {{ field_list().length }} + {{ mapped_field_count() }} / {{ field_list().length }}
@@ -138,6 +139,8 @@ export class MatchFieldsComponent implements OnChanges, OnInit { const mappings = this.mappings(); const list = this.list(); if (changes.list && list && list.length) { + // Mappings from a previous file do not apply to a new one + this.field_mapping.set({}); this.source_fields.set( Object.keys(list[0]).map((i) => ({ id: i, @@ -184,11 +187,11 @@ export class MatchFieldsComponent implements OnChanges, OnInit { })); } - public mappedFieldCount(): number { - return this.field_list().filter( - (field) => !!this.field_mapping[field.id], - ).length; - } + public readonly mapped_field_count = computed(() => { + const mapping = this.field_mapping(); + return this.field_list().filter((field) => !!mapping[field.id]) + .length; + }); public isRequired(field: unknown): boolean { return this.required_fields().includes(`${field}`); diff --git a/src/app/overlays/duplicate-modal.component.ts b/src/app/overlays/duplicate-modal.component.ts index e3561461e..e006d8772 100644 --- a/src/app/overlays/duplicate-modal.component.ts +++ b/src/app/overlays/duplicate-modal.component.ts @@ -9,6 +9,7 @@ import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef, + MatDialogState, } from '@angular/material/dialog'; import { FormsModule } from '@angular/forms'; @@ -43,6 +44,9 @@ export interface DuplicateModalData { matInput name="times" type="number" + min="1" + [max]="MAX_TIMES" + step="1" [(ngModel)]="times" placeholder="Number of duplications" required @@ -55,7 +59,7 @@ export interface DuplicateModalData { @if (!done()) {
Creating item duplicates...
} - @for (itm of temp(); track itm.id; let i = $index) { + @for (itm of temp(); track $index; let i = $index) {
{{ item.name }} ({{ i + 1 }}) @@ -85,7 +89,7 @@ export interface DuplicateModalData { @@ -100,9 +97,8 @@ import { RepositoriesStateService } from './repositories-state.service'; MatRippleModule, ], }) -export class RepositoryDriversComponent extends AsyncHandler implements OnInit { +export class RepositoryDriversComponent { private _service = inject(RepositoriesStateService); - private _router = inject(Router); /** Whether driver list is loading */ public readonly loading = this._service.loading; @@ -110,29 +106,6 @@ export class RepositoryDriversComponent extends AsyncHandler implements OnInit { public readonly driver_list = this._service.driver_list; public readonly driver_list_error = this._service.driver_list_error; - public get item(): PlaceRepository { - return this._service.active_item as PlaceRepository; - } - - public readonly newDriver = (d) => this._service.newDriver(d); - - constructor() { - super(); - effect(() => { - if (this.driver_list()?.length || this.driver_list_error()) { - this.clearTimeout('has_drivers'); - } - }); - } - - public ngOnInit() { - this.timeout( - 'has_drivers', - () => this._router.navigate(['/repositories', this.item.id]), - 3000, - ); - if (this.driver_list()?.length || this.driver_list_error()) { - this.clearTimeout('has_drivers'); - } - } + public readonly newDriver = (driver: string) => + this._service.newDriver(driver); } diff --git a/src/app/repositories/repository-form.component.ts b/src/app/repositories/repository-form.component.ts index 13f26fe73..4b9b13a09 100644 --- a/src/app/repositories/repository-form.component.ts +++ b/src/app/repositories/repository-form.component.ts @@ -612,6 +612,11 @@ export class RepositoryFormComponent extends AsyncHandler implements OnInit { public async submit(): Promise { await submit(this.form, async () => { + // A disabled branch field skips validation, e.g. when branches fail to load + if (!this.formModel().branch) { + notifyError(i18n(`${this._name}.BRANCH_REQUIRED`)); + return; + } const item = this._data.item; this.saving.set(i18n(`${this._name}.SAVING`)); this._dialog_ref.disableClose = true; diff --git a/src/app/systems/system-about.component.ts b/src/app/systems/system-about.component.ts index b6fa10308..332ce69ef 100644 --- a/src/app/systems/system-about.component.ts +++ b/src/app/systems/system-about.component.ts @@ -52,7 +52,7 @@ import { SystemStateService } from './system-state.service'; {{ 'COMMON.TRUE' | translate }}
} - @if (item()?.email) { + @if (item()?.public !== undefined) {
{{ 'SYSTEMS.PUBLIC' | translate }}
diff --git a/src/app/systems/system-form.component.ts b/src/app/systems/system-form.component.ts index 50fdab4d2..6f3dc4ff5 100644 --- a/src/app/systems/system-form.component.ts +++ b/src/app/systems/system-form.component.ts @@ -667,6 +667,7 @@ export class SystemFormComponent extends AsyncHandler implements OnInit { } public async submit(): Promise { + if (this.loading()) return; await submit(this.form, async () => { const item = this._data.item as unknown as PlaceResource; this.loading.set(i18n(`${this._name}.SAVING`)); diff --git a/src/app/systems/system-modules.component.ts b/src/app/systems/system-modules.component.ts index 60f840ff6..e86a9b925 100644 --- a/src/app/systems/system-modules.component.ts +++ b/src/app/systems/system-modules.component.ts @@ -1,4 +1,11 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatRippleModule } from '@angular/material/core'; @@ -393,6 +400,7 @@ export class SystemModulesComponent extends AsyncHandler { public readonly item_signal = computed( () => this._service.item() as PlaceSystem, ); + private readonly _item_id = computed(() => this.item_signal()?.id); public readonly loading = this._service.loading; public readonly modules = this._service.modules; public readonly modules_refresh = computed(() => { @@ -498,10 +506,10 @@ export class SystemModulesComponent extends AsyncHandler { ); /** Function for excluding modules already within this system */ public readonly exclude_fn = ( - item: PlaceModule & { control_system_id?: string; role?: number }, + item: PlaceModule & { role?: number }, __: string, ) => - item.control_system_id === this.item.id || + !!this.item.modules?.includes(item.id) || item.role === PlaceDriverRole.Logic; public readonly newModule = () => this._service.newModule(); @@ -530,6 +538,15 @@ export class SystemModulesComponent extends AsyncHandler { return `${i18n('DRIVERS.UNKNOWN')} (${role})`; } + constructor() { + super(); + // Tab is reused across systems. Drop the pending module on change. + effect(() => { + this._item_id(); + untracked(() => this.new_module.set('')); + }); + } + public get item(): PlaceSystem { return this._service.active_item as PlaceSystem; } diff --git a/src/app/systems/system-state.service.ts b/src/app/systems/system-state.service.ts index e61720c39..f05a38a5c 100644 --- a/src/app/systems/system-state.service.ts +++ b/src/app/systems/system-state.service.ts @@ -664,7 +664,7 @@ export class SystemStateService extends AsyncHandler { public async removeModule(device: PlaceModule) { const details = await this.confirm({ title: 'Remove module?', - content: `Remove ${device.driver_id} from this system?
If this is not used elsewhere the associated data will be removed immediately.`, + content: `Remove ${device.custom_name || device.name || device.id} from this system?
If this is not used elsewhere the associated data will be removed immediately.`, icon: { type: 'icon', content: 'delete' }, }); if (details.reason !== 'done') return; diff --git a/src/app/systems/system-trigger-form.component.ts b/src/app/systems/system-trigger-form.component.ts index ffbbf4271..65c8b756f 100644 --- a/src/app/systems/system-trigger-form.component.ts +++ b/src/app/systems/system-trigger-form.component.ts @@ -205,7 +205,7 @@ export class SystemTriggerFormComponent extends AsyncHandler implements OnInit { ); public readonly form = form(this.formModel); public loading: string; - public heading = i18n(`Trigger.${this._data.item.id ? 'EDIT' : 'NEW'}`); + public heading = i18n(`TRIGGERS.${this._data.item.id ? 'EDIT' : 'NEW'}`); public readonly trigger_state = this.formModel.asReadonly(); /** Playlist names keyed by playlist ID. IDs without a name show the ID. */ @@ -244,6 +244,8 @@ export class SystemTriggerFormComponent extends AsyncHandler implements OnInit { } public async submit(): Promise { + // Caller sets `loading` while it saves. Ignore repeat submits. + if (this.loading) return; await submit(this.form, async () => undefined); if (this.form().invalid()) { return notifyError( diff --git a/src/app/systems/system-triggers.component.ts b/src/app/systems/system-triggers.component.ts index ee8480683..ced168383 100644 --- a/src/app/systems/system-triggers.component.ts +++ b/src/app/systems/system-triggers.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, model, signal } from '@angular/core'; +import { Component, computed, inject, model } from '@angular/core'; import { PlaceTrigger } from '@placeos/ts-client'; import { copyToClipboard, unique } from '../common/general'; import { notifyInfo } from '../common/notifications'; @@ -229,13 +229,9 @@ export class SystemTriggersComponent { public readonly comparisons: HashMap = {}; - public readonly temp_trigger = signal(null); - public readonly triggers = computed(() => { const filter = this.filter().toLowerCase(); - const temp = this.temp_trigger(); - const triggers = this.service_triggers(); - const list = unique(temp ? [...triggers, temp] : triggers, 'id'); + const list = unique(this.service_triggers(), 'id'); return filter ? list.filter((t) => (t as PlaceTrigger & { name: string }).name @@ -251,13 +247,10 @@ export class SystemTriggersComponent { ); notifyInfo(i18n('SYSTEMS.COPIED_WEBHOOK')); }; - public readonly editTrigger = async (t) => - this.temp_trigger.set( - (await this._service.editTrigger(t)) as PlaceTrigger, - ); + // The service reloads the trigger list after each change + public readonly editTrigger = (t) => this._service.editTrigger(t); public readonly deleteTrigger = (t) => this._service.removeTrigger(t); - public readonly selectTrigger = async () => - this.temp_trigger.set((await this._service.selectTrigger()) || null); + public readonly selectTrigger = () => this._service.selectTrigger(); public get item() { return this._service.active_item; diff --git a/src/app/systems/system-zones.component.ts b/src/app/systems/system-zones.component.ts index 957b1355a..c12a793ea 100644 --- a/src/app/systems/system-zones.component.ts +++ b/src/app/systems/system-zones.component.ts @@ -1,4 +1,11 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { + Component, + computed, + effect, + inject, + signal, + untracked, +} from '@angular/core'; import { PlaceSystem, PlaceZone } from '@placeos/ts-client'; import { querySupportZones as queryZones } from '../common/support-access'; @@ -177,13 +184,14 @@ import { SystemStateService } from './system-state.service'; export class SystemZonesComponent { private _service = inject(SystemStateService); - public order_changed = false; + public readonly order_changed = signal(false); public show_original = false; public readonly original_zones = this._service.zones; public readonly item_signal = this._service.item; + private readonly _item_id = computed(() => this.item_signal()?.id); public changed: Record = {}; /** ID of a zone that the user wishes to add to the system */ @@ -212,7 +220,7 @@ export class SystemZonesComponent { this.changed[item.id] || pending.find((_) => _.id === item.id) ) { - colours[index] = 'var(--wal)'; + colours[index] = 'var(--warn-light)'; } }); return colours; @@ -236,7 +244,7 @@ export class SystemZonesComponent { }); public readonly has_changes = computed( - () => this.pending_zones().length > 0 || this.order_changed, + () => this.pending_zones().length > 0 || this.order_changed(), ); /** Query function for systems */ @@ -267,7 +275,7 @@ export class SystemZonesComponent { const zone_order = this.zone_order(); if (zones.every(({ id }, idx) => zone_order[idx] === id)) return; await this._service.reorderZones(zone_order); - this.order_changed = false; + this.order_changed.set(false); this.changed = {}; this.zone_order.set([]); }; @@ -278,12 +286,20 @@ export class SystemZonesComponent { } public clearChanges() { - this.order_changed = false; + this.order_changed.set(false); this.changed = {}; this.zone_order.set([]); this.pending_zones.set([]); } + constructor() { + // Tab is reused across systems. Drop unsaved changes on system change. + effect(() => { + this._item_id(); + untracked(() => this.clearChanges()); + }); + } + public get item(): PlaceSystem { return this._service.active_item as PlaceSystem; } @@ -291,8 +307,8 @@ export class SystemZonesComponent { public async reorder([previous, current]: [number, number]) { const zones = [...this.zones()]; moveItemInArray(zones, previous, current); - this.changed[zones[previous].id] = true; + this.changed[zones[current].id] = true; this.zone_order.set(zones.map(({ id }) => id)); - this.order_changed = true; + this.order_changed.set(true); } } diff --git a/src/app/triggers/trigger-about.component.ts b/src/app/triggers/trigger-about.component.ts index ac0811d30..a668c5c6f 100644 --- a/src/app/triggers/trigger-about.component.ts +++ b/src/app/triggers/trigger-about.component.ts @@ -249,7 +249,7 @@ import { TriggerStateService } from './trigger-state.service'; }, ]" [can_reorder]="true" - (ondrop)="confirmReorder('function', $event)" + (ondrop)="confirmReorder('mailer', $event)" [empty_message]="'TRIGGERS.ACTION_EMAIL_EMPTY' | translate" /> diff --git a/src/app/triggers/trigger-instances.component.ts b/src/app/triggers/trigger-instances.component.ts index b568759af..ef4dab761 100644 --- a/src/app/triggers/trigger-instances.component.ts +++ b/src/app/triggers/trigger-instances.component.ts @@ -53,8 +53,8 @@ import { TriggerStateService } from './trigger-state.service';
@@ -83,7 +83,7 @@ import { TriggerStateService } from './trigger-state.service'; error matRipple [matTooltip]="'TRIGGERS.DELETE_INSTANCE' | translate" - (click)="deleteTrigger(item)" + (click)="deleteTrigger(row)" > delete diff --git a/src/app/triggers/trigger-state.service.ts b/src/app/triggers/trigger-state.service.ts index c85af8203..b934a4dcc 100644 --- a/src/app/triggers/trigger-state.service.ts +++ b/src/app/triggers/trigger-state.service.ts @@ -10,7 +10,9 @@ import { TriggerTimeCondition, listTriggerInstances, removeSystemTrigger, + showZone, updateTrigger, + updateZone, } from '@placeos/ts-client'; import { escapeHtml } from '../common/general'; @@ -302,7 +304,12 @@ export class TriggerStateService { title: i18n('TRIGGERS.REMOVE_INSTANCE_TITLE', { type }), content: i18n('TRIGGERS.REMOVE_INSTANCE_MSG', { type, - name: escapeHtml(instance.name), + // Instances have no name, so match the id the list shows + name: escapeHtml( + instance.name || + instance.zone_id || + instance.control_system_id, + ), }), icon: { type: 'icon', content: 'delete' }, }, @@ -310,12 +317,23 @@ export class TriggerStateService { ); if (details.reason !== 'done') return; details.loading(i18n('TRIGGERS.REMOVE_INSTANCE_LOADING', { type })); - const method = - type === 'zone' ? removeSystemTrigger : removeSystemTrigger; - let err: unknown = await method( - instance.control_system_id, - instance?.id || this.active_item.id, - ).catch((_) => ({ error: _ })); + // Zone instances come from the zone's trigger list, so remove it there + const remove = instance.zone_id + ? async () => { + const zone = await showZone(instance.zone_id); + await updateZone(zone.id, { + ...zone, + triggers: (zone.triggers || []).filter( + (id) => id !== this.active_item.id, + ), + }); + } + : () => + removeSystemTrigger( + instance.control_system_id, + instance?.id || this.active_item.id, + ); + let err: unknown = await remove().catch((_) => ({ error: _ })); details.close(); if ((err as Record)?.error) { err = (err as Record).error; diff --git a/src/app/triggers/triggers.component.ts b/src/app/triggers/triggers.component.ts index c2ddf4111..7ec53f308 100644 --- a/src/app/triggers/triggers.component.ts +++ b/src/app/triggers/triggers.component.ts @@ -1,8 +1,8 @@ -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 { listTriggerInstances, PlaceTrigger } from '@placeos/ts-client'; +import { PlaceTrigger } from '@placeos/ts-client'; import { extensionsForItem } from '../common/api'; import { AsyncHandler } from '../common/async-handler.class'; import { PlaceDebugService } from '../common/debug.service'; @@ -18,6 +18,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 { TriggerStateService } from './trigger-state.service'; @Component({ selector: 'new-triggers-view', @@ -111,11 +112,14 @@ import { TranslatePipe } from '../ui/translate.pipe'; export class TriggersComponent extends AsyncHandler { protected _service = inject(ActiveItemService); private _debug = inject(PlaceDebugService); + private _state = inject(TriggerStateService); public readonly name = 'triggers'; public open_menu = false; - public readonly instance_count = signal(0); + public readonly instance_count = computed( + () => this._state.instances().length, + ); public readonly loading = toSignal(this._service.loading, { initialValue: false, }); @@ -140,20 +144,4 @@ export class TriggersComponent extends AsyncHandler { }, ].concat(extensionsForItem(this.item(), this.name)), ); - - constructor() { - super(); - effect(() => { - void this.loadValues(this.item() as PlaceTrigger | null); - }); - } - - protected async loadValues(item: PlaceTrigger | null) { - if (!item) { - this.instance_count.set(0); - return; - } - // Get trigger count - this.instance_count.set((await listTriggerInstances(item.id)).length); - } } diff --git a/src/app/triggers/triggers.utilities.ts b/src/app/triggers/triggers.utilities.ts index a7eb648f3..7954a8243 100644 --- a/src/app/triggers/triggers.utilities.ts +++ b/src/app/triggers/triggers.utilities.ts @@ -203,3 +203,81 @@ export function applyTriggerActionFormSchema( }, }); } + +export type CronPeriod = + | 'minute' + | 'hour' + | 'day' + | 'week' + | 'month' + | 'year' + | 'custom'; + +/** Values for the simple schedule editor. Month is 1-12, weekday is 0-6. */ +export interface CronParts { + minute: number; + hour: number; + day_of_week: number; + day_of_month: number; + month: number; +} + +/** + * Build a cron string from the simple schedule editor values. + * Returns `null` for the `custom` period as the string is edited directly. + */ +export function buildCronString( + period: CronPeriod, + parts: CronParts, +): string | null { + const minute = parts.minute % 60; + const { hour, day_of_week, day_of_month, month } = parts; + switch (period) { + case 'minute': + return minute ? `*/${minute} * * * *` : '* * * * *'; + case 'hour': + return hour ? `${minute} */${hour} * * *` : `${minute} * * * *`; + case 'day': + return `${minute} ${hour} * * *`; + case 'week': + return `${minute} ${hour} * * ${day_of_week}`; + case 'month': + return `${minute} ${hour} ${day_of_month} * *`; + case 'year': + return `${minute} ${hour} ${day_of_month} ${month} *`; + } + return null; +} + +/** + * Parse a cron string into the simple schedule editor values. + * Strings with ranges, steps or lists use the `custom` period. + */ +export function parseCronString(cron: string): { + period: CronPeriod; + parts: CronParts; +} { + const cron_str = (cron || '').trim() || '* * * * *'; + const [minute, hour, day, month, weekday] = cron_str.split(/\s+/); + const parts: CronParts = { + minute: +minute || 0, + hour: +hour || 0, + day_of_week: +weekday || 0, + day_of_month: +day || 1, + month: +month || 1, + }; + if (/[-/,]/.test(cron_str)) return { period: 'custom', parts }; + const is_set = (value?: string) => !!value && value !== '*'; + const period: CronPeriod = is_set(month) + ? 'year' + : is_set(day) + ? 'month' + : is_set(weekday) + ? 'week' + : is_set(hour) + ? 'day' + : is_set(minute) + ? 'hour' + : 'minute'; + return { period, parts }; +} diff --git a/src/app/ui/custom-fields/cron-input-field.component.ts b/src/app/ui/custom-fields/cron-input-field.component.ts index 980095fbb..8b67e107b 100644 --- a/src/app/ui/custom-fields/cron-input-field.component.ts +++ b/src/app/ui/custom-fields/cron-input-field.component.ts @@ -31,6 +31,11 @@ const VALID_INPUT = [ 'ArrowUp', 'ArrowDown', 'Delete', + 'Tab', + 'Home', + 'End', + 'Enter', + 'Escape', ]; function listPattern(fieldPattern) { // One instance of `fieldPattern` plus any number of `,fieldPattern` @@ -120,7 +125,7 @@ export class CronInputFieldComponent implements ControlValueAccessor { this.validatePattern( path.day, listPattern( - '(?:\\*(?:/\\d{1,2})?|(?:[1-9]|[12]\\d|3[01])(?:-(?:[1-9]|[12]d|3[01]))?(?:/\\d{1,2})?)', + '(?:\\*(?:/\\d{1,2})?|(?:[1-9]|[12]\\d|3[01])(?:-(?:[1-9]|[12]\\d|3[01]))?(?:/\\d{1,2})?)', ), ); this.validatePattern( @@ -176,7 +181,9 @@ export class CronInputFieldComponent implements ControlValueAccessor { } public preventInvalidCharacters(event: KeyboardEvent): void { - if (this.disabled()) event.preventDefault(); + if (this.disabled()) return event.preventDefault(); + // Allow shortcuts such as copy, paste and select all + if (event.ctrlKey || event.metaKey || event.altKey) return; if (!VALID_INPUT.includes(event.key)) event.preventDefault(); } diff --git a/src/app/ui/custom-fields/date-calendar.component.ts b/src/app/ui/custom-fields/date-calendar.component.ts index 40a7d4e1c..06694943c 100644 --- a/src/app/ui/custom-fields/date-calendar.component.ts +++ b/src/app/ui/custom-fields/date-calendar.component.ts @@ -37,10 +37,11 @@ interface DateItem {
- {{ date_list()[6]?.id || date() | date: 'LLLL YYYY' }} + {{ date_list()[6]?.id || date() | date: 'LLLL yyyy' }}