Enhance SwarmUI integration by adding swarm connection status to the API and updating client-side components to reflect this status. Improved localization strings for various SwarmUI states and refactored ApplicantsDialog and PersonCard components to utilize the new connection information, ensuring a more responsive user experience.
ci / server (push) Failing after 4m56s
ci / client (push) Failing after 15s

This commit is contained in:
Leonid Pershin
2026-08-20 04:21:35 +03:00
parent 36b46774a7
commit 5991dcd5c4
13 changed files with 246 additions and 33 deletions
+10
View File
@@ -146,6 +146,11 @@ const ru = {
peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.',
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
peoplePortraitSwarmDisabled: 'SwarmUI: не настроен',
peoplePortraitSwarmChecking: 'SwarmUI: проверка…',
peoplePortraitSwarmConnected: 'SwarmUI: подключён',
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
modeOverview: 'Обзор',
@@ -381,6 +386,11 @@ const en: Messages = {
peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.',
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.',
peoplePortraitSwarmDisabled: 'SwarmUI: not configured',
peoplePortraitSwarmChecking: 'SwarmUI: checking…',
peoplePortraitSwarmConnected: 'SwarmUI: connected',
peoplePortraitSwarmDisconnected: 'SwarmUI: unreachable',
peoplePortraitSwarmOffline: 'SwarmUI is unreachable — check that the service is running.',
peoplePortraitFailed: 'Could not generate the portrait.',
modeOverview: 'Overview',
+2
View File
@@ -346,6 +346,8 @@ export interface GameStatus {
readonly maxSchools: number;
readonly connections: number;
readonly swarmUiConfigured: boolean;
/** null when SwarmUI is not configured on the server. */
readonly swarmUiConnected: boolean | null;
}
export async function fetchGameStatus(): Promise<GameStatus> {
+18
View File
@@ -643,6 +643,24 @@ body {
margin-bottom: 16px;
}
.people__swarm-status {
margin: 0 0 12px;
font-size: 13px;
}
.people__swarm-status--ok {
color: var(--ok, #2e7d32);
}
.people__swarm-status--bad {
color: var(--danger, #c62828);
}
.people__swarm-status--pending,
.people__swarm-status--off {
color: var(--text-muted, #666);
}
.people__card-name {
margin: 0 0 2px;
font-size: 15px;
+21 -8
View File
@@ -70,18 +70,13 @@ export class ApplicantsDialog {
private busy = false;
private cardTab: PersonCardTab = 'overview';
private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private painted: PersonCard | null = null;
constructor(private readonly options: ApplicantsDialogOptions) {
this.staffing = options.staffing;
this.selectedId = options.selectedId ?? null;
void fetchGameStatus()
.then((status) => {
this.swarmConfigured = status.swarmUiConfigured;
})
.catch(() => {
this.swarmConfigured = false;
});
void this.refreshSwarmStatus();
this.error.hidden = true;
this.ageMinInput.min = '0';
this.ageMaxInput.min = '0';
@@ -325,15 +320,33 @@ export class ApplicantsDialog {
tab: this.cardTab,
onTabChange: (tab) => {
this.cardTab = tab;
if (this.painted !== null) {
if (tab === 'portrait') {
void this.refreshSwarmStatus();
} else if (this.painted !== null) {
this.paintCard(this.painted);
}
},
swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
});
this.appendHireIfNeeded(this.card);
}
private async refreshSwarmStatus(): Promise<void> {
try {
const status = await fetchGameStatus();
this.swarmConfigured = status.swarmUiConfigured;
this.swarmConnected = status.swarmUiConnected;
} catch {
this.swarmConfigured = false;
this.swarmConnected = null;
}
if (this.painted !== null) {
this.paintCard(this.painted);
}
}
private appendHireIfNeeded(parent: HTMLElement = this.card): void {
const personId = this.selectedId ?? this.painted?.id;
const applicant =
@@ -160,4 +160,17 @@ describe('renderPersonCard', () => {
portraitTab?.click();
expect(onTabChange).toHaveBeenCalledWith('portrait');
});
it('shows Swarm connection status on the portrait tab', () => {
setLocale('en');
const root = document.createElement('div');
renderPersonCard(root, card(), handlers({ tab: 'portrait', swarmConfigured: true, swarmConnected: true }));
expect(root.querySelector('.people__swarm-status--ok')?.textContent).toContain('connected');
root.replaceChildren();
renderPersonCard(root, card(), handlers({ tab: 'portrait', swarmConfigured: true, swarmConnected: false }));
expect(root.querySelector('.people__swarm-status--bad')?.textContent).toContain('unreachable');
});
});
+32 -2
View File
@@ -21,6 +21,8 @@ export interface PersonCardHandlers {
readonly portraitBusy?: 'avatar' | 'full' | null;
readonly portraitError?: string | null;
readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null;
}
export interface PersonCardMount {
@@ -122,6 +124,8 @@ function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCa
}
function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void {
parent.append(swarmStatusLine(handlers));
parent.append(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
parent.append(portraitPreview(card, handlers, 'avatar'));
parent.append(
@@ -129,7 +133,7 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
class: 'button button--small',
type: 'button',
text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'),
disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false,
disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers),
onClick: () => handlers.onGeneratePortrait?.('avatar'),
}),
);
@@ -141,13 +145,15 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
class: 'button button--small',
type: 'button',
text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'),
disabled: handlers.portraitBusy !== null || handlers.swarmConfigured === false,
disabled: handlers.portraitBusy !== null || !portraitGenerateEnabled(handlers),
onClick: () => handlers.onGeneratePortrait?.('full'),
}),
);
if (handlers.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') }));
} else if (handlers.swarmConfigured === true && handlers.swarmConnected === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitSwarmOffline') }));
}
if ((handlers.portraitError?.length ?? 0) > 0) {
@@ -155,6 +161,30 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
}
}
function portraitGenerateEnabled(handlers: PersonCardHandlers): boolean {
if (handlers.swarmConfigured !== true) {
return false;
}
return handlers.swarmConnected === true;
}
function swarmStatusLine(handlers: PersonCardHandlers): HTMLElement {
if (handlers.swarmConfigured === false) {
return el('p', { class: 'people__swarm-status people__swarm-status--off', text: t('peoplePortraitSwarmDisabled') });
}
if (handlers.swarmConnected === true) {
return el('p', { class: 'people__swarm-status people__swarm-status--ok', text: t('peoplePortraitSwarmConnected') });
}
if (handlers.swarmConnected === false) {
return el('p', { class: 'people__swarm-status people__swarm-status--bad', text: t('peoplePortraitSwarmDisconnected') });
}
return el('p', { class: 'people__swarm-status people__swarm-status--pending', text: t('peoplePortraitSwarmChecking') });
}
function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement {
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
if (hasImage && handlers.schoolId !== null) {
+22 -8
View File
@@ -15,6 +15,7 @@ export class PersonCardHost {
private portraitBusy: 'avatar' | 'full' | null = null;
private portraitError: string | null = null;
private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private schoolId: number | null = null;
private painted: PersonCard | null = null;
private mount: PersonCardMount | null = null;
@@ -25,13 +26,20 @@ export class PersonCardHost {
attach(schoolId: number): void {
this.schoolId = schoolId;
void fetchGameStatus()
.then((status) => {
this.swarmConfigured = status.swarmUiConfigured;
})
.catch(() => {
this.swarmConfigured = false;
});
void this.refreshSwarmStatus();
}
private async refreshSwarmStatus(): Promise<void> {
try {
const status = await fetchGameStatus();
this.swarmConfigured = status.swarmUiConfigured;
this.swarmConnected = status.swarmUiConnected;
} catch {
this.swarmConfigured = false;
this.swarmConnected = null;
}
this.refreshPainted();
}
detach(): void {
@@ -43,6 +51,7 @@ export class PersonCardHost {
this.tab = 'overview';
this.portraitBusy = null;
this.portraitError = null;
this.swarmConnected = null;
}
paint(
@@ -87,12 +96,17 @@ export class PersonCardHost {
tab: this.tab,
onTabChange: (tab) => {
this.tab = tab;
this.refreshPainted();
if (tab === 'portrait') {
void this.refreshSwarmStatus();
} else {
this.refreshPainted();
}
},
onGeneratePortrait: (kind) => void this.generate(kind),
portraitBusy: this.portraitBusy,
portraitError: this.portraitError,
swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
};
}