Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 4 additions & 41 deletions apps/map-studio/src/app/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@ import { provideServiceWorker } from '@angular/service-worker';

import { environment } from '../environments/environment';
import { routes } from './app.routes';
import { PlaceOSService, PlaceOSZone } from './data/placeos.service';
import { PlaceOSService } from './data/placeos.service';
import { StoreService } from './data/store.service';

/** Level requests in flight at once during zone sync */
const SYNC_CONCURRENCY = 6;

async function initialisePlaceOS() {
const placeos = inject(PlaceOSService);
const store = inject(StoreService);
Expand All @@ -24,43 +21,9 @@ async function initialisePlaceOS() {
if (window.location.hash.startsWith('#/kiosk')) return;

// Zone import can take a while on large deployments. It must not hold
// Angular bootstrap open because the editor can render without it.
void syncPlaceOSZones(placeos, store);
}

async function syncPlaceOSZones(
placeos: PlaceOSService,
store: StoreService,
): Promise<void> {
try {
const organisation = await placeos.getActiveOrganisation();
if (!organisation) return;
const regions = await placeos.getZones('region', organisation.id);
const building_lists = await Promise.all(
[organisation, ...regions].map((parent) =>
placeos.getZones('building', parent.id),
),
);
const zones_by_id = new Map(
building_lists.flat().map((zone) => [zone.id, zone]),
);
const zones = [...zones_by_id.values()];
const buildings: { zone: PlaceOSZone; levels: PlaceOSZone[] }[] = [];
// A few buildings at a time, so large deployments do not flood the API
for (let i = 0; i < zones.length; i += SYNC_CONCURRENCY) {
buildings.push(
...(await Promise.all(
zones.slice(i, i + SYNC_CONCURRENCY).map(async (zone) => ({
zone,
levels: await placeos.getZones('level', zone.id),
})),
)),
);
}
await store.syncPlaceOSZones(buildings);
} catch (error) {
console.warn('Failed to create projects from PlaceOS zones', error);
}
// Angular bootstrap open because the editor can render without it. The
// projects page shows its progress through `store.placeos_sync`.
void store.syncFromPlaceOS();
}

export const appConfig: ApplicationConfig = {
Expand Down
14 changes: 13 additions & 1 deletion apps/map-studio/src/app/app.routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { inject } from '@angular/core';
import { ActivatedRouteSnapshot, Router, Routes } from '@angular/router';
import {
ActivatedRouteSnapshot,
CanDeactivateFn,
Router,
Routes,
} from '@angular/router';

import { StoreService } from './data/store.service';
import type { EditorComponent } from './editor/editor.component';

/** Opens the floorplan for a PlaceOS level, which is how Concierge links here. */
async function openLevel(route: ActivatedRouteSnapshot) {
Expand All @@ -16,6 +23,10 @@ async function openLevel(route: ActivatedRouteSnapshot) {
}
}

/** Saves pending editor changes before any navigation away, Back included */
const saveBeforeLeaving: CanDeactivateFn<EditorComponent> = (editor) =>
editor.canLeave();

export const routes: Routes = [
{
path: 'level/:level_id',
Expand All @@ -26,6 +37,7 @@ export const routes: Routes = [
path: 'editor/:floorplan_id',
loadComponent: () =>
import('./editor/editor.component').then((m) => m.EditorComponent),
canDeactivate: [saveBeforeLeaving],
},
{
path: 'kiosk/:project_id',
Expand Down
65 changes: 53 additions & 12 deletions apps/map-studio/src/app/data/placeos.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,34 @@ export interface PlaceOSSettings {
api_key: string;
}

/** Engine API response with a non-2xx status */
export class PlaceOSRequestError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message);
}
}

/** Plain message for a failed connection test */
function connectionMessage(error: unknown): string {
if (error instanceof PlaceOSRequestError) {
if (error.status === 401 || error.status === 403) {
return 'PlaceOS rejected the API key. Check the key and its permissions.';
}
if (error.status === 404) {
return 'No PlaceOS API was found at this domain. Check the domain.';
}
return `PlaceOS returned an error (${error.status}). Try again later.`;
}
// fetch rejects with a TypeError when the host cannot be reached
if (error instanceof TypeError) {
return 'Cannot reach the PlaceOS domain. Check the address and your network connection.';
}
return error instanceof Error ? error.message : 'Connection failed';
}

/**
* `domain` when the app is served by a PlaceOS domain and the user is signed
* in through it, `manual` when a domain and API key have to be entered.
Expand Down Expand Up @@ -234,14 +262,31 @@ export class PlaceOSService {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
}

/** Checks the stored credentials by fetching the current user */
public async testConnection(): Promise<{
/** Forgets the stored domain and API key */
public clearConfig(): void {
this._settings.set({ domain: '', api_key: '' });
localStorage.removeItem(SETTINGS_KEY);
}

/**
* Checks credentials by fetching the current user. Tests `settings` when
* given, without storing them, and the stored credentials otherwise.
*/
public async testConnection(settings?: PlaceOSSettings): Promise<{
ok: boolean;
user?: PlaceOSUser;
error?: string;
}> {
try {
const user = await this._request<PlaceOSUser>('/users/current');
const user = await this._request<PlaceOSUser>(
'/users/current',
{},
{},
settings && {
...settings,
domain: settings.domain.replace(/\/$/, ''),
},
);
return {
ok: true,
user: {
Expand All @@ -251,13 +296,7 @@ export class PlaceOSService {
},
};
} catch (error) {
return {
ok: false,
error:
error instanceof Error
? error.message
: 'Connection failed',
};
return { ok: false, error: connectionMessage(error) };
}
}

Expand Down Expand Up @@ -507,8 +546,9 @@ export class PlaceOSService {
path: string,
params: Record<string, string> = {},
options: RequestInit = {},
settings: PlaceOSSettings = this._settings(),
): Promise<T> {
const { domain, api_key } = this._settings();
const { domain, api_key } = settings;
const on_domain = this._mode() === 'domain';
if (!on_domain && !api_key) throw new Error('PlaceOS not configured');
const query = new URLSearchParams(params).toString();
Expand All @@ -527,7 +567,8 @@ export class PlaceOSService {
async (response) => {
if (!response.ok) {
const detail = await response.text().catch(() => '');
throw new Error(
throw new PlaceOSRequestError(
response.status,
`PlaceOS ${response.status}: ${detail || response.statusText}`,
);
}
Expand Down
Loading