Files
ServerKit/frontend/src/components/JobProgressModal.jsx
T
Juan Denis c0de9fbcac refactor(i18n): consolidate duplicate locale strings into common.* namespace
en.json drops from 8107 to 6624 keys: repeated UI copy (Cancel x95,
Status x67, Refresh x54, ...) now lives under common.actions /
common.labels / common.loading, and extractor-era suffix duplicates
(refresh2, status3) within a component collapse onto one key.
Trailing ... defaults are normalized to the ellipsis character.

Translations are preserved: all 16 locales were re-keyed, carrying
existing per-component translations onto their new common.* keys
(majority vote where sources disagreed), and stale keys were pruned.

Verified: npm run lint (0 errors), node --test (140 pass), vite build.

edit
2026-08-22 01:32:52 -04:00

111 lines
4.2 KiB
React

import { useCallback, useEffect, useRef, useState } from 'react';
import Modal from './Modal';
import { Button } from '@/components/ui/button';
import { rooms, SOCKET_EVENTS } from '@/constants/events';
import { useServerStream } from '@/hooks/useServerStream';
import { scrollBehavior } from '@/utils/reducedMotion';
import { useTranslation } from 'react-i18next';
// Subscribes to a remote agent's job:<id> stream channel and renders
// the live log output. Used by PackagesTab and ServicesTab for
// long-running install / upgrade / journal-follow operations.
//
// The agent emits {phase, lines[], message, percent, exit_code, error}
// events on the channel. The panel re-broadcasts those over Socket.IO
// as `server_stream` events into room `server_<serverId>_<channel>`.
// We join the room on mount, listen for matching server_stream events,
// and surface a "done" phase as success/error in the footer.
export default function JobProgressModal({
open,
serverId,
channel,
title = 'Working…',
onClose,
onComplete,
}) {
const { t } = useTranslation();
const [lines, setLines] = useState([]);
const [done, setDone] = useState(null); // null | { exitCode, error, extra }
const logEndRef = useRef(null);
const onStream = useCallback((msg) => {
if (msg?.channel !== channel) return;
const ev = msg.data || {};
if (Array.isArray(ev.lines) && ev.lines.length) {
setLines((prev) => [...prev, ...ev.lines]);
} else if (ev.message) {
setLines((prev) => [...prev, ev.message]);
}
if (ev.phase === 'done') {
const exitCode = typeof ev.exit_code === 'number' ? ev.exit_code : null;
setDone({
exitCode,
error: ev.error || '',
extra: ev.extra || null,
});
if (onComplete) onComplete(ev);
}
}, [channel, onComplete]);
useServerStream(
serverId && channel ? rooms.serverChannel(serverId, channel) : null,
SOCKET_EVENTS.SERVER_STREAM,
onStream,
{ enabled: open },
);
// Reset state on close so the next job starts fresh.
useEffect(() => {
if (!open) {
setLines([]);
setDone(null);
}
}, [open]);
// Auto-scroll to the bottom as new lines arrive.
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: scrollBehavior() });
}, [lines.length]);
const success = done && done.exitCode === 0 && !done.error;
const failure = done && !success;
return (
<Modal
open={open}
onClose={onClose}
title={title}
size="xl"
footer={
<div className="flex items-center gap-3 w-full">
<div className="flex-1">
{!done && <span className="text-muted-foreground text-sm">{t('app.jobProgressModal.streamingProgress', 'Streaming progress…')}</span>}
{success && <span className="text-success text-sm">{t('app.jobProgressModal.completedSuccessfully', 'Completed successfully')}</span>}
{failure && (
<span className="text-destructive text-sm">
{t('common.state.failed', 'Failed')}{done.exitCode !== null ? ` (exit ${done.exitCode})` : ''}
{done.error ? `: ${done.error}` : ''}
</span>
)}
</div>
<Button variant="outline" onClick={onClose} disabled={!done}>
{done ? 'Close' : 'Working…'}
</Button>
</div>
}
>
<div className="job-progress-modal">
{lines.length === 0 && !done && (
<p className="text-muted-foreground text-sm">{t('app.jobProgressModal.waitingForTheAgentToBegin', 'Waiting for the agent to begin…')}</p>
)}
{lines.length > 0 && (
<pre className="job-progress-modal__log">
{lines.join('\n')}
<div ref={logEndRef} />
</pre>
)}
</div>
</Modal>
);
}