Add dashboard health restart action (#38)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
createScheduledTask,
|
createScheduledTask,
|
||||||
fetchDashboardData,
|
fetchDashboardData,
|
||||||
runInboxAction,
|
runInboxAction,
|
||||||
|
runServiceAction,
|
||||||
runScheduledTaskAction,
|
runScheduledTaskAction,
|
||||||
sendRoomMessage,
|
sendRoomMessage,
|
||||||
updateScheduledTask,
|
updateScheduledTask,
|
||||||
@@ -48,6 +49,7 @@ type TaskActionKey =
|
|||||||
| `${string}:edit`
|
| `${string}:edit`
|
||||||
| `${string}:${DashboardTaskAction}`;
|
| `${string}:${DashboardTaskAction}`;
|
||||||
type InboxActionKey = `${string}:${DashboardInboxAction}`;
|
type InboxActionKey = `${string}:${DashboardInboxAction}`;
|
||||||
|
type ServiceActionKey = 'stack:restart';
|
||||||
type InboxFilter = 'all' | InboxItem['kind'];
|
type InboxFilter = 'all' | InboxItem['kind'];
|
||||||
type HealthLevel = 'ok' | 'stale' | 'down';
|
type HealthLevel = 'ok' | 'stale' | 'down';
|
||||||
|
|
||||||
@@ -913,13 +915,18 @@ function InboxPanel({
|
|||||||
function HealthPanel({
|
function HealthPanel({
|
||||||
data,
|
data,
|
||||||
locale,
|
locale,
|
||||||
|
onRestartStack,
|
||||||
|
serviceActionKey,
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
data: DashboardState;
|
data: DashboardState;
|
||||||
locale: Locale;
|
locale: Locale;
|
||||||
|
onRestartStack: () => void;
|
||||||
|
serviceActionKey: ServiceActionKey | null;
|
||||||
t: Messages;
|
t: Messages;
|
||||||
}) {
|
}) {
|
||||||
const services = data.overview.services;
|
const services = data.overview.services;
|
||||||
|
const restarts = data.overview.operations?.serviceRestarts ?? [];
|
||||||
const serviceLevels = services.map((service) => ({
|
const serviceLevels = services.map((service) => ({
|
||||||
service,
|
service,
|
||||||
level: serviceHealthLevel(service, data.overview.generatedAt),
|
level: serviceHealthLevel(service, data.overview.generatedAt),
|
||||||
@@ -981,6 +988,71 @@ function HealthPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 ? (
|
{services.length === 0 ? (
|
||||||
<EmptyState>{t.service.empty}</EmptyState>
|
<EmptyState>{t.service.empty}</EmptyState>
|
||||||
) : affectedServices.length === 0 ? null : (
|
) : affectedServices.length === 0 ? null : (
|
||||||
@@ -1705,6 +1777,8 @@ function App() {
|
|||||||
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [serviceActionKey, setServiceActionKey] =
|
||||||
|
useState<ServiceActionKey | null>(null);
|
||||||
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
|
||||||
const t = messages[locale];
|
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(
|
async function handleRoomMessage(
|
||||||
roomJid: string,
|
roomJid: string,
|
||||||
text: string,
|
text: string,
|
||||||
@@ -1946,7 +2041,13 @@ function App() {
|
|||||||
<h2>{t.panels.health}</h2>
|
<h2>{t.panels.health}</h2>
|
||||||
<span>{t.panels.healthSignals}</span>
|
<span>{t.panels.healthSignals}</span>
|
||||||
</div>
|
</div>
|
||||||
<HealthPanel data={data} locale={locale} t={t} />
|
<HealthPanel
|
||||||
|
data={data}
|
||||||
|
locale={locale}
|
||||||
|
onRestartStack={() => void handleServiceRestart()}
|
||||||
|
serviceActionKey={serviceActionKey}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ export interface DashboardOverview {
|
|||||||
}>;
|
}>;
|
||||||
fetchedAt: string | null;
|
fetchedAt: string | null;
|
||||||
};
|
};
|
||||||
|
operations?: {
|
||||||
|
serviceRestarts: DashboardServiceRestart[];
|
||||||
|
};
|
||||||
inbox: Array<{
|
inbox: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
groupKey: 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 {
|
export interface StatusSnapshot {
|
||||||
serviceId: string;
|
serviceId: string;
|
||||||
agentType: string;
|
agentType: string;
|
||||||
@@ -102,6 +115,7 @@ export interface DashboardTask {
|
|||||||
|
|
||||||
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
|
||||||
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
|
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
|
||||||
|
export type DashboardServiceAction = 'restart';
|
||||||
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
|
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
|
||||||
export type DashboardTaskContextMode = 'group' | 'isolated';
|
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(
|
export async function sendRoomMessage(
|
||||||
roomJid: string,
|
roomJid: string,
|
||||||
text: string,
|
text: string,
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ export interface Messages {
|
|||||||
queue: string;
|
queue: string;
|
||||||
ciFailures: string;
|
ciFailures: string;
|
||||||
affectedServices: string;
|
affectedServices: string;
|
||||||
|
restart: string;
|
||||||
|
restartStack: string;
|
||||||
|
restarting: string;
|
||||||
|
restartHint: string;
|
||||||
|
confirmRestart: string;
|
||||||
|
restartLog: string;
|
||||||
|
restartTarget: string;
|
||||||
|
restartStatus: string;
|
||||||
|
restartRequested: string;
|
||||||
|
restartServices: string;
|
||||||
levels: {
|
levels: {
|
||||||
ok: string;
|
ok: string;
|
||||||
stale: string;
|
stale: string;
|
||||||
@@ -323,6 +333,16 @@ export const messages = {
|
|||||||
queue: '큐',
|
queue: '큐',
|
||||||
ciFailures: 'CI 실패',
|
ciFailures: 'CI 실패',
|
||||||
affectedServices: '이상 서비스',
|
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: {
|
levels: {
|
||||||
ok: '정상',
|
ok: '정상',
|
||||||
stale: '주의',
|
stale: '주의',
|
||||||
@@ -558,6 +578,16 @@ export const messages = {
|
|||||||
queue: 'Queue',
|
queue: 'Queue',
|
||||||
ciFailures: 'CI failures',
|
ciFailures: 'CI failures',
|
||||||
affectedServices: 'Affected services',
|
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: {
|
levels: {
|
||||||
ok: 'OK',
|
ok: 'OK',
|
||||||
stale: 'Watch',
|
stale: 'Watch',
|
||||||
@@ -793,6 +823,16 @@ export const messages = {
|
|||||||
queue: '队列',
|
queue: '队列',
|
||||||
ciFailures: 'CI 失败',
|
ciFailures: 'CI 失败',
|
||||||
affectedServices: '异常服务',
|
affectedServices: '异常服务',
|
||||||
|
restart: '重启',
|
||||||
|
restartStack: '重启 stack',
|
||||||
|
restarting: '重启中...',
|
||||||
|
restartHint: '重启全部服务',
|
||||||
|
confirmRestart: '现在重启 stack?',
|
||||||
|
restartLog: '重启记录',
|
||||||
|
restartTarget: '目标',
|
||||||
|
restartStatus: '状态',
|
||||||
|
restartRequested: '请求',
|
||||||
|
restartServices: '服务',
|
||||||
levels: {
|
levels: {
|
||||||
ok: '正常',
|
ok: '正常',
|
||||||
stale: '关注',
|
stale: '关注',
|
||||||
@@ -1028,6 +1068,16 @@ export const messages = {
|
|||||||
queue: 'キュー',
|
queue: 'キュー',
|
||||||
ciFailures: 'CI失敗',
|
ciFailures: 'CI失敗',
|
||||||
affectedServices: '異常サービス',
|
affectedServices: '異常サービス',
|
||||||
|
restart: '再起動',
|
||||||
|
restartStack: 'Stack再起動',
|
||||||
|
restarting: '再起動中...',
|
||||||
|
restartHint: '全サービス再起動',
|
||||||
|
confirmRestart: 'Stackを再起動しますか?',
|
||||||
|
restartLog: '再起動ログ',
|
||||||
|
restartTarget: '対象',
|
||||||
|
restartStatus: '状態',
|
||||||
|
restartRequested: '要求',
|
||||||
|
restartServices: 'サービス',
|
||||||
levels: {
|
levels: {
|
||||||
ok: '正常',
|
ok: '正常',
|
||||||
stale: '注意',
|
stale: '注意',
|
||||||
|
|||||||
@@ -851,9 +851,12 @@ progress::-moz-progress-bar {
|
|||||||
.inbox-summary div,
|
.inbox-summary div,
|
||||||
.health-signals div,
|
.health-signals div,
|
||||||
.health-overview,
|
.health-overview,
|
||||||
|
.health-actions,
|
||||||
.inbox-card,
|
.inbox-card,
|
||||||
.health-service,
|
.health-service,
|
||||||
.health-service-details {
|
.health-service-details,
|
||||||
|
.health-restart-log,
|
||||||
|
.health-restart-record {
|
||||||
border: 1px solid rgba(43, 55, 38, 0.1);
|
border: 1px solid rgba(43, 55, 38, 0.1);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
background: rgba(255, 250, 240, 0.64);
|
background: rgba(255, 250, 240, 0.64);
|
||||||
@@ -1018,6 +1021,100 @@ progress::-moz-progress-bar {
|
|||||||
letter-spacing: -0.04em;
|
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 {
|
.health-down {
|
||||||
border-color: rgba(183, 71, 52, 0.32);
|
border-color: rgba(183, 71, 52, 0.32);
|
||||||
}
|
}
|
||||||
@@ -1823,6 +1920,23 @@ progress::-moz-progress-bar {
|
|||||||
gap: 8px;
|
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 {
|
.health-service {
|
||||||
grid-template-columns: minmax(0, 1fr) auto;
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
}
|
}
|
||||||
@@ -1930,6 +2044,10 @@ progress::-moz-progress-bar {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.health-restart-record {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.task-time-grid {
|
.task-time-grid {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1019,6 +1019,162 @@ describe('web dashboard server handler', () => {
|
|||||||
expect(noQueue.status).toBe(503);
|
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 () => {
|
it('injects room messages and queues room work from the web dashboard', async () => {
|
||||||
const messages: NewMessage[] = [];
|
const messages: NewMessage[] = [];
|
||||||
const metadata: Array<{
|
const metadata: Array<{
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { execFileSync } from 'child_process';
|
||||||
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
|
||||||
@@ -39,7 +40,13 @@ import {
|
|||||||
|
|
||||||
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
|
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
|
||||||
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
|
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 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<
|
type PairedFollowUpTask = Pick<
|
||||||
PairedTask,
|
PairedTask,
|
||||||
@@ -54,6 +61,16 @@ type WebPairedFollowUpScheduler = (args: {
|
|||||||
enqueue: () => void;
|
enqueue: () => void;
|
||||||
}) => boolean;
|
}) => boolean;
|
||||||
|
|
||||||
|
export interface ServiceRestartRecord {
|
||||||
|
id: string;
|
||||||
|
target: 'stack';
|
||||||
|
requestedAt: string;
|
||||||
|
completedAt: string | null;
|
||||||
|
status: 'running' | 'success' | 'failed';
|
||||||
|
services: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebDashboardHandlerOptions {
|
export interface WebDashboardHandlerOptions {
|
||||||
staticDir?: string;
|
staticDir?: string;
|
||||||
statusMaxAgeMs?: number;
|
statusMaxAgeMs?: number;
|
||||||
@@ -113,6 +130,7 @@ export interface WebDashboardHandlerOptions {
|
|||||||
hasMessage?: (chatJid: string, id: string) => boolean;
|
hasMessage?: (chatJid: string, id: string) => boolean;
|
||||||
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
|
||||||
nudgeScheduler?: () => void;
|
nudgeScheduler?: () => void;
|
||||||
|
restartServiceStack?: () => string[];
|
||||||
now?: () => string;
|
now?: () => string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +212,7 @@ function serveStaticFile(staticDir: string, pathname: string): Response {
|
|||||||
|
|
||||||
type TaskAction = 'pause' | 'resume' | 'cancel';
|
type TaskAction = 'pause' | 'resume' | 'cancel';
|
||||||
type InboxAction = 'run' | 'decline' | 'dismiss';
|
type InboxAction = 'run' | 'decline' | 'dismiss';
|
||||||
|
type ServiceAction = 'restart';
|
||||||
|
|
||||||
interface InboxActionRequest {
|
interface InboxActionRequest {
|
||||||
action: InboxAction;
|
action: InboxAction;
|
||||||
@@ -201,6 +220,11 @@ interface InboxActionRequest {
|
|||||||
lastOccurredAt: string | null;
|
lastOccurredAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServiceActionRequest {
|
||||||
|
action: ServiceAction;
|
||||||
|
requestId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseTaskPath(pathname: string): string | null {
|
function parseTaskPath(pathname: string): string | null {
|
||||||
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
|
||||||
if (!match) return null;
|
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(
|
function parsePairedInboxTarget(
|
||||||
inboxId: string,
|
inboxId: string,
|
||||||
): { taskId: string; status: PairedTask['status'] } | null {
|
): { taskId: string; status: PairedTask['status'] } | null {
|
||||||
@@ -278,6 +312,10 @@ function isInboxAction(value: unknown): value is InboxAction {
|
|||||||
return value === 'run' || value === 'decline' || value === 'dismiss';
|
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'] {
|
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
|
||||||
return (
|
return (
|
||||||
value === 'active' ||
|
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 {
|
interface ScheduledTaskMutationBody {
|
||||||
roomJid?: unknown;
|
roomJid?: unknown;
|
||||||
groupFolder?: unknown;
|
groupFolder?: unknown;
|
||||||
@@ -464,6 +520,20 @@ function makeWebRunId(prefix: string): string {
|
|||||||
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
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 {
|
function sanitizeInboxActionRequestId(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -513,6 +583,58 @@ function pairedFollowUpIntentForStatus(
|
|||||||
return null;
|
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(
|
export function createWebDashboardHandler(
|
||||||
opts: WebDashboardHandlerOptions = {},
|
opts: WebDashboardHandlerOptions = {},
|
||||||
): (request: Request) => Response | Promise<Response> {
|
): (request: Request) => Response | Promise<Response> {
|
||||||
@@ -534,10 +656,14 @@ export function createWebDashboardHandler(
|
|||||||
const messageExists = opts.hasMessage ?? hasMessage;
|
const messageExists = opts.hasMessage ?? hasMessage;
|
||||||
const enqueueMessageCheck = opts.enqueueMessageCheck;
|
const enqueueMessageCheck = opts.enqueueMessageCheck;
|
||||||
const nudgeScheduler = opts.nudgeScheduler;
|
const nudgeScheduler = opts.nudgeScheduler;
|
||||||
|
const restartServiceStack =
|
||||||
|
opts.restartServiceStack ?? restartEjclawStackServices;
|
||||||
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
|
||||||
const seenRoomMessageIds: string[] = [];
|
const seenRoomMessageIds: string[] = [];
|
||||||
const seenRoomMessageIdSet = new Set<string>();
|
const seenRoomMessageIdSet = new Set<string>();
|
||||||
const dismissedInboxKeys = new Set<string>();
|
const dismissedInboxKeys = new Set<string>();
|
||||||
|
const recentServiceRestarts: ServiceRestartRecord[] = [];
|
||||||
|
const activeServiceRestartTargets = new Set<string>();
|
||||||
|
|
||||||
function rememberRoomMessageId(id: string): boolean {
|
function rememberRoomMessageId(id: string): boolean {
|
||||||
if (seenRoomMessageIdSet.has(id)) return false;
|
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> => {
|
return async (request: Request): Promise<Response> => {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const actionTaskId = parseTaskActionPath(url.pathname);
|
const actionTaskId = parseTaskActionPath(url.pathname);
|
||||||
const taskId = parseTaskPath(url.pathname);
|
const taskId = parseTaskPath(url.pathname);
|
||||||
const actionInboxId = parseInboxActionPath(url.pathname);
|
const actionInboxId = parseInboxActionPath(url.pathname);
|
||||||
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
const messageRoomJid = parseRoomMessagePath(url.pathname);
|
||||||
|
const actionServiceId = parseServiceActionPath(url.pathname);
|
||||||
|
|
||||||
if (actionTaskId) {
|
if (actionTaskId) {
|
||||||
if (request.method !== 'POST') {
|
if (request.method !== 'POST') {
|
||||||
@@ -815,6 +949,83 @@ export function createWebDashboardHandler(
|
|||||||
return jsonResponse({ ok: true, id, queued: true });
|
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 (url.pathname === '/api/tasks' && request.method === 'POST') {
|
||||||
if (!loadRoomBindings) {
|
if (!loadRoomBindings) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
@@ -994,6 +1205,9 @@ export function createWebDashboardHandler(
|
|||||||
});
|
});
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
...overview,
|
...overview,
|
||||||
|
operations: {
|
||||||
|
serviceRestarts: recentServiceRestarts,
|
||||||
|
},
|
||||||
inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)),
|
inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user