fix: multiple bug fixes

isolated users, db backup, weird disconnect issues for long voice sessions,
This commit is contained in:
2026-04-24 22:19:57 +02:00
parent 44588e8789
commit bc2fa7de22
56 changed files with 1861 additions and 133 deletions

View File

@@ -79,7 +79,7 @@ graph TD
## Endpoint lifecycle
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active.
On startup, `ServerEndpointStateService` loads endpoints from localStorage, reconciles them with the configured defaults from the environment, and ensures at least one endpoint is active. Configured default endpoints are treated as active by default unless the user explicitly disabled or removed them.
```mermaid
stateDiagram-v2
@@ -167,6 +167,7 @@ Default servers are configured in the environment file. The state service builds
- Stored endpoints are matched to defaults by `defaultKey` or URL
- Missing defaults are added unless the user explicitly removed them (tracked in a separate localStorage key)
- Default endpoints stay active by default unless the user explicitly disabled them (tracked separately from the endpoint payload)
- `restoreDefaultServers()` re-adds any removed defaults and clears the removal tracking
- The primary default URL is used as a fallback when no endpoint is resolved

View File

@@ -0,0 +1,177 @@
import { Injector, runInInjectionContext } from '@angular/core';
import { environment } from '../../../../../environments/environment';
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
import * as serverDirectoryStorageKeys from '../../infrastructure/constants/server-directory.infrastructure.constants';
import { ServerEndpointStorageService } from '../../infrastructure/services/server-endpoint-storage.service';
import { ServerEndpointStateService } from './server-endpoint-state.service';
function createLocalStorageMock(): Storage {
const store = new Map<string, string>();
return {
get length(): number {
return store.size;
},
clear(): void {
store.clear();
},
getItem(key: string): string | null {
return store.get(key) ?? null;
},
key(index: number): string | null {
return [...store.keys()][index] ?? null;
},
removeItem(key: string): void {
store.delete(key);
},
setItem(key: string, value: string): void {
store.set(key, value);
}
};
}
Object.defineProperty(globalThis, 'localStorage', {
value: createLocalStorageMock(),
configurable: true
});
function getConfiguredDefaultServer(key: string): { key?: string; name?: string; url?: string } {
const defaultServer = environment.defaultServers.find((server) => server.key === key);
if (!defaultServer) {
throw new Error(`Missing configured default server for key: ${key}`);
}
return defaultServer;
}
function seedStoredEndpoints(endpoints: ServerEndpoint[]): void {
localStorage.setItem(serverDirectoryStorageKeys.SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
function createService(): ServerEndpointStateService {
const injector = Injector.create({
providers: [
{
provide: ServerEndpointStorageService,
useClass: ServerEndpointStorageService,
deps: []
}
]
});
return runInInjectionContext(injector, () => new ServerEndpointStateService());
}
function getRequiredDefaultEndpoint(service: ServerEndpointStateService, defaultKey: string | undefined): ServerEndpoint {
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultKey);
if (!endpoint) {
throw new Error(`Expected default endpoint for key: ${defaultKey ?? 'unknown'}`);
}
return endpoint;
}
describe('ServerEndpointStateService', () => {
beforeEach(() => {
localStorage.clear();
});
it('reactivates configured default endpoints unless the user disabled them', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: false,
isDefault: true,
defaultKey: defaultServer.key,
status: 'unknown'
}
]);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(true);
});
it('keeps a configured default endpoint inactive after the user turned it off', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: true,
isDefault: true,
defaultKey: defaultServer.key,
status: 'unknown'
}
]);
localStorage.setItem(
serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
JSON.stringify([defaultServer.key])
);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(false);
});
it('keeps configured default endpoints active even when stored as incompatible unless the user disabled them', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
seedStoredEndpoints([
{
id: 'default-server',
name: 'Stored Default',
url: defaultServer.url ?? '',
isActive: false,
isDefault: true,
defaultKey: defaultServer.key,
status: 'incompatible'
}
]);
const service = createService();
const endpoint = service.servers().find((candidate) => candidate.defaultKey === defaultServer.key);
expect(endpoint?.isActive).toBe(true);
expect(endpoint?.status).toBe('incompatible');
});
it('does not deactivate configured default endpoints when compatibility checks fail', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
service.updateServerStatus(endpoint.id, 'incompatible');
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
});
it('persists turning a configured default endpoint off and back on', () => {
const defaultServer = getConfiguredDefaultServer('toju-primary');
const service = createService();
const endpoint = getRequiredDefaultEndpoint(service, defaultServer.key);
service.deactivateServer(endpoint.id);
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(false);
expect(JSON.parse(
localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY) ?? '[]'
)).toContain(defaultServer.key);
service.setActiveServer(endpoint.id);
expect(service.servers().find((candidate) => candidate.id === endpoint.id)?.isActive).toBe(true);
expect(localStorage.getItem(serverDirectoryStorageKeys.DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY)).toBeNull();
});
});

View File

@@ -145,6 +145,7 @@ export class ServerEndpointStateService {
if (target.isDefault) {
this.markDefaultEndpointRemoved(target);
this.clearDefaultEndpointDisabled(target);
}
const updatedEndpoints = ensureAnyActiveEndpoint(
@@ -171,6 +172,7 @@ export class ServerEndpointStateService {
this._servers.update((endpoints) => ensureAnyActiveEndpoint([...endpoints, ...restoredEndpoints]));
this.storage.clearRemovedDefaultEndpointKeys();
this.clearDisabledDefaultEndpointKeys(restoredEndpoints);
this.saveEndpoints();
return restoredEndpoints;
}
@@ -190,6 +192,12 @@ export class ServerEndpointStateService {
);
});
const target = this._servers().find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) {
this.clearDefaultEndpointDisabled(target);
}
this.saveEndpoints();
}
@@ -206,6 +214,12 @@ export class ServerEndpointStateService {
)
);
const target = this._servers().find((endpoint) => endpoint.id === endpointId);
if (target?.isDefault) {
this.markDefaultEndpointDisabled(target);
}
this.saveEndpoints();
}
@@ -225,7 +239,7 @@ export class ServerEndpointStateService {
instanceId: versions?.serverInstanceId ?? endpoint.instanceId,
status,
latency,
isActive: status === 'incompatible' ? false : endpoint.isActive,
isActive: status === 'incompatible' && !endpoint.isDefault ? false : endpoint.isActive,
serverVersion: versions?.serverVersion ?? endpoint.serverVersion,
clientVersion: versions?.clientVersion ?? endpoint.clientVersion
};
@@ -258,6 +272,7 @@ export class ServerEndpointStateService {
private reconcileStoredEndpoints(storedEndpoints: ServerEndpoint[]): ServerEndpoint[] {
const reconciled: ServerEndpoint[] = [];
const claimedDefaultKeys = new Set<string>();
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
const removedDefaultKeys = this.storage.loadRemovedDefaultEndpointKeys();
for (const endpoint of storedEndpoints) {
@@ -279,6 +294,7 @@ export class ServerEndpointStateService {
...endpoint,
name: matchedDefault.name,
url: matchedDefault.url,
isActive: this.isDefaultEndpointActive(matchedDefault.defaultKey, disabledDefaultKeys),
isDefault: true,
defaultKey: matchedDefault.defaultKey,
status: endpoint.status ?? 'unknown'
@@ -303,7 +319,7 @@ export class ServerEndpointStateService {
reconciled.push({
...defaultEndpoint,
id: uuidv4(),
isActive: defaultEndpoint.isActive
isActive: this.isDefaultEndpointActive(defaultEndpoint.defaultKey, disabledDefaultKeys)
});
}
}
@@ -324,6 +340,64 @@ export class ServerEndpointStateService {
this.storage.saveRemovedDefaultEndpointKeys(removedDefaultKeys);
}
private markDefaultEndpointDisabled(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
disabledDefaultKeys.add(defaultKey);
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private clearDefaultEndpointDisabled(endpoint: ServerEndpoint): void {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
return;
}
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
if (!disabledDefaultKeys.delete(defaultKey)) {
return;
}
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private clearDisabledDefaultEndpointKeys(endpoints: ServerEndpoint[]): void {
const disabledDefaultKeys = this.storage.loadDisabledDefaultEndpointKeys();
let didChange = false;
for (const endpoint of endpoints) {
const defaultKey = endpoint.defaultKey ?? findDefaultEndpointKeyByUrl(this.defaultEndpoints, endpoint.url);
if (!defaultKey) {
continue;
}
didChange = disabledDefaultKeys.delete(defaultKey) || didChange;
}
if (!didChange) {
return;
}
this.storage.saveDisabledDefaultEndpointKeys(disabledDefaultKeys);
}
private isDefaultEndpointActive(
defaultKey: string,
disabledDefaultKeys: Set<string>
): boolean {
return !disabledDefaultKeys.has(defaultKey);
}
private saveEndpoints(): void {
this.storage.saveEndpoints(this._servers());
}

View File

@@ -1,3 +1,4 @@
export const SERVER_ENDPOINTS_STORAGE_KEY = 'metoyou_server_endpoints';
export const REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_removed_default_server_keys';
export const DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY = 'metoyou_disabled_default_server_keys';
export const SERVER_HEALTH_CHECK_TIMEOUT_MS = 5000;

View File

@@ -1,5 +1,9 @@
import { Injectable } from '@angular/core';
import { REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, SERVER_ENDPOINTS_STORAGE_KEY } from '../constants/server-directory.infrastructure.constants';
import {
DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY,
SERVER_ENDPOINTS_STORAGE_KEY
} from '../constants/server-directory.infrastructure.constants';
import type { ServerEndpoint } from '../../domain/models/server-directory.model';
@Injectable({ providedIn: 'root' })
@@ -26,8 +30,32 @@ export class ServerEndpointStorageService {
localStorage.setItem(SERVER_ENDPOINTS_STORAGE_KEY, JSON.stringify(endpoints));
}
loadDisabledDefaultEndpointKeys(): Set<string> {
return this.loadStringSet(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
saveDisabledDefaultEndpointKeys(keys: Set<string>): void {
this.saveStringSet(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY, keys);
}
clearDisabledDefaultEndpointKeys(): void {
localStorage.removeItem(DISABLED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
loadRemovedDefaultEndpointKeys(): Set<string> {
const stored = localStorage.getItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
return this.loadStringSet(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
this.saveStringSet(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, keys);
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
}
private loadStringSet(storageKey: string): Set<string> {
const stored = localStorage.getItem(storageKey);
if (!stored) {
return new Set<string>();
@@ -46,16 +74,12 @@ export class ServerEndpointStorageService {
}
}
saveRemovedDefaultEndpointKeys(keys: Set<string>): void {
private saveStringSet(storageKey: string, keys: Set<string>): void {
if (keys.size === 0) {
this.clearRemovedDefaultEndpointKeys();
localStorage.removeItem(storageKey);
return;
}
localStorage.setItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY, JSON.stringify([...keys]));
}
clearRemovedDefaultEndpointKeys(): void {
localStorage.removeItem(REMOVED_DEFAULT_SERVER_KEYS_STORAGE_KEY);
localStorage.setItem(storageKey, JSON.stringify([...keys]));
}
}