From bc755089d0b3778ef3d3c2c73976751d70cc29fe Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 15 Oct 2026 11:10:14 +1100 Subject: [PATCH 1/2] feat(admin): MQTT re-sync and clear & re-sync on the database page Two buttons on Manage Instance > Database: re-sync publishes every module's state and every model's metadata to the MQTT brokers again, and clear & re-sync removes everything PlaceOS published first, after a confirmation. A running re-sync, whoever started it, disables both and shows its progress until it finishes or fails. Needs @placeos/ts-client with sourceResync (PlaceOS/ts-client#33). Co-Authored-By: Claude Opus 5.5 --- src/app/admin/database-details.component.ts | 172 +++++++++++++++++- .../admin/database-details.component.spec.ts | 168 +++++++++++++++++ user-stories/admin.md | 18 ++ 3 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 src/tests/admin/database-details.component.spec.ts diff --git a/src/app/admin/database-details.component.ts b/src/app/admin/database-details.component.ts index 1302b2554..8a7a69433 100644 --- a/src/app/admin/database-details.component.ts +++ b/src/app/admin/database-details.component.ts @@ -1,9 +1,18 @@ -import { Component, inject, signal } from '@angular/core'; -import { addZone, PlaceZone, queryZones, showZone } from '@placeos/ts-client'; +import { Component, computed, inject, OnInit, signal } from '@angular/core'; +import { + addZone, + PlaceSourceResyncStatus, + PlaceZone, + queryZones, + showZone, + sourceResync, + sourceResyncStatus, +} from '@placeos/ts-client'; import { MatRippleModule } from '@angular/material/core'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { AsyncHandler } from '../common/async-handler.class'; import { readError } from '../common/errors'; import { csvToJson, downloadFile, jsonToCsv } from '../common/general'; import { @@ -11,8 +20,12 @@ import { notifySuccess, notifyWarn, } from '../common/notifications'; +import { openConfirmModal } from '../overlays/confirm-modal.component'; import { ZoneTreeExportModalComponent } from './zone-tree-export-modal.component'; +/** How often a running MQTT re-sync is checked on */ +const MQTT_STATUS_POLL_MS = 2000; + type ZoneTreeExportItem = Record & { id: string; parent_id?: string; @@ -136,6 +149,50 @@ async function queryAllZones(query_params: Record = {}) { } +
+

+ Publish every module's state and every model's metadata to + the MQTT brokers again. Clearing first removes everything + PlaceOS has published, for when what the brokers hold has + drifted from the modules; clients see nothing until it is + published again. +

+
+ + +
+ @if (mqtt_status(); as status) { +
+ @if (mqtt_running()) { + + } + {{ mqtt_status_text() }} +
+ } +
`, styles: [ @@ -159,14 +216,123 @@ async function queryAllZones(query_params: Record = {}) { ], imports: [MatProgressSpinnerModule, MatRippleModule, MatDialogModule], }) -export class PlaceDatabaseDetailsComponent { +export class PlaceDatabaseDetailsComponent + extends AsyncHandler + implements OnInit +{ private readonly _dialog = inject(MatDialog); + /** Where the last MQTT re-sync got to, if this user may see it */ + public readonly mqtt_status = signal(null); + /** Whether a re-sync is being requested */ + private readonly _mqtt_starting = signal(false); + /** Whether a re-sync is being requested or is running */ + public readonly mqtt_running = computed( + () => + this._mqtt_starting() || + ['clearing', 'resyncing'].includes(this.mqtt_status()?.state), + ); + public readonly mqtt_status_text = computed(() => { + const status = this.mqtt_status(); + if (!status) return ''; + switch (status.state) { + case 'clearing': + return 'Clearing the MQTT brokers...'; + case 'resyncing': + return 'Publishing to the MQTT brokers...'; + case 'complete': + return `Last re-sync finished ${new Date( + status.finished_at, + ).toLocaleString()}${ + status.clear + ? `, after removing ${status.cleared} topics` + : '' + }.`; + case 'failed': + return `Last re-sync failed: ${status.error || 'unknown error'}`; + default: + return 'No re-sync since the MQTT service started.'; + } + }); + /** Whether zones are being exported */ public readonly exporting_zones = signal(false); /** Whether zones are being imported */ public readonly importing_zones = signal(false); + public ngOnInit() { + this._loadMqttStatus(); + } + + /** Re-publishes everything to the MQTT brokers, clearing them first if asked */ + public async resyncMqtt(clear: boolean) { + if (clear) { + const details = await openConfirmModal( + { + title: 'Clear & Re-sync MQTT', + content: + 'Remove everything PlaceOS has published from the MQTT brokers, then publish it all again. Dashboards and alerts show nothing until it is published again. Continue?', + icon: { type: 'icon', content: 'sync_problem' }, + confirm_text: 'Clear & Re-sync', + }, + this._dialog, + ); + if (details.reason !== 'done') return; + details.close(); + } + this._mqtt_starting.set(true); + try { + this.mqtt_status.set(await sourceResync(clear)); + this._watchMqttStatus(); + } catch (err) { + if ((err as Response)?.status === 409) { + notifyWarn('An MQTT re-sync is already running.'); + this._loadMqttStatus(); + } else { + notifyError( + `Error starting the MQTT re-sync. Error: ${await readError(err)}`, + ); + } + } + this._mqtt_starting.set(false); + } + + private async _loadMqttStatus() { + try { + this.mqtt_status.set(await sourceResyncStatus()); + if (this.mqtt_running()) this._watchMqttStatus(); + } catch { + // not available to this user, or to this version of PlaceOS + this.mqtt_status.set(null); + } + } + + /** Follows a running re-sync and says how it ended */ + private _watchMqttStatus() { + this.interval( + 'mqtt_status', + async () => { + let status: PlaceSourceResyncStatus; + try { + status = await sourceResyncStatus(); + } catch { + return; + } + this.mqtt_status.set(status); + if (status.state === 'complete') { + this.clearInterval('mqtt_status'); + notifySuccess('MQTT re-sync complete.'); + } else if (status.state === 'failed') { + this.clearInterval('mqtt_status'); + notifyError( + `MQTT re-sync failed. Error: ${status.error || 'unknown error'}`, + ); + } + }, + MQTT_STATUS_POLL_MS, + ); + } + public exportZoneTree() { const dialog_ref = this._dialog.open(ZoneTreeExportModalComponent, { data: { selected: [] }, diff --git a/src/tests/admin/database-details.component.spec.ts b/src/tests/admin/database-details.component.spec.ts new file mode 100644 index 000000000..ffcae72c3 --- /dev/null +++ b/src/tests/admin/database-details.component.spec.ts @@ -0,0 +1,168 @@ +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { sourceResync, sourceResyncStatus } from '@placeos/ts-client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PlaceDatabaseDetailsComponent } from '../../app/admin/database-details.component'; +import { + notifyError, + notifySuccess, + notifyWarn, +} from '../../app/common/notifications'; +import { openConfirmModal } from '../../app/overlays/confirm-modal.component'; + +vi.mock('@placeos/ts-client', () => ({ + addZone: vi.fn(), + queryZones: vi.fn(), + showZone: vi.fn(), + sourceResync: vi.fn(), + sourceResyncStatus: vi.fn(), +})); +vi.mock('../../app/common/notifications', () => ({ + notifyError: vi.fn(), + notifySuccess: vi.fn(), + notifyWarn: vi.fn(), +})); +vi.mock('../../app/overlays/confirm-modal.component', () => ({ + openConfirmModal: vi.fn(), +})); + +const status = (state: string, extra: Record = {}) => + ({ + state, + clear: false, + cleared: 0, + metadata: 0, + ...extra, + }) as any; + +describe('Database details: MQTT re-sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + vi.mocked(sourceResyncStatus).mockResolvedValue(status('idle')); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + vi.useRealTimers(); + }); + + async function createComponent() { + await TestBed.configureTestingModule({ + imports: [PlaceDatabaseDetailsComponent], + providers: [{ provide: MatDialog, useValue: { open: vi.fn() } }], + }).compileComponents(); + const fixture = TestBed.createComponent(PlaceDatabaseDetailsComponent); + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(0); + fixture.detectChanges(); + return fixture; + } + + const buttons = (fixture: any) => ({ + resync: fixture.nativeElement.querySelector( + '[mqtt-resync-button]', + ) as HTMLButtonElement, + clear: fixture.nativeElement.querySelector( + '[mqtt-clear-button]', + ) as HTMLButtonElement, + }); + + it('re-syncs without asking, and follows it until it finishes', async () => { + const fixture = await createComponent(); + vi.mocked(sourceResync).mockResolvedValue(status('resyncing')); + vi.mocked(sourceResyncStatus).mockResolvedValue(status('resyncing')); + + buttons(fixture).resync.click(); + await vi.advanceTimersByTimeAsync(0); + fixture.detectChanges(); + + expect(openConfirmModal).not.toHaveBeenCalled(); + expect(sourceResync).toHaveBeenCalledWith(false); + expect(buttons(fixture).resync.disabled).toBe(true); + expect(buttons(fixture).clear.disabled).toBe(true); + + vi.mocked(sourceResyncStatus).mockResolvedValue( + status('complete', { finished_at: '2026-10-15T00:00:00Z' }), + ); + await vi.advanceTimersByTimeAsync(2000); + fixture.detectChanges(); + + expect(notifySuccess).toHaveBeenCalledWith('MQTT re-sync complete.'); + expect(buttons(fixture).resync.disabled).toBe(false); + expect( + fixture.nativeElement.querySelector('[mqtt-status]').textContent, + ).toContain('Last re-sync finished'); + }); + + it('asks before clearing, and does nothing if dismissed', async () => { + const fixture = await createComponent(); + vi.mocked(openConfirmModal).mockResolvedValue({ + reason: undefined, + loading: vi.fn(), + close: vi.fn(), + }); + + buttons(fixture).clear.click(); + await vi.advanceTimersByTimeAsync(0); + + expect(openConfirmModal).toHaveBeenCalled(); + expect(sourceResync).not.toHaveBeenCalled(); + }); + + it('clears and re-syncs once confirmed, and reports a failure', async () => { + const fixture = await createComponent(); + vi.mocked(openConfirmModal).mockResolvedValue({ + reason: 'done', + loading: vi.fn(), + close: vi.fn(), + }); + vi.mocked(sourceResync).mockResolvedValue( + status('clearing', { clear: true }), + ); + vi.mocked(sourceResyncStatus).mockResolvedValue( + status('failed', { clear: true, error: 'broker down' }), + ); + + buttons(fixture).clear.click(); + await vi.advanceTimersByTimeAsync(0); + expect(sourceResync).toHaveBeenCalledWith(true); + + await vi.advanceTimersByTimeAsync(2000); + expect(notifyError).toHaveBeenCalledWith( + 'MQTT re-sync failed. Error: broker down', + ); + }); + + it('says when one is already running', async () => { + const fixture = await createComponent(); + vi.mocked(sourceResync).mockRejectedValue({ status: 409 }); + + buttons(fixture).resync.click(); + await vi.advanceTimersByTimeAsync(0); + + expect(notifyWarn).toHaveBeenCalledWith( + 'An MQTT re-sync is already running.', + ); + }); + + it('follows a re-sync that was already running when opened', async () => { + vi.mocked(sourceResyncStatus).mockResolvedValue(status('clearing')); + const fixture = await createComponent(); + expect(buttons(fixture).clear.disabled).toBe(true); + + vi.mocked(sourceResyncStatus).mockResolvedValue( + status('complete', { finished_at: '2026-10-15T00:00:00Z' }), + ); + await vi.advanceTimersByTimeAsync(2000); + expect(notifySuccess).toHaveBeenCalled(); + }); + + it('hides the status when it cannot be read', async () => { + vi.mocked(sourceResyncStatus).mockRejectedValue({ status: 403 }); + const fixture = await createComponent(); + expect(fixture.nativeElement.querySelector('[mqtt-status]')).toBeNull(); + expect(buttons(fixture).resync.disabled).toBe(false); + }); +}); diff --git a/user-stories/admin.md b/user-stories/admin.md index 5b268fd22..dc0678c66 100644 --- a/user-stories/admin.md +++ b/user-stories/admin.md @@ -126,6 +126,24 @@ The Admin section provides comprehensive system administration capabilities incl - When I view the database details page - Then I should see loading feedback and the related import or export action should be disabled until it completes +**AC-ADM-004-7: Re-sync MQTT** +- Given I am a system administrator viewing database details +- When I choose Re-sync MQTT +- Then every module's state and every model's metadata is published to the MQTT brokers again +- And both MQTT actions are disabled, with progress shown, until it finishes +- And I am told when it has finished, or why it failed + +**AC-ADM-004-8: Clear & Re-sync MQTT** +- Given the MQTT brokers hold state that has drifted from the modules +- When I choose Clear & Re-sync MQTT and confirm +- Then everything PlaceOS has published is removed from the brokers before it is all published again +- And dismissing the confirmation changes nothing + +**AC-ADM-004-9: MQTT Re-sync Already Running** +- Given an MQTT re-sync is running, started by me or anyone else +- When I open the database details page, or try to start another +- Then I see its progress and cannot start a second one + --- # Network & Interfaces From 43d2a902c760d5d3723944042556d5d1622975de Mon Sep 17 00:00:00 2001 From: Stephen von Takach Date: Thu, 15 Oct 2026 12:35:48 +1100 Subject: [PATCH 2/2] chore(deps): use ts-client 6.11.0 for the source re-sync helpers Co-Authored-By: Claude Opus 5.5 --- bun.lock | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index 22beb184c..dae2974c3 100644 --- a/bun.lock +++ b/bun.lock @@ -43,7 +43,7 @@ "@nx/web": "23.3.0", "@nx/workspace": "23.3.0", "@placeos/cloud-uploads": "^1.1.3", - "@placeos/ts-client": "^6.7.0", + "@placeos/ts-client": "^6.11.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.1.9", "@sentry/browser": "^10.25.0", @@ -772,7 +772,7 @@ "@placeos/cloud-uploads": ["@placeos/cloud-uploads@1.1.3", "", { "peerDependencies": { "rxjs": "^7.8.2", "ts-md5": "^2.0.1" } }, "sha512-GRHSzTfvSlMQG+98sxzfWhHuWXloFtlc1lg3GA/lTlAXBzIhJUgfkkox+kY8w6Wdwrh4X2WsxjAoAaO0c/+KDg=="], - "@placeos/ts-client": ["@placeos/ts-client@6.7.0", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-xkvzLDGCZ0RExV7mcLwqnCLrsYg4kPloy1NHuzJKI4pPa8ai3XfnXdt5NPzIHDsPjmtXEXYVwo5l/ejyj/BUOg=="], + "@placeos/ts-client": ["@placeos/ts-client@6.11.0", "", { "dependencies": { "byte-base64": "^1.1.0", "fast-sha256": "^1.3.0" }, "peerDependencies": { "date-fns": "^4.1.0", "node-fetch": "^3.2.0", "ts-md5": "^2.0.1", "websocket": "^1.0.34" } }, "sha512-1SLvYrhOd68NYVQkCvlX5RU4SeqIul1uUE0xKAJ43r5z1aDgoCuq9M48ouNDJnSluaTut1bGM8Lji3hSfIKXVw=="], "@playwright/test": ["@playwright/test@1.56.1", "", { "dependencies": { "playwright": "1.56.1" }, "bin": { "playwright": "cli.js" } }, "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg=="], diff --git a/package.json b/package.json index 3f2d6bf11..b4d6555dc 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@nx/web": "23.3.0", "@nx/workspace": "23.3.0", "@placeos/cloud-uploads": "^1.1.3", - "@placeos/ts-client": "^6.7.0", + "@placeos/ts-client": "^6.11.0", "@playwright/test": "^1.36.0", "@schematics/angular": "22.1.9", "@sentry/browser": "^10.25.0",