362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
import { Boxes, ChevronLeft, ChevronRight, EllipsisVertical, ExternalLink, FileText, Gauge, Lock, Plus, Power, Square, Trash2 } from 'lucide-react';
|
|
import type { RefObject } from 'react';
|
|
|
|
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
|
|
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
|
|
import type { CompactPanelTab } from '../types';
|
|
import './DashboardMenus.css';
|
|
import './BotListPanel.css';
|
|
|
|
interface BotListLabels {
|
|
batchStart: string;
|
|
batchStop: string;
|
|
botSearchNoResult: string;
|
|
botSearchPlaceholder: string;
|
|
clearSearch: string;
|
|
delete: string;
|
|
disable: string;
|
|
disabled: string;
|
|
enable: string;
|
|
extensions: string;
|
|
image: string;
|
|
manageImages: string;
|
|
newBot: string;
|
|
paginationNext: string;
|
|
paginationPage: (page: number, totalPages: number) => string;
|
|
paginationPrev: string;
|
|
searchAction: string;
|
|
start: string;
|
|
stop: string;
|
|
syncingPageSize: string;
|
|
templateManager: string;
|
|
titleBots: string;
|
|
}
|
|
|
|
interface BotListPanelProps {
|
|
bots: any[];
|
|
filteredBots: any[];
|
|
pagedBots: any[];
|
|
selectedBotId: string;
|
|
normalizedBotListQuery: string;
|
|
botListQuery: string;
|
|
botListPageSizeReady: boolean;
|
|
botListPage: number;
|
|
botListTotalPages: number;
|
|
botListMenuOpen: boolean;
|
|
controlStateByBot: Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>;
|
|
operatingBotId: string | null;
|
|
compactMode: boolean;
|
|
isZh: boolean;
|
|
isLoadingTemplates: boolean;
|
|
isBatchOperating: boolean;
|
|
labels: BotListLabels;
|
|
botSearchInputName: string;
|
|
botListMenuRef: RefObject<HTMLDivElement | null>;
|
|
onOpenCreateWizard?: () => void;
|
|
onOpenImageFactory?: () => void;
|
|
onToggleMenu?: () => void;
|
|
onCloseMenu: () => void;
|
|
onOpenTemplateManager: () => Promise<void> | void;
|
|
onBatchStartBots: () => Promise<void> | void;
|
|
onBatchStopBots: () => Promise<void> | void;
|
|
onBotListQueryChange: (value: string) => void;
|
|
onBotListPageChange: (value: number | ((prev: number) => number)) => void;
|
|
onSelectBot: (botId: string) => void;
|
|
onSetCompactPanelTab: (tab: CompactPanelTab) => void;
|
|
onSetBotEnabled: (botId: string, enabled: boolean) => Promise<void> | void;
|
|
onStartBot: (botId: string, dockerStatus: string) => Promise<void> | void;
|
|
onStopBot: (botId: string, dockerStatus: string) => Promise<void> | void;
|
|
onOpenResourceMonitor: (botId: string) => void;
|
|
onRemoveBot: (botId: string) => Promise<void> | void;
|
|
}
|
|
|
|
export function BotListPanel({
|
|
bots,
|
|
filteredBots,
|
|
pagedBots,
|
|
selectedBotId,
|
|
normalizedBotListQuery,
|
|
botListQuery,
|
|
botListPageSizeReady,
|
|
botListPage,
|
|
botListTotalPages,
|
|
botListMenuOpen,
|
|
controlStateByBot,
|
|
operatingBotId,
|
|
compactMode,
|
|
isZh,
|
|
isLoadingTemplates,
|
|
isBatchOperating,
|
|
labels,
|
|
botSearchInputName,
|
|
botListMenuRef,
|
|
onOpenCreateWizard,
|
|
onOpenImageFactory,
|
|
onToggleMenu,
|
|
onCloseMenu,
|
|
onOpenTemplateManager,
|
|
onBatchStartBots,
|
|
onBatchStopBots,
|
|
onBotListQueryChange,
|
|
onBotListPageChange,
|
|
onSelectBot,
|
|
onSetCompactPanelTab,
|
|
onSetBotEnabled,
|
|
onStartBot,
|
|
onStopBot,
|
|
onOpenResourceMonitor,
|
|
onRemoveBot,
|
|
}: BotListPanelProps) {
|
|
return (
|
|
<section className="panel stack ops-bot-list">
|
|
<div className="row-between">
|
|
<h2 style={{ fontSize: 18 }}>
|
|
{normalizedBotListQuery
|
|
? `${labels.titleBots} (${filteredBots.length}/${bots.length})`
|
|
: `${labels.titleBots} (${bots.length})`}
|
|
</h2>
|
|
<div className="ops-list-actions" ref={botListMenuRef}>
|
|
<LucentIconButton
|
|
className="btn btn-primary btn-sm icon-btn"
|
|
onClick={onOpenCreateWizard}
|
|
tooltip={labels.newBot}
|
|
aria-label={labels.newBot}
|
|
>
|
|
<Plus size={14} />
|
|
</LucentIconButton>
|
|
<LucentIconButton
|
|
className="btn btn-secondary btn-sm icon-btn"
|
|
onClick={onToggleMenu}
|
|
tooltip={labels.extensions}
|
|
aria-label={labels.extensions}
|
|
aria-haspopup="menu"
|
|
aria-expanded={botListMenuOpen}
|
|
>
|
|
<EllipsisVertical size={14} />
|
|
</LucentIconButton>
|
|
{botListMenuOpen ? (
|
|
<div className="ops-more-menu" role="menu" aria-label={labels.extensions}>
|
|
<button
|
|
className="ops-more-item"
|
|
role="menuitem"
|
|
disabled={!onOpenImageFactory}
|
|
onClick={() => {
|
|
onCloseMenu();
|
|
onOpenImageFactory?.();
|
|
}}
|
|
>
|
|
<Boxes size={14} />
|
|
<span>{labels.manageImages}</span>
|
|
</button>
|
|
<button
|
|
className="ops-more-item"
|
|
role="menuitem"
|
|
disabled={isLoadingTemplates}
|
|
onClick={() => {
|
|
void onOpenTemplateManager();
|
|
}}
|
|
>
|
|
<FileText size={14} />
|
|
<span>{labels.templateManager}</span>
|
|
</button>
|
|
<button
|
|
className="ops-more-item"
|
|
role="menuitem"
|
|
disabled={isBatchOperating}
|
|
onClick={() => {
|
|
void onBatchStartBots();
|
|
}}
|
|
>
|
|
<Power size={14} />
|
|
<span>{labels.batchStart}</span>
|
|
</button>
|
|
<button
|
|
className="ops-more-item"
|
|
role="menuitem"
|
|
disabled={isBatchOperating}
|
|
onClick={() => {
|
|
void onBatchStopBots();
|
|
}}
|
|
>
|
|
<Square size={14} />
|
|
<span>{labels.batchStop}</span>
|
|
</button>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="ops-bot-list-toolbar">
|
|
<ProtectedSearchInput
|
|
value={botListQuery}
|
|
onChange={onBotListQueryChange}
|
|
onClear={() => {
|
|
onBotListQueryChange('');
|
|
onBotListPageChange(1);
|
|
}}
|
|
onSearchAction={() => onBotListPageChange(1)}
|
|
debounceMs={120}
|
|
placeholder={labels.botSearchPlaceholder}
|
|
ariaLabel={labels.botSearchPlaceholder}
|
|
clearTitle={labels.clearSearch}
|
|
searchTitle={labels.searchAction}
|
|
name={botSearchInputName}
|
|
id={botSearchInputName}
|
|
/>
|
|
</div>
|
|
|
|
<div className="list-scroll">
|
|
{!botListPageSizeReady ? (
|
|
<div className="ops-bot-list-empty">{labels.syncingPageSize}</div>
|
|
) : null}
|
|
{botListPageSizeReady
|
|
? pagedBots.map((bot) => {
|
|
const selected = selectedBotId === bot.id;
|
|
const controlState = controlStateByBot[bot.id];
|
|
const isOperating = operatingBotId === bot.id;
|
|
const isEnabled = bot.enabled !== false;
|
|
const isEnabling = controlState === 'enabling';
|
|
const isDisabling = controlState === 'disabling';
|
|
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
|
|
const showActionPending = isOperating && !isEnabling && !isDisabling;
|
|
return (
|
|
<div
|
|
key={bot.id}
|
|
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
|
|
onClick={() => {
|
|
onSelectBot(bot.id);
|
|
if (compactMode) onSetCompactPanelTab('chat');
|
|
}}
|
|
>
|
|
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
|
|
<div className="row-between ops-bot-top">
|
|
<div className="ops-bot-name-wrap">
|
|
<div className="ops-bot-name-row">
|
|
{bot.has_access_password ? (
|
|
<span className="ops-bot-lock" title={isZh ? '已设置访问密码' : 'Access password enabled'} aria-label={isZh ? '已设置访问密码' : 'Access password enabled'}>
|
|
<Lock size={12} />
|
|
</span>
|
|
) : null}
|
|
<div className="ops-bot-name">{bot.name}</div>
|
|
<LucentIconButton
|
|
className="ops-bot-open-inline"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
|
|
window.open(target, '_blank', 'noopener,noreferrer');
|
|
}}
|
|
tooltip={isZh ? '新页面打开' : 'Open in new page'}
|
|
aria-label={isZh ? '新页面打开' : 'Open in new page'}
|
|
>
|
|
<ExternalLink size={11} />
|
|
</LucentIconButton>
|
|
</div>
|
|
<div className="mono ops-bot-id">{bot.id}</div>
|
|
</div>
|
|
<div className="ops-bot-top-actions">
|
|
{!isEnabled ? (
|
|
<span className="badge badge-err">{labels.disabled}</span>
|
|
) : null}
|
|
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
|
|
</div>
|
|
</div>
|
|
<div className="ops-bot-meta">{labels.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
|
|
<div className="ops-bot-actions">
|
|
<label
|
|
className="ops-bot-enable-switch"
|
|
title={isEnabled ? labels.disable : labels.enable}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={isEnabled}
|
|
disabled={isOperating || isEnabling || isDisabling}
|
|
onChange={(e) => {
|
|
void onSetBotEnabled(bot.id, e.target.checked);
|
|
}}
|
|
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
|
|
/>
|
|
<span className="ops-bot-enable-switch-track" />
|
|
</label>
|
|
<div className="ops-bot-actions-main">
|
|
<LucentIconButton
|
|
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
|
|
disabled={isOperating || !isEnabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void (isRunning ? onStopBot(bot.id, bot.docker_status) : onStartBot(bot.id, bot.docker_status));
|
|
}}
|
|
tooltip={isRunning ? labels.stop : labels.start}
|
|
aria-label={isRunning ? labels.stop : labels.start}
|
|
>
|
|
{showActionPending ? (
|
|
<span className="ops-control-pending">
|
|
<span className="ops-control-dots" aria-hidden="true">
|
|
<i />
|
|
<i />
|
|
<i />
|
|
</span>
|
|
</span>
|
|
) : isRunning ? <Square size={14} /> : <Power size={14} />}
|
|
</LucentIconButton>
|
|
<LucentIconButton
|
|
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
|
|
disabled={isOperating || !isEnabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onOpenResourceMonitor(bot.id);
|
|
}}
|
|
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
|
|
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
|
|
>
|
|
<Gauge size={14} />
|
|
</LucentIconButton>
|
|
<LucentIconButton
|
|
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
|
|
disabled={isOperating || !isEnabled}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void onRemoveBot(bot.id);
|
|
}}
|
|
tooltip={labels.delete}
|
|
aria-label={labels.delete}
|
|
>
|
|
<Trash2 size={14} />
|
|
</LucentIconButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
: null}
|
|
{botListPageSizeReady && filteredBots.length === 0 ? (
|
|
<div className="ops-bot-list-empty">{labels.botSearchNoResult}</div>
|
|
) : null}
|
|
</div>
|
|
{botListPageSizeReady ? (
|
|
<div className="ops-bot-list-pagination">
|
|
<LucentIconButton
|
|
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
|
onClick={() => onBotListPageChange((p) => Math.max(1, p - 1))}
|
|
disabled={botListPage <= 1}
|
|
tooltip={labels.paginationPrev}
|
|
aria-label={labels.paginationPrev}
|
|
>
|
|
<ChevronLeft size={14} />
|
|
</LucentIconButton>
|
|
<div className="ops-bot-list-page-indicator pager-status">{labels.paginationPage(botListPage, botListTotalPages)}</div>
|
|
<LucentIconButton
|
|
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
|
|
onClick={() => onBotListPageChange((p) => Math.min(botListTotalPages, p + 1))}
|
|
disabled={botListPage >= botListTotalPages}
|
|
tooltip={labels.paginationNext}
|
|
aria-label={labels.paginationNext}
|
|
>
|
|
<ChevronRight size={14} />
|
|
</LucentIconButton>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|