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));
}