Add dashboard health restart action (#38)
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '注意',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user