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.
This commit is contained in:
+2
-2
@@ -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
|
||||
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
|
||||
trying POST first.
|
||||
`GET /api/status` includes `swarmUiConfigured` and `swarmUiConnected` (`null` when not configured)
|
||||
so the client can disable generate buttons and show reachability without trying POST first.
|
||||
|
||||
### `GET /api/schools/{id}/staffing`
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
void this.refreshSwarmStatus();
|
||||
}
|
||||
|
||||
private async refreshSwarmStatus(): Promise<void> {
|
||||
try {
|
||||
const status = await fetchGameStatus();
|
||||
this.swarmConfigured = status.swarmUiConfigured;
|
||||
})
|
||||
.catch(() => {
|
||||
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;
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,33 @@ internal sealed class SwarmUiClient
|
||||
|
||||
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(
|
||||
string prompt,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,7 @@ builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
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<PortraitService>();
|
||||
|
||||
@@ -60,16 +61,19 @@ app.MapSchoolEndpoints();
|
||||
app.MapTimetableEndpoints();
|
||||
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 configured = !string.IsNullOrWhiteSpace(swarm.Value.BaseUrl);
|
||||
var connected = configured ? await swarmHealth.GetConnectedAsync(cancellationToken) : null;
|
||||
return new GameStatusResponse(
|
||||
loop.CurrentTick,
|
||||
loop.Options.TickRate,
|
||||
state.Schools.Count,
|
||||
state.MaxSchools,
|
||||
clients.Count,
|
||||
!string.IsNullOrWhiteSpace(swarm.Value.BaseUrl));
|
||||
configured,
|
||||
connected);
|
||||
})
|
||||
.WithName("GetGameStatus");
|
||||
|
||||
@@ -119,7 +123,8 @@ internal sealed record GameStatusResponse(
|
||||
int Schools,
|
||||
int MaxSchools,
|
||||
int Connections,
|
||||
bool SwarmUiConfigured);
|
||||
bool SwarmUiConfigured,
|
||||
bool? SwarmUiConnected);
|
||||
|
||||
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
||||
public partial class Program;
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"model": "pornmasterFlux2Klein_v4TurboFp8.safetensors",
|
||||
"steps": 8,
|
||||
"cfgScale": 1,
|
||||
"clipSkip": 1,
|
||||
"sampler": "euler",
|
||||
"seed": -1,
|
||||
"positive": "School portrait photograph, neutral background, natural lighting, realistic, sharp focus.",
|
||||
"negative": "nsfw, nude, naked, explicit, blurry, deformed, extra limbs, bad anatomy, watermark, text, logo",
|
||||
"model": "dreamshaperXL_lightningDPMSDE.safetensors",
|
||||
"steps": 4,
|
||||
"cfgScale": 2,
|
||||
"clipSkip": 2,
|
||||
"sampler": "DPM++ SDE Karras",
|
||||
"seed": 3346112079,
|
||||
"positive": "cinematic photo, realist detail, detailed character expressions, amazing quality, analog film grain, school portrait photograph, neutral background, natural lighting, realistic, sharp focus",
|
||||
"negative": "(low quality, worst quality:1.4), cgi, text, signature, watermark, extra limbs, nsfw, nude, naked, explicit, blurry, deformed, bad anatomy, logo",
|
||||
"avatar": {
|
||||
"width": 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": {
|
||||
"width": 768,
|
||||
"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]);
|
||||
}
|
||||
|
||||
[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
|
||||
{
|
||||
public List<string> Requests { get; } = [];
|
||||
|
||||
Reference in New Issue
Block a user