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
+2 -2
View File
@@ -333,8 +333,8 @@ at GET. SwarmUI is not configured when `SwarmUi:BaseUrl` is empty — `503` `swa
Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files Swarm errors are `502` `swarmui-unavailable`; a slow backend is `504` `swarmui-timeout`. Files
land under `saves/{id}.portraits/` and survive until the school is deleted. land under `saves/{id}.portraits/` and survive until the school is deleted.
`GET /api/status` includes `swarmUiConfigured` so the client can disable generate buttons without `GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
trying POST first. so the client can disable generate buttons and show reachability without trying POST first.
### `GET /api/schools/{id}/staffing` ### `GET /api/schools/{id}/staffing`
+10
View File
@@ -146,6 +146,11 @@ const ru = {
peoplePortraitGenerating: 'Генерация…', peoplePortraitGenerating: 'Генерация…',
peoplePortraitMissing: 'Ещё не сгенерировано.', peoplePortraitMissing: 'Ещё не сгенерировано.',
peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.', peoplePortraitUnavailable: 'SwarmUI не настроен на сервере.',
peoplePortraitSwarmDisabled: 'SwarmUI: не настроен',
peoplePortraitSwarmChecking: 'SwarmUI: проверка…',
peoplePortraitSwarmConnected: 'SwarmUI: подключён',
peoplePortraitSwarmDisconnected: 'SwarmUI: нет связи',
peoplePortraitSwarmOffline: 'SwarmUI недоступен — проверьте, что сервис запущен.',
peoplePortraitFailed: 'Не удалось сгенерировать портрет.', peoplePortraitFailed: 'Не удалось сгенерировать портрет.',
modeOverview: 'Обзор', modeOverview: 'Обзор',
@@ -381,6 +386,11 @@ const en: Messages = {
peoplePortraitGenerating: 'Generating…', peoplePortraitGenerating: 'Generating…',
peoplePortraitMissing: 'Not generated yet.', peoplePortraitMissing: 'Not generated yet.',
peoplePortraitUnavailable: 'SwarmUI is not configured on the server.', 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.', peoplePortraitFailed: 'Could not generate the portrait.',
modeOverview: 'Overview', modeOverview: 'Overview',
+2
View File
@@ -346,6 +346,8 @@ export interface GameStatus {
readonly maxSchools: number; readonly maxSchools: number;
readonly connections: number; readonly connections: number;
readonly swarmUiConfigured: boolean; readonly swarmUiConfigured: boolean;
/** null when SwarmUI is not configured on the server. */
readonly swarmUiConnected: boolean | null;
} }
export async function fetchGameStatus(): Promise<GameStatus> { export async function fetchGameStatus(): Promise<GameStatus> {
+18
View File
@@ -643,6 +643,24 @@ body {
margin-bottom: 16px; 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 { .people__card-name {
margin: 0 0 2px; margin: 0 0 2px;
font-size: 15px; font-size: 15px;
+21 -8
View File
@@ -70,18 +70,13 @@ export class ApplicantsDialog {
private busy = false; private busy = false;
private cardTab: PersonCardTab = 'overview'; private cardTab: PersonCardTab = 'overview';
private swarmConfigured = false; private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private painted: PersonCard | null = null; private painted: PersonCard | null = null;
constructor(private readonly options: ApplicantsDialogOptions) { constructor(private readonly options: ApplicantsDialogOptions) {
this.staffing = options.staffing; this.staffing = options.staffing;
this.selectedId = options.selectedId ?? null; this.selectedId = options.selectedId ?? null;
void fetchGameStatus() void this.refreshSwarmStatus();
.then((status) => {
this.swarmConfigured = status.swarmUiConfigured;
})
.catch(() => {
this.swarmConfigured = false;
});
this.error.hidden = true; this.error.hidden = true;
this.ageMinInput.min = '0'; this.ageMinInput.min = '0';
this.ageMaxInput.min = '0'; this.ageMaxInput.min = '0';
@@ -325,15 +320,33 @@ export class ApplicantsDialog {
tab: this.cardTab, tab: this.cardTab,
onTabChange: (tab) => { onTabChange: (tab) => {
this.cardTab = tab; this.cardTab = tab;
if (this.painted !== null) { if (tab === 'portrait') {
void this.refreshSwarmStatus();
} else if (this.painted !== null) {
this.paintCard(this.painted); this.paintCard(this.painted);
} }
}, },
swarmConfigured: this.swarmConfigured, swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
}); });
this.appendHireIfNeeded(this.card); 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 { private appendHireIfNeeded(parent: HTMLElement = this.card): void {
const personId = this.selectedId ?? this.painted?.id; const personId = this.selectedId ?? this.painted?.id;
const applicant = const applicant =
@@ -160,4 +160,17 @@ describe('renderPersonCard', () => {
portraitTab?.click(); portraitTab?.click();
expect(onTabChange).toHaveBeenCalledWith('portrait'); 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 portraitBusy?: 'avatar' | 'full' | null;
readonly portraitError?: string | null; readonly portraitError?: string | null;
readonly swarmConfigured?: boolean; readonly swarmConfigured?: boolean;
/** null while checking or when SwarmUI is not configured. */
readonly swarmConnected?: boolean | null;
} }
export interface PersonCardMount { export interface PersonCardMount {
@@ -122,6 +124,8 @@ function mountOverview(parent: HTMLElement, card: PersonCard, handlers: PersonCa
} }
function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCardHandlers): void { 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(el('h4', { class: 'people__section-title', text: t('peoplePortraitAvatar') }));
parent.append(portraitPreview(card, handlers, 'avatar')); parent.append(portraitPreview(card, handlers, 'avatar'));
parent.append( parent.append(
@@ -129,7 +133,7 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
class: 'button button--small', class: 'button button--small',
type: 'button', type: 'button',
text: handlers.portraitBusy === 'avatar' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateAvatar'), 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'), onClick: () => handlers.onGeneratePortrait?.('avatar'),
}), }),
); );
@@ -141,13 +145,15 @@ function mountPortrait(parent: HTMLElement, card: PersonCard, handlers: PersonCa
class: 'button button--small', class: 'button button--small',
type: 'button', type: 'button',
text: handlers.portraitBusy === 'full' ? t('peoplePortraitGenerating') : t('peoplePortraitGenerateFull'), 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'), onClick: () => handlers.onGeneratePortrait?.('full'),
}), }),
); );
if (handlers.swarmConfigured === false) { if (handlers.swarmConfigured === false) {
parent.append(el('p', { class: 'panel__empty', text: t('peoplePortraitUnavailable') })); 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) { 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 { function portraitPreview(card: PersonCard, handlers: PersonCardHandlers, kind: 'avatar' | 'full'): HTMLElement {
const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody; const hasImage = kind === 'avatar' ? card.hasAvatar : card.hasFullBody;
if (hasImage && handlers.schoolId !== null) { if (hasImage && handlers.schoolId !== null) {
+22 -8
View File
@@ -15,6 +15,7 @@ export class PersonCardHost {
private portraitBusy: 'avatar' | 'full' | null = null; private portraitBusy: 'avatar' | 'full' | null = null;
private portraitError: string | null = null; private portraitError: string | null = null;
private swarmConfigured = false; private swarmConfigured = false;
private swarmConnected: boolean | null = null;
private schoolId: number | null = null; private schoolId: number | null = null;
private painted: PersonCard | null = null; private painted: PersonCard | null = null;
private mount: PersonCardMount | null = null; private mount: PersonCardMount | null = null;
@@ -25,13 +26,20 @@ export class PersonCardHost {
attach(schoolId: number): void { attach(schoolId: number): void {
this.schoolId = schoolId; this.schoolId = schoolId;
void fetchGameStatus() void this.refreshSwarmStatus();
.then((status) => { }
this.swarmConfigured = status.swarmUiConfigured;
}) private async refreshSwarmStatus(): Promise<void> {
.catch(() => { try {
this.swarmConfigured = false; const status = await fetchGameStatus();
}); this.swarmConfigured = status.swarmUiConfigured;
this.swarmConnected = status.swarmUiConnected;
} catch {
this.swarmConfigured = false;
this.swarmConnected = null;
}
this.refreshPainted();
} }
detach(): void { detach(): void {
@@ -43,6 +51,7 @@ export class PersonCardHost {
this.tab = 'overview'; this.tab = 'overview';
this.portraitBusy = null; this.portraitBusy = null;
this.portraitError = null; this.portraitError = null;
this.swarmConnected = null;
} }
paint( paint(
@@ -87,12 +96,17 @@ export class PersonCardHost {
tab: this.tab, tab: this.tab,
onTabChange: (tab) => { onTabChange: (tab) => {
this.tab = tab; this.tab = tab;
this.refreshPainted(); if (tab === 'portrait') {
void this.refreshSwarmStatus();
} else {
this.refreshPainted();
}
}, },
onGeneratePortrait: (kind) => void this.generate(kind), onGeneratePortrait: (kind) => void this.generate(kind),
portraitBusy: this.portraitBusy, portraitBusy: this.portraitBusy,
portraitError: this.portraitError, portraitError: this.portraitError,
swarmConfigured: this.swarmConfigured, swarmConfigured: this.swarmConfigured,
swarmConnected: this.swarmConnected,
}; };
} }
+27
View File
@@ -28,6 +28,33 @@ internal sealed class SwarmUiClient
public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl); public bool IsConfigured => !string.IsNullOrWhiteSpace(_options.BaseUrl);
/// <summary>Opens a session to verify SwarmUI responds; does not generate an image.</summary>
public async Task<bool> ProbeAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
if (!IsConfigured)
{
return false;
}
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutSource.CancelAfter(timeout);
try
{
await RefreshSessionAsync(timeoutSource.Token);
return true;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return false;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SwarmUI probe failed.");
return false;
}
}
public async Task<byte[]> GenerateAsync( public async Task<byte[]> GenerateAsync(
string prompt, string prompt,
string negativePrompt, string negativePrompt,
@@ -0,0 +1,43 @@
namespace HSchool.Server.Game;
/// <summary>Caches a lightweight SwarmUI reachability probe so /api/status stays cheap.</summary>
internal sealed class SwarmUiHealthService(SwarmUiClient client)
{
private static readonly TimeSpan CacheTtl = TimeSpan.FromSeconds(15);
private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(5);
private readonly SemaphoreSlim _gate = new(1, 1);
private bool? _lastConnected;
private DateTime _lastCheckUtc = DateTime.MinValue;
/// <summary><c>null</c> when SwarmUI is not configured.</summary>
public async Task<bool?> GetConnectedAsync(CancellationToken cancellationToken)
{
if (!client.IsConfigured)
{
return null;
}
if (DateTime.UtcNow - _lastCheckUtc < CacheTtl && _lastConnected.HasValue)
{
return _lastConnected;
}
await _gate.WaitAsync(cancellationToken);
try
{
if (DateTime.UtcNow - _lastCheckUtc < CacheTtl && _lastConnected.HasValue)
{
return _lastConnected;
}
_lastConnected = await client.ProbeAsync(ProbeTimeout, cancellationToken);
_lastCheckUtc = DateTime.UtcNow;
return _lastConnected;
}
finally
{
_gate.Release();
}
}
}
+8 -3
View File
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<GameSocketHandler>();
builder.Services.AddSingleton<GameLoopService>(); builder.Services.AddSingleton<GameLoopService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
builder.Services.AddSwarmUi(builder.Configuration); builder.Services.AddSwarmUi(builder.Configuration);
builder.Services.AddSingleton<SwarmUiHealthService>();
builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings"))); builder.Services.AddSingleton(sp => SwarmUiSettings.Load(sp.GetRequiredService<IHostEnvironment>(), sp.GetRequiredService<ILoggerFactory>().CreateLogger("SwarmUiSettings")));
builder.Services.AddSingleton<PortraitService>(); builder.Services.AddSingleton<PortraitService>();
@@ -60,16 +61,19 @@ app.MapSchoolEndpoints();
app.MapTimetableEndpoints(); app.MapTimetableEndpoints();
app.MapModEndpoints(); app.MapModEndpoints();
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> swarm) => app.MapGet("/api/status", async (GameLoopService loop, ClientRegistry clients, IOptions<SwarmUiOptions> swarm, SwarmUiHealthService swarmHealth, CancellationToken cancellationToken) =>
{ {
var state = loop.SchoolsState; var state = loop.SchoolsState;
var configured = !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl);
var connected = configured ? await swarmHealth.GetConnectedAsync(cancellationToken) : null;
return new GameStatusResponse( return new GameStatusResponse(
loop.CurrentTick, loop.CurrentTick,
loop.Options.TickRate, loop.Options.TickRate,
state.Schools.Count, state.Schools.Count,
state.MaxSchools, state.MaxSchools,
clients.Count, clients.Count,
!string.IsNullOrWhiteSpace(swarm.Value.BaseUrl)); configured,
connected);
}) })
.WithName("GetGameStatus"); .WithName("GetGameStatus");
@@ -119,7 +123,8 @@ internal sealed record GameStatusResponse(
int Schools, int Schools,
int MaxSchools, int MaxSchools,
int Connections, int Connections,
bool SwarmUiConfigured); bool SwarmUiConfigured,
bool? SwarmUiConnected);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary> /// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program; public partial class Program;
+10 -10
View File
@@ -1,20 +1,20 @@
{ {
"model": "pornmasterFlux2Klein_v4TurboFp8.safetensors", "model": "dreamshaperXL_lightningDPMSDE.safetensors",
"steps": 8, "steps": 4,
"cfgScale": 1, "cfgScale": 2,
"clipSkip": 1, "clipSkip": 2,
"sampler": "euler", "sampler": "DPM++ SDE Karras",
"seed": -1, "seed": 3346112079,
"positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.", "positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
"negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo", "negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
"avatar": { "avatar": {
"width": 512, "width": 512,
"height": 512, "height": 512,
"positive": "Head and shoulders portrait, facing the camera, upper body visible." "positive": "close up, head and shoulders portrait, facing the camera, upper body visible."
}, },
"fullBody": { "fullBody": {
"width": 768, "width": 768,
"height": 1024, "height": 1024,
"positive": "Full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible." "positive": "full body standing portrait, head to toe visible, neutral pose, current outfit clearly visible."
} }
} }
@@ -34,6 +34,44 @@ public class SwarmUiClientTests
Assert.Contains("/API/GenerateText2Image", handler.Requests[1]); Assert.Contains("/API/GenerateText2Image", handler.Requests[1]);
} }
[Fact]
public async Task ProbeAsync_ReturnsTrueWhenSessionOpens()
{
var handler = new FakeHandler();
var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") };
var client = new SwarmUiClient(
http,
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance);
var ok = await client.ProbeAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.True(ok);
Assert.Single(handler.Requests);
Assert.Contains("/API/GetNewSession", handler.Requests[0]);
}
[Fact]
public async Task ProbeAsync_ReturnsFalseWhenUnreachable()
{
var handler = new FailingHandler();
var http = new HttpClient(handler) { BaseAddress = new Uri("http://swarm.test/") };
var client = new SwarmUiClient(
http,
Options.Create(new SwarmUiOptions { BaseUrl = "http://swarm.test", TimeoutSeconds = 30 }),
NullLogger<SwarmUiClient>.Instance);
var ok = await client.ProbeAsync(TimeSpan.FromSeconds(5), CancellationToken.None);
Assert.False(ok);
}
private sealed class FailingHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.ServiceUnavailable));
}
private sealed class FakeHandler : HttpMessageHandler private sealed class FakeHandler : HttpMessageHandler
{ {
public List<string> Requests { get; } = []; public List<string> Requests { get; } = [];