Add dashboard health restart action (#38)

This commit is contained in:
Eyejoker
2026-04-27 03:52:24 +09:00
committed by GitHub
parent 38c3430156
commit 76b0ab754b
6 changed files with 670 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import {
createScheduledTask,
fetchDashboardData,
runInboxAction,
runServiceAction,
runScheduledTaskAction,
sendRoomMessage,
updateScheduledTask,
@@ -48,6 +49,7 @@ type TaskActionKey =
| `${string}:edit`
| `${string}:${DashboardTaskAction}`;
type InboxActionKey = `${string}:${DashboardInboxAction}`;
type ServiceActionKey = 'stack:restart';
type InboxFilter = 'all' | InboxItem['kind'];
type HealthLevel = 'ok' | 'stale' | 'down';
@@ -913,13 +915,18 @@ function InboxPanel({
function HealthPanel({
data,
locale,
onRestartStack,
serviceActionKey,
t,
}: {
data: DashboardState;
locale: Locale;
onRestartStack: () => void;
serviceActionKey: ServiceActionKey | null;
t: Messages;
}) {
const services = data.overview.services;
const restarts = data.overview.operations?.serviceRestarts ?? [];
const serviceLevels = services.map((service) => ({
service,
level: serviceHealthLevel(service, data.overview.generatedAt),
@@ -981,6 +988,71 @@ function HealthPanel({
</div>
</section>
<section className="health-actions" aria-label={t.health.restart}>
<div>
<span className="eyebrow">{t.health.restart}</span>
<strong>{t.health.restartStack}</strong>
<small>{t.health.restartHint}</small>
</div>
<button
disabled={serviceActionKey === 'stack:restart'}
onClick={onRestartStack}
type="button"
>
{serviceActionKey === 'stack:restart'
? t.health.restarting
: t.health.restartStack}
</button>
</section>
{restarts.length > 0 ? (
<details className="health-restart-log">
<summary>
{t.health.restartLog}
<strong>{restarts.length}</strong>
</summary>
<div className="health-restart-list">
{restarts.map((restart) => {
const pill =
restart.status === 'success'
? 'ok'
: restart.status === 'failed'
? 'error'
: 'stale';
return (
<article className="health-restart-record" key={restart.id}>
<div>
<small>{t.health.restartTarget}</small>
<strong>{restart.target}</strong>
</div>
<span
aria-label={`${t.health.restartStatus}: ${restart.status}`}
className={`pill pill-${pill}`}
>
{restart.status}
</span>
<div>
<small>{t.health.restartRequested}</small>
<strong>{formatDate(restart.requestedAt, locale)}</strong>
</div>
<div>
<small>{t.health.restartServices}</small>
<strong>
{restart.services.length > 0
? restart.services.join(', ')
: '-'}
</strong>
</div>
{restart.error ? (
<p className="health-restart-error">{restart.error}</p>
) : null}
</article>
);
})}
</div>
</details>
) : null}
{services.length === 0 ? (
<EmptyState>{t.service.empty}</EmptyState>
) : affectedServices.length === 0 ? null : (
@@ -1705,6 +1777,8 @@ function App() {
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
null,
);
const [serviceActionKey, setServiceActionKey] =
useState<ServiceActionKey | null>(null);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
const t = messages[locale];
@@ -1809,6 +1883,27 @@ function App() {
}
}
async function handleServiceRestart() {
if (
typeof window !== 'undefined' &&
!window.confirm(t.health.confirmRestart)
) {
return;
}
setServiceActionKey('stack:restart');
try {
await runServiceAction('stack', 'restart', {
requestId: makeClientRequestId(),
});
await refresh(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setServiceActionKey(null);
}
}
async function handleRoomMessage(
roomJid: string,
text: string,
@@ -1946,7 +2041,13 @@ function App() {
<h2>{t.panels.health}</h2>
<span>{t.panels.healthSignals}</span>
</div>
<HealthPanel data={data} locale={locale} t={t} />
<HealthPanel
data={data}
locale={locale}
onRestartStack={() => void handleServiceRestart()}
serviceActionKey={serviceActionKey}
t={t}
/>
</section>
) : null}

View File

@@ -35,6 +35,9 @@ export interface DashboardOverview {
}>;
fetchedAt: string | null;
};
operations?: {
serviceRestarts: DashboardServiceRestart[];
};
inbox: Array<{
id: string;
groupKey: string;
@@ -62,6 +65,16 @@ export interface DashboardOverview {
}>;
}
export interface DashboardServiceRestart {
id: string;
target: 'stack';
requestedAt: string;
completedAt: string | null;
status: 'running' | 'success' | 'failed';
services: string[];
error?: string;
}
export interface StatusSnapshot {
serviceId: string;
agentType: string;
@@ -102,6 +115,7 @@ export interface DashboardTask {
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
export type DashboardServiceAction = 'restart';
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
export type DashboardTaskContextMode = 'group' | 'isolated';
@@ -232,6 +246,21 @@ export async function runInboxAction(
});
}
export async function runServiceAction(
serviceId: 'stack',
action: DashboardServiceAction,
options: { requestId?: string } = {},
): Promise<{
ok: true;
duplicate?: boolean;
restart: DashboardServiceRestart;
}> {
return postJson(`/api/services/${encodeURIComponent(serviceId)}/actions`, {
action,
requestId: options.requestId,
});
}
export async function sendRoomMessage(
roomJid: string,
text: string,

View File

@@ -72,6 +72,16 @@ export interface Messages {
queue: string;
ciFailures: string;
affectedServices: string;
restart: string;
restartStack: string;
restarting: string;
restartHint: string;
confirmRestart: string;
restartLog: string;
restartTarget: string;
restartStatus: string;
restartRequested: string;
restartServices: string;
levels: {
ok: string;
stale: string;
@@ -323,6 +333,16 @@ export const messages = {
queue: '큐',
ciFailures: 'CI 실패',
affectedServices: '이상 서비스',
restart: 'Restart',
restartStack: 'Restart stack',
restarting: 'Restarting...',
restartHint: '서비스 전체 재시작',
confirmRestart: 'Restart stack now?',
restartLog: 'Restart log',
restartTarget: 'Target',
restartStatus: 'Status',
restartRequested: 'Requested',
restartServices: 'Services',
levels: {
ok: '정상',
stale: '주의',
@@ -558,6 +578,16 @@ export const messages = {
queue: 'Queue',
ciFailures: 'CI failures',
affectedServices: 'Affected services',
restart: 'Restart',
restartStack: 'Restart stack',
restarting: 'Restarting...',
restartHint: 'Restart all services',
confirmRestart: 'Restart stack now?',
restartLog: 'Restart log',
restartTarget: 'Target',
restartStatus: 'Status',
restartRequested: 'Requested',
restartServices: 'Services',
levels: {
ok: 'OK',
stale: 'Watch',
@@ -793,6 +823,16 @@ export const messages = {
queue: '队列',
ciFailures: 'CI 失败',
affectedServices: '异常服务',
restart: '重启',
restartStack: '重启 stack',
restarting: '重启中...',
restartHint: '重启全部服务',
confirmRestart: '现在重启 stack?',
restartLog: '重启记录',
restartTarget: '目标',
restartStatus: '状态',
restartRequested: '请求',
restartServices: '服务',
levels: {
ok: '正常',
stale: '关注',
@@ -1028,6 +1068,16 @@ export const messages = {
queue: 'キュー',
ciFailures: 'CI失敗',
affectedServices: '異常サービス',
restart: '再起動',
restartStack: 'Stack再起動',
restarting: '再起動中...',
restartHint: '全サービス再起動',
confirmRestart: 'Stackを再起動しますか?',
restartLog: '再起動ログ',
restartTarget: '対象',
restartStatus: '状態',
restartRequested: '要求',
restartServices: 'サービス',
levels: {
ok: '正常',
stale: '注意',

View File

@@ -851,9 +851,12 @@ progress::-moz-progress-bar {
.inbox-summary div,
.health-signals div,
.health-overview,
.health-actions,
.inbox-card,
.health-service,
.health-service-details {
.health-service-details,
.health-restart-log,
.health-restart-record {
border: 1px solid rgba(43, 55, 38, 0.1);
border-radius: 18px;
background: rgba(255, 250, 240, 0.64);
@@ -1018,6 +1021,100 @@ progress::-moz-progress-bar {
letter-spacing: -0.04em;
}
.health-actions {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
padding: 12px;
}
.health-actions > div {
display: grid;
min-width: 0;
gap: 3px;
}
.health-actions strong,
.health-actions small {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.health-actions small {
color: var(--muted);
}
.health-actions button {
min-height: 44px;
flex: none;
}
.health-restart-log {
padding: 10px;
}
.health-restart-log summary {
display: flex;
min-height: 34px;
cursor: pointer;
align-items: center;
justify-content: space-between;
color: var(--bg-ink);
font-size: 13px;
font-weight: 900;
list-style: none;
}
.health-restart-log summary::-webkit-details-marker {
display: none;
}
.health-restart-log[open] summary {
margin-bottom: 8px;
}
.health-restart-list {
display: grid;
gap: 8px;
}
.health-restart-record {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
}
.health-restart-record > div {
min-width: 0;
}
.health-restart-record small,
.health-restart-record strong {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.health-restart-record small {
color: var(--muted);
}
.health-restart-error {
grid-column: 1 / -1;
margin: 0;
color: #7c2518;
overflow-wrap: anywhere;
font-size: 12px;
font-weight: 800;
}
.health-down {
border-color: rgba(183, 71, 52, 0.32);
}
@@ -1823,6 +1920,23 @@ progress::-moz-progress-bar {
gap: 8px;
}
.health-actions {
display: grid;
}
.health-actions button {
width: 100%;
}
.health-restart-record {
grid-template-columns: minmax(0, 1fr) auto;
}
.health-restart-record > div:nth-of-type(2),
.health-restart-record > div:nth-of-type(3) {
grid-column: 1 / -1;
}
.health-service {
grid-template-columns: minmax(0, 1fr) auto;
}
@@ -1930,6 +2044,10 @@ progress::-moz-progress-bar {
grid-template-columns: 1fr;
}
.health-restart-record {
grid-template-columns: 1fr;
}
.task-time-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}

View File

@@ -1019,6 +1019,162 @@ describe('web dashboard server handler', () => {
expect(noQueue.status).toBe(503);
});
it('restarts the service stack through the health action endpoint', async () => {
let restartCalls = 0;
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
restartServiceStack: () => {
restartCalls += 1;
return ['ejclaw'];
},
now: () => '2026-04-26T05:30:00.000Z',
});
const restart = () =>
handler(
new Request('http://localhost/api/services/stack/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: 'restart',
requestId: 'stack-restart-1',
}),
}),
);
const response = await restart();
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
ok: true,
restart: {
id: 'web-restart-stack-restart-1',
target: 'stack',
requestedAt: '2026-04-26T05:30:00.000Z',
completedAt: '2026-04-26T05:30:00.000Z',
status: 'success',
services: ['ejclaw'],
},
});
expect(restartCalls).toBe(1);
const duplicate = await restart();
expect(duplicate.status).toBe(200);
await expect(duplicate.json()).resolves.toMatchObject({
ok: true,
duplicate: true,
restart: {
id: 'web-restart-stack-restart-1',
status: 'success',
},
});
expect(restartCalls).toBe(1);
const overview = await handler(
new Request('http://localhost/api/overview'),
);
expect(overview.status).toBe(200);
await expect(overview.json()).resolves.toMatchObject({
operations: {
serviceRestarts: [
{
id: 'web-restart-stack-restart-1',
status: 'success',
services: ['ejclaw'],
},
],
},
});
});
it('records failed service stack restarts', async () => {
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
restartServiceStack: () => {
throw new Error('systemctl failed');
},
now: () => '2026-04-26T05:35:00.000Z',
});
const response = await handler(
new Request('http://localhost/api/services/stack/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
action: 'restart',
requestId: 'stack-restart-fail',
}),
}),
);
expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({
error: 'systemctl failed',
restart: {
id: 'web-restart-stack-restart-fail',
target: 'stack',
status: 'failed',
error: 'systemctl failed',
},
});
const overview = await handler(
new Request('http://localhost/api/overview'),
);
await expect(overview.json()).resolves.toMatchObject({
operations: {
serviceRestarts: [
{
id: 'web-restart-stack-restart-fail',
status: 'failed',
error: 'systemctl failed',
},
],
},
});
});
it('rejects invalid service restart requests', async () => {
let restartCalls = 0;
const handler = createWebDashboardHandler({
readStatusSnapshots: () => [],
getTasks: () => [],
getPairedTasks: () => [],
restartServiceStack: () => {
restartCalls += 1;
return ['ejclaw'];
},
});
const invalidAction = await handler(
new Request('http://localhost/api/services/stack/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'stop' }),
}),
);
expect(invalidAction.status).toBe(400);
const invalidTarget = await handler(
new Request('http://localhost/api/services/ejclaw/actions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ action: 'restart' }),
}),
);
expect(invalidTarget.status).toBe(400);
const wrongMethod = await handler(
new Request('http://localhost/api/services/stack/actions', {
method: 'GET',
}),
);
expect(wrongMethod.status).toBe(405);
expect(restartCalls).toBe(0);
});
it('injects room messages and queues room work from the web dashboard', async () => {
const messages: NewMessage[] = [];
const metadata: Array<{

View File

@@ -1,5 +1,6 @@
import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
@@ -39,7 +40,13 @@ import {
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
const SERVICE_RESTART_LOG_LIMIT = 20;
const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service';
const WEB_TASK_PROMPT_MAX_LENGTH = 8000;
const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.';
const UNIT_NOT_FOUND_PATTERN =
/(Unit .* not found|Could not find the requested service|not-found)/i;
type PairedFollowUpTask = Pick<
PairedTask,
@@ -54,6 +61,16 @@ type WebPairedFollowUpScheduler = (args: {
enqueue: () => void;
}) => boolean;
export interface ServiceRestartRecord {
id: string;
target: 'stack';
requestedAt: string;
completedAt: string | null;
status: 'running' | 'success' | 'failed';
services: string[];
error?: string;
}
export interface WebDashboardHandlerOptions {
staticDir?: string;
statusMaxAgeMs?: number;
@@ -113,6 +130,7 @@ export interface WebDashboardHandlerOptions {
hasMessage?: (chatJid: string, id: string) => boolean;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
nudgeScheduler?: () => void;
restartServiceStack?: () => string[];
now?: () => string;
}
@@ -194,6 +212,7 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
type TaskAction = 'pause' | 'resume' | 'cancel';
type InboxAction = 'run' | 'decline' | 'dismiss';
type ServiceAction = 'restart';
interface InboxActionRequest {
action: InboxAction;
@@ -201,6 +220,11 @@ interface InboxActionRequest {
lastOccurredAt: string | null;
}
interface ServiceActionRequest {
action: ServiceAction;
requestId: string | null;
}
function parseTaskPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
if (!match) return null;
@@ -241,6 +265,16 @@ function parseRoomMessagePath(pathname: string): string | null {
}
}
function parseServiceActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parsePairedInboxTarget(
inboxId: string,
): { taskId: string; status: PairedTask['status'] } | null {
@@ -278,6 +312,10 @@ function isInboxAction(value: unknown): value is InboxAction {
return value === 'run' || value === 'decline' || value === 'dismiss';
}
function isServiceAction(value: unknown): value is ServiceAction {
return value === 'restart';
}
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
return (
value === 'active' ||
@@ -322,6 +360,24 @@ async function readInboxAction(
}
}
async function readServiceAction(
request: Request,
): Promise<ServiceActionRequest | null> {
try {
const body = (await request.json()) as {
action?: unknown;
requestId?: unknown;
};
if (!isServiceAction(body.action)) return null;
return {
action: body.action,
requestId: sanitizeServiceActionRequestId(body.requestId),
};
} catch {
return null;
}
}
interface ScheduledTaskMutationBody {
roomJid?: unknown;
groupFolder?: unknown;
@@ -464,6 +520,20 @@ function makeWebRunId(prefix: string): string {
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sanitizeServiceActionRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
return safe || null;
}
function makeServiceRestartId(requestId: string | null): string {
return requestId
? `web-restart-${requestId}`
: `web-restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sanitizeInboxActionRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
@@ -513,6 +583,58 @@ function pairedFollowUpIntentForStatus(
return null;
}
function isRoot(): boolean {
return typeof process.getuid === 'function' && process.getuid() === 0;
}
function restartEjclawStackServices(): string[] {
if (process.platform !== 'linux') {
throw new Error('Service restart only supports Linux systemd services');
}
const services = ['ejclaw'];
const systemctlArgs = isRoot() ? [] : ['--user'];
try {
execFileSync(
'systemctl',
[...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME],
{ stdio: 'ignore' },
);
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error
? String((error as { stderr?: unknown }).stderr || '')
: '';
const stdout =
error && typeof error === 'object' && 'stdout' in error
? String((error as { stdout?: unknown }).stdout || '')
: '';
const message =
error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim();
const combined = `${message}\n${stdout}\n${stderr}`;
if (!UNIT_NOT_FOUND_PATTERN.test(combined)) {
throw error;
}
if (process.env.SERVICE_ID) {
throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE);
}
execFileSync('systemctl', [...systemctlArgs, 'restart', ...services], {
stdio: 'ignore',
});
for (const service of services) {
execFileSync(
'systemctl',
[...systemctlArgs, 'is-active', '--quiet', service],
{ stdio: 'ignore' },
);
}
}
return services;
}
export function createWebDashboardHandler(
opts: WebDashboardHandlerOptions = {},
): (request: Request) => Response | Promise<Response> {
@@ -534,10 +656,14 @@ export function createWebDashboardHandler(
const messageExists = opts.hasMessage ?? hasMessage;
const enqueueMessageCheck = opts.enqueueMessageCheck;
const nudgeScheduler = opts.nudgeScheduler;
const restartServiceStack =
opts.restartServiceStack ?? restartEjclawStackServices;
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
const seenRoomMessageIds: string[] = [];
const seenRoomMessageIdSet = new Set<string>();
const dismissedInboxKeys = new Set<string>();
const recentServiceRestarts: ServiceRestartRecord[] = [];
const activeServiceRestartTargets = new Set<string>();
function rememberRoomMessageId(id: string): boolean {
if (seenRoomMessageIdSet.has(id)) return false;
@@ -560,12 +686,20 @@ export function createWebDashboardHandler(
);
}
function rememberServiceRestart(record: ServiceRestartRecord): void {
recentServiceRestarts.unshift(record);
if (recentServiceRestarts.length > SERVICE_RESTART_LOG_LIMIT) {
recentServiceRestarts.length = SERVICE_RESTART_LOG_LIMIT;
}
}
return async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const actionTaskId = parseTaskActionPath(url.pathname);
const taskId = parseTaskPath(url.pathname);
const actionInboxId = parseInboxActionPath(url.pathname);
const messageRoomJid = parseRoomMessagePath(url.pathname);
const actionServiceId = parseServiceActionPath(url.pathname);
if (actionTaskId) {
if (request.method !== 'POST') {
@@ -815,6 +949,83 @@ export function createWebDashboardHandler(
return jsonResponse({ ok: true, id, queued: true });
}
if (actionServiceId) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
if (actionServiceId !== 'stack') {
return jsonResponse(
{ error: 'Unsupported service restart target' },
{ status: 400 },
);
}
const serviceRequest = await readServiceAction(request);
if (!serviceRequest) {
return jsonResponse(
{ error: 'Invalid service action' },
{ status: 400 },
);
}
const id = makeServiceRestartId(serviceRequest.requestId);
const previous = recentServiceRestarts.find((record) => record.id === id);
if (serviceRequest.requestId && previous) {
if (previous.status === 'failed') {
return jsonResponse(
{
error: previous.error ?? 'Service restart failed',
duplicate: true,
restart: previous,
},
{ status: 500 },
);
}
return jsonResponse({
ok: true,
duplicate: true,
restart: previous,
});
}
if (activeServiceRestartTargets.has(actionServiceId)) {
return jsonResponse(
{ error: 'Service restart is already running' },
{ status: 409 },
);
}
const requestedAt = opts.now?.() ?? new Date().toISOString();
const record: ServiceRestartRecord = {
id,
target: 'stack',
requestedAt,
completedAt: null,
status: 'running',
services: [],
};
rememberServiceRestart(record);
activeServiceRestartTargets.add(actionServiceId);
try {
const services = restartServiceStack();
record.completedAt = opts.now?.() ?? new Date().toISOString();
record.status = 'success';
record.services = services;
return jsonResponse({ ok: true, restart: record });
} catch (error) {
record.completedAt = opts.now?.() ?? new Date().toISOString();
record.status = 'failed';
record.error = error instanceof Error ? error.message : String(error);
return jsonResponse(
{ error: record.error, restart: record },
{ status: 500 },
);
} finally {
activeServiceRestartTargets.delete(actionServiceId);
}
}
if (url.pathname === '/api/tasks' && request.method === 'POST') {
if (!loadRoomBindings) {
return jsonResponse(
@@ -994,6 +1205,9 @@ export function createWebDashboardHandler(
});
return jsonResponse({
...overview,
operations: {
serviceRestarts: recentServiceRestarts,
},
inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)),
});
}