Skip to content

Commit 3dd8602

Browse files
committed
feat: moderator admin UI to status page
Introduce a moderator-only admin surface on the public /status page to let moderators manage components, incidents, maintenance windows and alerts. - Add ModeratorStatusPanel and four child surfaces: component overrides, incident manager, maintenance manager, and alerts feed (TS + HTML for each). - Add status-moderator.constants with enums/utility helpers (severity badges, datetime conversions). - Implement StatusAdminService and accompanying spec to perform privileged admin API calls used by the moderator UI. - Wire the moderator panel into the main status page (render when isModerator()). - Update shared types (src/app/types/status.ts) and i18n / styles to support new UI. - Use Angular signals, effects and a reloadToken mechanism so children can trigger refetches; show toast messages and handle API errors. This enables in-place moderation workflows (promote alerts, set/clear overrides, create/update/resolve incidents, schedule/cancel maintenance) and ensures the public status sections refresh after writes.
1 parent d0599d0 commit 3dd8602

19 files changed

Lines changed: 2663 additions & 2 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<section class="section-secondary mod-status">
2+
<div class="container-content mod-status__inner">
3+
<div class="mod-status__header">
4+
<div>
5+
<div class="mod-status__eyebrow">
6+
<app-icon name="lock-closed" />
7+
{{ "status.moderator.eyebrow" | transloco }}
8+
</div>
9+
<h2 class="heading-section-lg">
10+
{{ "status.moderator.title" | transloco }}
11+
</h2>
12+
<p class="text-description">
13+
{{ "status.moderator.intro" | transloco }}
14+
</p>
15+
</div>
16+
<button
17+
type="button"
18+
class="btn-muted btn-sm"
19+
(click)="onChanged()"
20+
[attr.aria-label]="'status.moderator.refresh_all' | transloco"
21+
>
22+
<app-icon name="refresh-alt" />
23+
{{ "status.moderator.refresh_all" | transloco }}
24+
</button>
25+
</div>
26+
27+
<app-status-component-overrides
28+
[components]="components()"
29+
[loaded]="componentsLoaded()"
30+
(changed)="onChanged()"
31+
/>
32+
33+
<app-status-incident-manager
34+
[components]="components()"
35+
[reloadToken]="reloadToken()"
36+
(changed)="onChanged()"
37+
/>
38+
39+
<app-status-maintenance-manager
40+
[components]="components()"
41+
[reloadToken]="reloadToken()"
42+
(changed)="onChanged()"
43+
/>
44+
45+
<app-status-alerts-feed
46+
[components]="components()"
47+
[reloadToken]="reloadToken()"
48+
(changed)="onChanged()"
49+
/>
50+
</div>
51+
</section>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
ChangeDetectionStrategy,
3+
Component,
4+
DestroyRef,
5+
afterNextRender,
6+
inject,
7+
signal,
8+
} from '@angular/core';
9+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
10+
import { TranslocoPipe } from '@jsverse/transloco';
11+
import { IconComponent } from '../../../components/icon/icon.component';
12+
import { StatusAdminService } from '../../../services/status-admin.service';
13+
import { ToastService } from '../../../services/toast.service';
14+
import { extractApiError } from '../../../helper/extract-api-error';
15+
import { AdminComponent } from '../../../types/status';
16+
import { StatusComponentOverridesComponent } from './status-component-overrides.component';
17+
import { StatusIncidentManagerComponent } from './status-incident-manager.component';
18+
import { StatusMaintenanceManagerComponent } from './status-maintenance-manager.component';
19+
import { StatusAlertsFeedComponent } from './status-alerts-feed.component';
20+
21+
/**
22+
* Moderator-only console rendered inline on the public `/status` page when an
23+
* AI Horde moderator is logged in. It owns the shared component list (used as
24+
* the affected-component picker by every child surface) and hosts the four
25+
* privileged surfaces: component overrides, incident management, maintenance
26+
* windows, and the alert feed.
27+
*
28+
* A `reloadToken` is bumped whenever any child performs a write, which the
29+
* children watch to refetch; writes also invalidate the shared cache, so the
30+
* public sections above update on their next read.
31+
*/
32+
@Component({
33+
selector: 'app-moderator-status-panel',
34+
imports: [
35+
TranslocoPipe,
36+
IconComponent,
37+
StatusComponentOverridesComponent,
38+
StatusIncidentManagerComponent,
39+
StatusMaintenanceManagerComponent,
40+
StatusAlertsFeedComponent,
41+
],
42+
templateUrl: './moderator-status-panel.component.html',
43+
changeDetection: ChangeDetectionStrategy.OnPush,
44+
})
45+
export class ModeratorStatusPanelComponent {
46+
private readonly admin = inject(StatusAdminService);
47+
private readonly toast = inject(ToastService);
48+
private readonly destroyRef = inject(DestroyRef);
49+
50+
public readonly components = signal<AdminComponent[]>([]);
51+
public readonly componentsLoaded = signal(false);
52+
53+
/** Bumped after any child write so every surface refetches fresh data. */
54+
public readonly reloadToken = signal(0);
55+
56+
constructor() {
57+
afterNextRender(() => this.loadComponents());
58+
}
59+
60+
public loadComponents(): void {
61+
this.admin
62+
.getComponents()
63+
.pipe(takeUntilDestroyed(this.destroyRef))
64+
.subscribe({
65+
next: (components) => {
66+
this.components.set(components);
67+
this.componentsLoaded.set(true);
68+
},
69+
error: (err: unknown) => {
70+
this.componentsLoaded.set(true);
71+
this.toast.show(
72+
'error',
73+
extractApiError(err, 'Failed to load status components'),
74+
);
75+
},
76+
});
77+
}
78+
79+
/** A child wrote something; refresh shared components and signal children. */
80+
public onChanged(): void {
81+
this.loadComponents();
82+
this.reloadToken.update((value) => value + 1);
83+
}
84+
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
<section class="mod-section">
2+
<div class="mod-section__head">
3+
<h3 class="mod-section__title">
4+
{{ "status.moderator.alerts.title" | transloco }}
5+
</h3>
6+
<label class="mod-toggle">
7+
<input
8+
type="checkbox"
9+
[checked]="unpromotedOnly()"
10+
(change)="unpromotedOnly.set($any($event.target).checked)"
11+
/>
12+
{{ "status.moderator.alerts.unpromoted_only" | transloco }}
13+
</label>
14+
</div>
15+
<p class="mod-section__hint">
16+
{{ "status.moderator.alerts.hint" | transloco }}
17+
</p>
18+
19+
@if (!loaded()) {
20+
<div class="mod-empty">{{ "status.loading" | transloco }}</div>
21+
} @else if (visibleAlerts().length === 0) {
22+
<div class="mod-empty">
23+
{{ "status.moderator.alerts.empty" | transloco }}
24+
</div>
25+
} @else {
26+
<div class="mod-list">
27+
@for (alert of visibleAlerts(); track alert.fingerprint) {
28+
<div class="mod-row">
29+
<div class="mod-row__main">
30+
<div class="mod-row__title">
31+
{{ alert.alertname }}
32+
@if (alert.severity) {
33+
<span class="badge badge-secondary badge-sm">
34+
{{ alert.severity }}
35+
</span>
36+
}
37+
@if (alert.promoted_incident_id) {
38+
<span class="badge badge-info badge-sm">
39+
{{ "status.moderator.alerts.promoted" | transloco }}
40+
</span>
41+
}
42+
</div>
43+
@if (alert.summary) {
44+
<div class="mod-row__meta">{{ alert.summary }}</div>
45+
}
46+
<div class="mod-row__meta">
47+
@if (alert.component) {
48+
{{ alert.component }} ·
49+
}
50+
{{ alert.state }} · {{ alert.started_at | date: "short" }}
51+
</div>
52+
</div>
53+
<div class="mod-row__actions">
54+
<button
55+
type="button"
56+
class="btn-muted btn-sm"
57+
[disabled]="alert.promoted_incident_id !== null"
58+
(click)="openPromote(alert)"
59+
>
60+
{{ "status.moderator.alerts.promote" | transloco }}
61+
</button>
62+
</div>
63+
</div>
64+
}
65+
</div>
66+
}
67+
</section>
68+
69+
<app-admin-dialog
70+
[open]="promoting() !== null"
71+
[title]="'status.moderator.alerts.dialog_title' | transloco"
72+
[confirmLabel]="'status.moderator.alerts.promote' | transloco"
73+
[cancelLabel]="'status.moderator.cancel' | transloco"
74+
[loading]="submitting()"
75+
(confirmed)="submit()"
76+
(cancelled)="closeDialog()"
77+
>
78+
<div class="mod-form">
79+
<div class="mod-form__field">
80+
<label class="form-label" for="promote-title">
81+
{{ "status.moderator.incidents.field_title" | transloco }}
82+
</label>
83+
<input
84+
id="promote-title"
85+
type="text"
86+
class="form-input"
87+
[ngModel]="formTitle()"
88+
(ngModelChange)="formTitle.set($event)"
89+
/>
90+
</div>
91+
<div class="mod-form__field">
92+
<label class="form-label" for="promote-severity">
93+
{{ "status.moderator.incidents.field_severity" | transloco }}
94+
</label>
95+
<select
96+
id="promote-severity"
97+
class="form-input"
98+
[ngModel]="formSeverity()"
99+
(ngModelChange)="formSeverity.set($event)"
100+
>
101+
@for (option of severities; track option) {
102+
<option [value]="option">
103+
{{ "status.severity." + option | transloco }}
104+
</option>
105+
}
106+
</select>
107+
</div>
108+
<div class="mod-form__field">
109+
<label class="form-label" for="promote-body">
110+
{{ "status.moderator.incidents.field_body" | transloco }}
111+
</label>
112+
<textarea
113+
id="promote-body"
114+
rows="3"
115+
class="form-input"
116+
[ngModel]="formBody()"
117+
(ngModelChange)="formBody.set($event)"
118+
></textarea>
119+
</div>
120+
<div class="mod-form__field">
121+
<span class="form-label">
122+
{{ "status.moderator.incidents.field_affected" | transloco }}
123+
</span>
124+
<div class="mod-checks">
125+
@for (component of components(); track component.id) {
126+
<label class="mod-check">
127+
<input
128+
type="checkbox"
129+
[checked]="isAffected(component.id)"
130+
(change)="toggleAffected(component.id)"
131+
/>
132+
{{ component.name }}
133+
</label>
134+
}
135+
</div>
136+
</div>
137+
</div>
138+
</app-admin-dialog>

0 commit comments

Comments
 (0)