[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
300
components/systemManager/ServicesManagerTab.tsx
Normal file
300
components/systemManager/ServicesManagerTab.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import {
|
||||
Play, RefreshCw, Square, Cog,
|
||||
} from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import { systemdUnitInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import type { SystemdUnitAction, SystemdUnitInfo } from '../../domain/systemManager/types';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import { mergePollListByKey, useStableListOrder } from './listStable';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelInlineError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelSegmented,
|
||||
SystemPanelShell,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type ServiceFilter = 'all' | 'running' | 'failed' | 'inactive';
|
||||
|
||||
interface PendingServiceAction {
|
||||
unit: SystemdUnitInfo;
|
||||
action: SystemdUnitAction;
|
||||
}
|
||||
|
||||
const mergeUnits = (
|
||||
prev: SystemdUnitInfo[] | null,
|
||||
next: SystemdUnitInfo[],
|
||||
) => mergePollListByKey(prev, next, (u) => `${u.scope}:${u.name}`, systemdUnitInfoEqual);
|
||||
|
||||
function activeTone(state: SystemdUnitInfo['activeState']): 'success' | 'warning' | 'muted' {
|
||||
if (state === 'active') return 'success';
|
||||
if (state === 'failed' || state === 'deactivating') return 'warning';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
function actionTitleKey(action: SystemdUnitAction): string {
|
||||
switch (action) {
|
||||
case 'start': return 'systemManager.services.start';
|
||||
case 'stop': return 'systemManager.services.stop';
|
||||
case 'restart': return 'systemManager.services.restart';
|
||||
case 'enable': return 'systemManager.services.enable';
|
||||
case 'disable': return 'systemManager.services.disable';
|
||||
case 'reload': return 'systemManager.services.reload';
|
||||
default: {
|
||||
const _exhaustive: never = action;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface ServicesManagerTabProps {
|
||||
sessionId: string;
|
||||
isVisible: boolean;
|
||||
backend: Backend;
|
||||
refreshIntervalSec: number;
|
||||
/** Network appliances: list only, no start/stop/restart. */
|
||||
allowMutations?: boolean;
|
||||
}
|
||||
|
||||
export const ServicesManagerTab = memo(function ServicesManagerTab({
|
||||
sessionId,
|
||||
isVisible,
|
||||
backend,
|
||||
refreshIntervalSec,
|
||||
allowMutations = true,
|
||||
}: ServicesManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const intervalMs = Math.max(3, refreshIntervalSec) * 1000;
|
||||
const [query, setQuery] = useState('');
|
||||
const [filter, setFilter] = useState<ServiceFilter>('all');
|
||||
const [pending, setPending] = useState<PendingServiceAction | null>(null);
|
||||
const [actionBusy, setActionBusy] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const [listPending, setListPending] = useState(false);
|
||||
const sessionIdRef = useRef(sessionId);
|
||||
sessionIdRef.current = sessionId;
|
||||
|
||||
useEffect(() => {
|
||||
setListPending(false);
|
||||
setPending(null);
|
||||
setActionBusy(false);
|
||||
setActionError(null);
|
||||
}, [sessionId]);
|
||||
|
||||
const fetcher = useCallback(async (): Promise<SystemdUnitInfo[] | null> => {
|
||||
const requestedSessionId = sessionId;
|
||||
try {
|
||||
const result = await backend.listSystemServices(requestedSessionId);
|
||||
if (sessionIdRef.current !== requestedSessionId) return null;
|
||||
if (result.pending) {
|
||||
setListPending(true);
|
||||
return null;
|
||||
}
|
||||
setListPending(false);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadServices'));
|
||||
}
|
||||
return result.units || [];
|
||||
} catch (error) {
|
||||
if (sessionIdRef.current === requestedSessionId) setListPending(false);
|
||||
throw error;
|
||||
}
|
||||
}, [backend, sessionId, stableT]);
|
||||
|
||||
const { data, error, loading, refresh } = usePolling(
|
||||
fetcher,
|
||||
intervalMs,
|
||||
isVisible,
|
||||
mergeUnits,
|
||||
{ resetKey: sessionId },
|
||||
);
|
||||
|
||||
const isRefreshActive = loading || listPending;
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (data || []).filter((unit) => {
|
||||
if (filter === 'running' && unit.activeState !== 'active') return false;
|
||||
if (filter === 'failed' && unit.activeState !== 'failed') return false;
|
||||
if (filter === 'inactive' && unit.activeState !== 'inactive') return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
unit.name.toLowerCase().includes(q)
|
||||
|| unit.description.toLowerCase().includes(q)
|
||||
|| unit.subState.toLowerCase().includes(q)
|
||||
|| unit.scope.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}, [data, filter, query]);
|
||||
|
||||
const units = useStableListOrder(
|
||||
filtered,
|
||||
(u) => `${u.scope}:${u.name}`,
|
||||
`${filter}|${query}`,
|
||||
(a, b) => {
|
||||
if (a.activeState === 'failed' && b.activeState !== 'failed') return -1;
|
||||
if (b.activeState === 'failed' && a.activeState !== 'failed') return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
},
|
||||
);
|
||||
|
||||
const executeAction = useCallback(async (unit: SystemdUnitInfo, action: SystemdUnitAction) => {
|
||||
const requestedSessionId = sessionId;
|
||||
setActionBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await backend.systemServiceAction({
|
||||
sessionId: requestedSessionId,
|
||||
unitName: unit.name,
|
||||
action,
|
||||
scope: unit.scope,
|
||||
});
|
||||
if (sessionIdRef.current !== requestedSessionId) return;
|
||||
if (result.pending) {
|
||||
setActionError(t('systemManager.errors.sshChannelUnavailable'));
|
||||
return;
|
||||
}
|
||||
if (!result.success) {
|
||||
setActionError(result.error || t('systemManager.errors.actionFailed'));
|
||||
return;
|
||||
}
|
||||
void refresh();
|
||||
} finally {
|
||||
if (sessionIdRef.current === requestedSessionId) setActionBusy(false);
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-services">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={isRefreshActive}
|
||||
onClick={() => void refresh()}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.services.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelSegmented
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
options={[
|
||||
{ id: 'all', label: t('systemManager.services.filter.all') },
|
||||
{ id: 'running', label: t('systemManager.services.filter.running') },
|
||||
{ id: 'failed', label: t('systemManager.services.filter.failed') },
|
||||
{ id: 'inactive', label: t('systemManager.services.filter.inactive') },
|
||||
]}
|
||||
/>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.services.meta', { count: units.length })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
{actionError ? <SystemPanelInlineError message={actionError} /> : null}
|
||||
|
||||
{error && !(data?.length) ? (
|
||||
<SystemPanelError
|
||||
message={error}
|
||||
onRetry={() => void refresh()}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
) : !(data?.length) && (loading || listPending) ? (
|
||||
<SystemPanelLoading message={t('systemManager.services.loading')} />
|
||||
) : !units.length ? (
|
||||
<SystemPanelEmpty icon={Cog} message={t('systemManager.services.empty')} />
|
||||
) : (
|
||||
<SystemPanelList>
|
||||
{units.map((unit) => {
|
||||
const isActive = unit.activeState === 'active';
|
||||
return (
|
||||
<SystemPanelRow
|
||||
key={`${unit.scope}:${unit.name}`}
|
||||
title={unit.name}
|
||||
subtitle={
|
||||
unit.description
|
||||
? `${unit.description}${unit.scope === 'user' ? ` · ${t('systemManager.services.scope.user')}` : ''}`
|
||||
: (unit.scope === 'user' ? t('systemManager.services.scope.user') : unit.subState)
|
||||
}
|
||||
trailing={(
|
||||
<SystemPanelStatusBadge tone={activeTone(unit.activeState)}>
|
||||
{unit.activeState}
|
||||
</SystemPanelStatusBadge>
|
||||
)}
|
||||
actions={allowMutations ? (
|
||||
<div className="flex shrink-0 items-center justify-end gap-1">
|
||||
{!isActive ? (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.services.start')}
|
||||
onClick={() => setPending({ unit, action: 'start' })}
|
||||
>
|
||||
<Play size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
) : (
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.services.stop')}
|
||||
onClick={() => setPending({ unit, action: 'stop' })}
|
||||
>
|
||||
<Square size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.services.restart')}
|
||||
onClick={() => setPending({ unit, action: 'restart' })}
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</SystemPanelList>
|
||||
)}
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={allowMutations && pending !== null}
|
||||
title={pending ? t(actionTitleKey(pending.action)) : ''}
|
||||
message={pending
|
||||
? t('systemManager.services.confirmAction', {
|
||||
action: t(actionTitleKey(pending.action)),
|
||||
name: pending.unit.name,
|
||||
})
|
||||
: ''}
|
||||
confirmLabel={pending ? t(actionTitleKey(pending.action)) : ''}
|
||||
destructive={pending?.action === 'stop' || pending?.action === 'disable'}
|
||||
busy={actionBusy}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !actionBusy) setPending(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
const target = pending;
|
||||
if (!target) return;
|
||||
setPending(null);
|
||||
void executeAction(target.unit, target.action);
|
||||
}}
|
||||
/>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user