dashboard-nanobot/frontend/src/modules/dashboard/components/RuntimePanel.tsx

340 lines
14 KiB
TypeScript
Raw Normal View History

2026-03-31 04:31:47 +00:00
import { Boxes, Check, Clock3, EllipsisVertical, FileText, Hammer, MessageSquareText, RefreshCw, RotateCcw, Save, Settings2, SlidersHorizontal, TriangleAlert, Trash2, Waypoints } from 'lucide-react';
import type { RefObject } from 'react';
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { BotState } from '../../../types/bot';
2026-04-02 12:27:06 +00:00
import { isPreviewableWorkspaceFile } from '../utils';
2026-03-31 04:31:47 +00:00
import type { WorkspaceNode } from '../types';
import { WorkspaceEntriesList } from './WorkspaceEntriesList';
import './DashboardMenus.css';
import './RuntimePanel.css';
interface RuntimePanelLabels {
agent: string;
autoRefresh: string;
base: string;
channels: string;
clearHistory: string;
cronViewer: string;
download: string;
emptyDir: string;
envParams: string;
exportHistory: string;
fileNotPreviewable: string;
folder: string;
goUp: string;
goUpTitle: string;
loadingDir: string;
mcp: string;
more: string;
noPreviewFile: string;
openingPreview: string;
openFolderTitle: string;
params: string;
previewTitle: string;
refreshHint: string;
restart: string;
runtime: string;
searchAction: string;
skills: string;
topic: string;
workspaceHint: string;
workspaceOutputs: string;
workspaceSearchNoResult: string;
workspaceSearchPlaceholder: string;
clearSearch: string;
}
interface RuntimePanelProps {
selectedBot?: BotState;
selectedBotEnabled: boolean;
operatingBotId: string | null;
runtimeMenuOpen: boolean;
runtimeMenuRef: RefObject<HTMLDivElement | null>;
displayState: string;
workspaceError: string;
workspacePathDisplay: string;
workspaceLoading: boolean;
workspaceQuery: string;
workspaceSearchInputName: string;
workspaceSearchLoading: boolean;
filteredWorkspaceEntries: WorkspaceNode[];
workspaceParentPath: string | null;
workspaceFileLoading: boolean;
workspaceDownloadExtensionSet: ReadonlySet<string>;
workspaceAutoRefresh: boolean;
isCompactHidden: boolean;
showCompactSurface: boolean;
emptyStateText: string;
labels: RuntimePanelLabels;
onRestartBot: (botId: string, dockerStatus: string) => Promise<void> | void;
onToggleRuntimeMenu: () => void;
onOpenBaseConfig: () => Promise<void> | void;
onOpenParamConfig: () => Promise<void> | void;
onOpenChannelConfig: () => Promise<void> | void;
onOpenTopicConfig: () => Promise<void> | void;
onOpenEnvParams: () => Promise<void> | void;
onOpenSkills: () => Promise<void> | void;
onOpenMcpConfig: () => Promise<void> | void;
onOpenCronJobs: () => Promise<void> | void;
onOpenAgentFiles: () => Promise<void> | void;
onExportHistory: () => void;
onClearHistory: () => Promise<void> | void;
onRefreshWorkspace: () => Promise<void> | void;
onWorkspaceQueryChange: (value: string) => void;
onWorkspaceQueryClear: () => void;
onWorkspaceQuerySearch: () => void;
onToggleWorkspaceAutoRefresh: () => void;
onLoadWorkspaceTree: (botId: string, path?: string) => Promise<void> | void;
onOpenWorkspaceFilePreview: (path: string) => Promise<void> | void;
onShowWorkspaceHoverCard: (node: WorkspaceNode, anchor: HTMLElement) => void;
onHideWorkspaceHoverCard: () => void;
}
export function RuntimePanel({
selectedBot,
selectedBotEnabled,
operatingBotId,
runtimeMenuOpen,
runtimeMenuRef,
displayState,
workspaceError,
workspacePathDisplay,
workspaceLoading,
workspaceQuery,
workspaceSearchInputName,
workspaceSearchLoading,
filteredWorkspaceEntries,
workspaceParentPath,
workspaceFileLoading,
workspaceDownloadExtensionSet,
workspaceAutoRefresh,
isCompactHidden,
showCompactSurface,
emptyStateText,
labels,
onRestartBot,
onToggleRuntimeMenu,
onOpenBaseConfig,
onOpenParamConfig,
onOpenChannelConfig,
onOpenTopicConfig,
onOpenEnvParams,
onOpenSkills,
onOpenMcpConfig,
onOpenCronJobs,
onOpenAgentFiles,
onExportHistory,
onClearHistory,
onRefreshWorkspace,
onWorkspaceQueryChange,
onWorkspaceQueryClear,
onWorkspaceQuerySearch,
onToggleWorkspaceAutoRefresh,
onLoadWorkspaceTree,
onOpenWorkspaceFilePreview,
onShowWorkspaceHoverCard,
onHideWorkspaceHoverCard,
}: RuntimePanelProps) {
const normalizedWorkspaceQuery = workspaceQuery.trim().toLowerCase();
2026-04-02 12:27:06 +00:00
const hasVisibleWorkspaceEntries = filteredWorkspaceEntries.length > 0;
const visibleWorkspaceFiles = filteredWorkspaceEntries.filter((entry) => entry.type === 'file');
const hasVisiblePreviewableFiles = visibleWorkspaceFiles.some((entry) =>
isPreviewableWorkspaceFile(entry, workspaceDownloadExtensionSet),
);
const showWorkspaceEmptyState = !workspaceLoading && !workspaceSearchLoading && !workspaceError && !hasVisibleWorkspaceEntries;
const showNoPreviewableFilesHint = !workspaceError && !normalizedWorkspaceQuery && visibleWorkspaceFiles.length > 0 && !hasVisiblePreviewableFiles;
2026-03-31 04:31:47 +00:00
return (
<section className={`panel stack ops-runtime-panel ${isCompactHidden ? 'ops-compact-hidden' : ''} ${showCompactSurface ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-runtime-shell">
<div className="row-between ops-runtime-head">
2026-04-02 12:27:06 +00:00
<h2 className="ops-runtime-heading">{labels.runtime}</h2>
2026-03-31 04:31:47 +00:00
<div className="ops-panel-tools" ref={runtimeMenuRef}>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onRestartBot(selectedBot.id, selectedBot.docker_status)}
disabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
tooltip={labels.restart}
aria-label={labels.restart}
>
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleRuntimeMenu}
disabled={!selectedBotEnabled}
tooltip={labels.more}
aria-label={labels.more}
aria-haspopup="menu"
aria-expanded={runtimeMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={labels.more}>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenBaseConfig()}>
<Settings2 size={14} />
<span>{labels.base}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenParamConfig()}>
<SlidersHorizontal size={14} />
<span>{labels.params}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenChannelConfig()}>
<Waypoints size={14} />
<span>{labels.channels}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenTopicConfig()}>
<MessageSquareText size={14} />
<span>{labels.topic}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenEnvParams()}>
<Settings2 size={14} />
<span>{labels.envParams}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenSkills()}>
<Hammer size={14} />
<span>{labels.skills}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenMcpConfig()}>
<Boxes size={14} />
<span>{labels.mcp}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenCronJobs()}>
<Clock3 size={14} />
<span>{labels.cronViewer}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={() => void onOpenAgentFiles()}>
<FileText size={14} />
<span>{labels.agent}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onExportHistory}>
<Save size={14} />
<span>{labels.exportHistory}</span>
</button>
<button className="ops-more-item danger" role="menuitem" onClick={() => void onClearHistory()}>
<Trash2 size={14} />
<span>{labels.clearHistory}</span>
</button>
</div>
) : null}
</div>
</div>
<div className="ops-runtime-scroll">
<div className="card ops-runtime-card ops-runtime-state-card is-visual">
<div className={`ops-state-stage ops-state-${String(displayState).toLowerCase()}`}>
<div className="ops-state-model mono">{selectedBot.llm_model || '-'}</div>
<div className="ops-state-face" aria-hidden="true">
<span className="ops-state-eye left" />
<span className="ops-state-eye right" />
</div>
<div className="ops-state-caption mono">{String(displayState || 'IDLE').toUpperCase()}</div>
{displayState === 'TOOL_CALL' ? <Hammer size={18} className="ops-state-float state-tool" /> : null}
{displayState === 'SUCCESS' ? <Check size={18} className="ops-state-float state-success" /> : null}
{displayState === 'ERROR' ? <TriangleAlert size={18} className="ops-state-float state-error" /> : null}
</div>
</div>
<div className="card ops-runtime-card">
<div className="section-mini-title">{labels.workspaceOutputs}</div>
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
<div className="workspace-toolbar">
<div className="workspace-path-wrap">
<div className="workspace-path mono" title={workspacePathDisplay}>
{workspacePathDisplay}
</div>
</div>
<div className="workspace-toolbar-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading}
onClick={() => void onRefreshWorkspace()}
tooltip={labels.refreshHint}
aria-label={labels.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={labels.autoRefresh}>
<span className="workspace-auto-switch-label">{labels.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={onToggleWorkspaceAutoRefresh}
aria-label={labels.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
</div>
<div className="workspace-search-toolbar">
<ProtectedSearchInput
value={workspaceQuery}
onChange={onWorkspaceQueryChange}
onClear={onWorkspaceQueryClear}
onSearchAction={onWorkspaceQuerySearch}
debounceMs={200}
placeholder={labels.workspaceSearchPlaceholder}
ariaLabel={labels.workspaceSearchPlaceholder}
clearTitle={labels.clearSearch}
searchTitle={labels.searchAction}
name={workspaceSearchInputName}
id={workspaceSearchInputName}
/>
</div>
<div className="workspace-panel">
<div className="workspace-list">
{workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{labels.loadingDir}</div>
) : (
2026-04-02 12:27:06 +00:00
<>
{(workspaceParentPath !== null || hasVisibleWorkspaceEntries) ? (
<WorkspaceEntriesList
nodes={filteredWorkspaceEntries}
workspaceParentPath={workspaceParentPath}
selectedBotId={selectedBot.id}
workspaceFileLoading={workspaceFileLoading}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
labels={{
download: labels.download,
fileNotPreviewable: labels.fileNotPreviewable,
folder: labels.folder,
goUp: labels.goUp,
goUpTitle: labels.goUpTitle,
openFolderTitle: labels.openFolderTitle,
previewTitle: labels.previewTitle,
}}
onLoadWorkspaceTree={onLoadWorkspaceTree}
onOpenWorkspaceFilePreview={onOpenWorkspaceFilePreview}
onShowWorkspaceHoverCard={onShowWorkspaceHoverCard}
onHideWorkspaceHoverCard={onHideWorkspaceHoverCard}
/>
) : null}
{showWorkspaceEmptyState ? (
<div className="ops-empty-inline">
{normalizedWorkspaceQuery ? labels.workspaceSearchNoResult : labels.emptyDir}
</div>
) : null}
</>
2026-03-31 04:31:47 +00:00
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading ? labels.openingPreview : labels.workspaceHint}
</div>
</div>
2026-04-02 12:27:06 +00:00
{showNoPreviewableFilesHint ? (
2026-03-31 04:31:47 +00:00
<div className="ops-empty-inline">{labels.noPreviewFile}</div>
) : null}
</div>
</div>
</div>
) : (
2026-04-02 12:27:06 +00:00
<div className="ops-panel-empty-copy">{emptyStateText}</div>
2026-03-31 04:31:47 +00:00
)}
</section>
);
}