diff --git a/application/i18n/locales/en/scripts.ts b/application/i18n/locales/en/scripts.ts index 2cbf50e61..b686e093d 100644 --- a/application/i18n/locales/en/scripts.ts +++ b/application/i18n/locales/en/scripts.ts @@ -18,8 +18,10 @@ export const enScriptsMessages = { 'scripts.editor.modalSubtitle': 'Edit metadata and script code in a larger workspace.', 'scripts.editor.lineCount': '{count} lines', 'scripts.editor.resize': 'Resize editor', - 'scripts.targets.hint': 'Automatic triggers apply to selected hosts, or to all hosts when that option is enabled.', + 'scripts.targets.hint': 'Selected groups are resolved dynamically, so hosts added later are included automatically.', 'scripts.targets.connectOrderHint': 'Run order for connect scripts is configured per host under Host details → Automation.', + 'hostDetails.automation.groupScripts': 'Inherited group scripts', + 'hostDetails.automation.groupScriptsHint': 'These scripts follow the host group dynamically and are ordered before the host-specific queue.', 'scripts.actions.runNowHint': 'Run on selected targets, or on all connectable hosts when that option is enabled.', 'scripts.actions.runParallel': 'Run on all tabs (parallel)', 'scripts.actions.runSequential': 'Run on all tabs (sequential)', diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 99d7729ee..0e19948eb 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -659,6 +659,9 @@ export const enTerminalMessages: Messages = { 'snippets.scriptEditor.modalTitle': 'Edit script', 'snippets.targets.title': 'Targets', 'snippets.targets.add': 'Add targets', + 'snippets.targets.selectHosts': 'Hosts', + 'snippets.targets.selectGroups': 'Groups', + 'snippets.targets.noGroups': 'No groups found', 'snippets.targets.allHosts': 'Apply to all hosts', 'snippets.targets.allHostsShort': 'All hosts', 'snippets.targets.allHostsActive': 'Applies to every connectable host.', diff --git a/application/i18n/locales/ru/scripts.ts b/application/i18n/locales/ru/scripts.ts index 79d7407f6..a97bf79a5 100644 --- a/application/i18n/locales/ru/scripts.ts +++ b/application/i18n/locales/ru/scripts.ts @@ -18,8 +18,10 @@ export const ruScriptsMessages = { 'scripts.editor.modalSubtitle': 'Редактируйте метаданные и код скрипта в большом рабочем пространстве.', 'scripts.editor.lineCount': '{count} строк', 'scripts.editor.resize': 'Изменить размер редактора', - 'scripts.targets.hint': 'Автоматические триггеры применяются к выбранным хостам или ко всем хостам, если включена эта настройка.', + 'scripts.targets.hint': 'Выбранные группы разрешаются динамически, поэтому новые хосты включаются автоматически.', 'scripts.targets.connectOrderHint': 'Порядок запуска скриптов подключения настраивается для каждого хоста в разделе «Сведения о хосте → Автоматизация».', + 'hostDetails.automation.groupScripts': 'Унаследованные скрипты группы', + 'hostDetails.automation.groupScriptsHint': 'Эти скрипты динамически следуют за группой хоста и выполняются перед очередью хоста.', 'scripts.actions.runNowHint': 'Запускается для выбранных целей либо для всех доступных для подключения хостов, если включена эта настройка.', 'scripts.actions.runParallel': 'Запустить на всех вкладках (параллельно)', 'scripts.actions.runSequential': 'Запустить на всех вкладках (последовательно)', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index 69123fbb4..cb7fc510e 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -681,6 +681,9 @@ export const ruTerminalMessages: Messages = { 'snippets.field.variableDefault': 'по умолчанию {value}', 'snippets.targets.title': 'Цели', 'snippets.targets.add': 'Добавить цели', + 'snippets.targets.selectHosts': 'Хосты', + 'snippets.targets.selectGroups': 'Группы', + 'snippets.targets.noGroups': 'Группы не найдены', 'snippets.history.title': 'История оболочки', 'snippets.history.subtitle': '{count} команд', 'snippets.history.emptyTitle': 'История оболочки пока пуста', diff --git a/application/i18n/locales/zh-CN/scripts.ts b/application/i18n/locales/zh-CN/scripts.ts index 56bae9c38..d7727a538 100644 --- a/application/i18n/locales/zh-CN/scripts.ts +++ b/application/i18n/locales/zh-CN/scripts.ts @@ -18,8 +18,10 @@ export const zhCNScriptsMessages = { 'scripts.editor.modalSubtitle': '在更大的工作区中编辑脚本元数据和代码。', 'scripts.editor.lineCount': '{count} 行', 'scripts.editor.resize': '调整编辑器高度', - 'scripts.targets.hint': '自动触发作用于所选主机;勾选「对所有主机生效」则作用于全部可连接主机。', + 'scripts.targets.hint': '所选分组会动态匹配,之后加入分组的主机也会自动生效。', 'scripts.targets.connectOrderHint': '连接脚本的执行顺序请在主机详情 → 自动化 中调整。', + 'hostDetails.automation.groupScripts': '分组继承脚本', + 'hostDetails.automation.groupScriptsHint': '这些脚本动态跟随主机所在分组,并在主机专属队列之前执行。', 'scripts.actions.runNowHint': '在所选目标主机上运行;勾选「对所有主机生效」时在所有可连接主机上运行。', 'scripts.actions.runParallel': '在所有标签页并行运行', 'scripts.actions.runSequential': '在所有标签页顺序运行', diff --git a/application/i18n/locales/zh-CN/terminal.ts b/application/i18n/locales/zh-CN/terminal.ts index e6a526e56..2ce8f1b91 100644 --- a/application/i18n/locales/zh-CN/terminal.ts +++ b/application/i18n/locales/zh-CN/terminal.ts @@ -740,8 +740,11 @@ export const zhCNTerminalMessages: Messages = { 'snippets.scriptEditor.expand': '弹窗编辑', 'snippets.scriptEditor.resize': '调整编辑器高度', 'snippets.scriptEditor.modalTitle': '编辑脚本', - 'snippets.targets.title': '目标主机', - 'snippets.targets.add': '添加目标主机', + 'snippets.targets.title': '执行目标', + 'snippets.targets.add': '添加目标', + 'snippets.targets.selectHosts': '选择主机', + 'snippets.targets.selectGroups': '选择分组', + 'snippets.targets.noGroups': '未找到分组', 'snippets.targets.allHosts': '对所有主机生效', 'snippets.targets.allHostsShort': '所有主机', 'snippets.targets.allHostsActive': '将作用于所有可连接的主机。', diff --git a/application/i18n/locales/zh-TW/scripts.ts b/application/i18n/locales/zh-TW/scripts.ts index 445b8a7b7..4e572d720 100644 --- a/application/i18n/locales/zh-TW/scripts.ts +++ b/application/i18n/locales/zh-TW/scripts.ts @@ -18,8 +18,10 @@ export const zhTWScriptsMessages = { 'scripts.editor.modalSubtitle': '在更大的工作區中編輯指令碼的中繼資料與程式碼。', 'scripts.editor.lineCount': '{count} 行', 'scripts.editor.resize': '調整編輯器高度', - 'scripts.targets.hint': '自動觸發會作用於所選主機;勾選「對所有主機生效」則作用於全部可連線的主機。', + 'scripts.targets.hint': '所選分組會動態比對,之後加入分組的主機也會自動生效。', 'scripts.targets.connectOrderHint': '連線指令碼的執行順序請在主機詳情 → 自動化 中調整。', + 'hostDetails.automation.groupScripts': '分組繼承指令碼', + 'hostDetails.automation.groupScriptsHint': '這些指令碼會動態跟隨主機所在分組,並在主機專屬佇列之前執行。', 'scripts.actions.runNowHint': '在所選目標主機上執行;勾選「對所有主機生效」時在所有可連線的主機上執行。', 'scripts.actions.runParallel': '在所有標籤頁並行執行', 'scripts.actions.runSequential': '在所有標籤頁依序執行', diff --git a/application/i18n/locales/zh-TW/terminal.ts b/application/i18n/locales/zh-TW/terminal.ts index 605fac8cb..ddb612200 100644 --- a/application/i18n/locales/zh-TW/terminal.ts +++ b/application/i18n/locales/zh-TW/terminal.ts @@ -737,8 +737,11 @@ export const zhTWTerminalMessages: Messages = { 'snippets.scriptEditor.expand': '彈出視窗編輯', 'snippets.scriptEditor.resize': '調整編輯器高度', 'snippets.scriptEditor.modalTitle': '編輯指令碼', - 'snippets.targets.title': '目標主機', - 'snippets.targets.add': '新增目標主機', + 'snippets.targets.title': '執行目標', + 'snippets.targets.add': '新增目標', + 'snippets.targets.selectHosts': '選擇主機', + 'snippets.targets.selectGroups': '選擇分組', + 'snippets.targets.noGroups': '未找到分組', 'snippets.history.title': 'Shell 歷史', 'snippets.history.subtitle': '{count} 條指令', 'snippets.history.emptyTitle': '暫無 Shell 歷史', diff --git a/application/state/useOutputTriggers.test.ts b/application/state/useOutputTriggers.test.ts index ab455a65e..6f802820e 100644 --- a/application/state/useOutputTriggers.test.ts +++ b/application/state/useOutputTriggers.test.ts @@ -93,6 +93,26 @@ test('hasApplicableOutputTriggerSnippet requires a runnable output trigger for t assert.equal(hasApplicableOutputTriggerSnippet(snippets, 'host-a'), true); }); +test('hasApplicableOutputTriggerSnippet follows the host current group', () => { + const snippets: Snippet[] = [{ + id: 'group-trigger', + label: 'Group trigger', + command: '', + kind: 'script', + trigger: 'onOutput', + triggerPattern: 'READY', + targetGroups: ['Production'], + }]; + assert.equal( + hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Production/Web' }), + true, + ); + assert.equal( + hasApplicableOutputTriggerSnippet(snippets, { id: 'host-a', group: 'Staging' }), + false, + ); +}); + test('output trigger scan buffer consumes scanned content and only keeps overlap', () => { const buffer = createOutputTriggerScanBuffer(4); diff --git a/application/state/useOutputTriggers.ts b/application/state/useOutputTriggers.ts index 05fcdc0ca..f4c805377 100644 --- a/application/state/useOutputTriggers.ts +++ b/application/state/useOutputTriggers.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; -import type { Snippet } from '@/domain/models'; +import type { Host, Snippet } from '@/domain/models'; import { snippetAppliesToOutputTrigger } from '@/domain/snippetTargets.ts'; import { isScriptSnippet } from '@/domain/snippetScript.ts'; import { @@ -19,7 +19,7 @@ const OUTPUT_TRIGGER_DROPPED_OVERFLOW_SCAN_WINDOW_CHARS = 2048; type OutputTriggerContext = { sessionId: string; - hostId?: string; + host?: Pick; snippets: Snippet[]; onRunScript: (snippet: Snippet, sessionId: string) => void | Promise; }; @@ -575,19 +575,22 @@ export function findMatchEndingAfter(text: string, pattern: string, minEndOffset return null; } -export function hasApplicableOutputTriggerSnippet(snippets: Snippet[], hostId?: string): boolean { +export function hasApplicableOutputTriggerSnippet( + snippets: Snippet[], + host?: Pick | string, +): boolean { return snippets.some((snippet) => ( isScriptSnippet(snippet) && snippet.trigger === 'onOutput' && Boolean(snippet.triggerPattern) && Boolean(snippet.id) - && snippetAppliesToOutputTrigger(snippet, hostId) + && snippetAppliesToOutputTrigger(snippet, host) )); } export function useOutputTriggers({ sessionId, - hostId, + host, snippets, onRunScript, }: OutputTriggerContext) { @@ -600,8 +603,8 @@ export function useOutputTriggers({ const pendingDroppedOverflowFinalActionRef = useRef<'leave' | null>(null); const pendingDroppedOverflowScanStateResetRef = useRef(false); const hasOutputTriggers = useMemo( - () => hasApplicableOutputTriggerSnippet(snippets, hostId), - [hostId, snippets], + () => hasApplicableOutputTriggerSnippet(snippets, host), + [host, snippets], ); const scanOutput = useCallback((scannableText: string) => { @@ -621,7 +624,7 @@ export function useOutputTriggers({ if (!isScriptSnippet(snippet) || snippet.trigger !== 'onOutput' || !snippet.triggerPattern || !snippet.id) { continue; } - if (!snippetAppliesToOutputTrigger(snippet, hostId)) continue; + if (!snippetAppliesToOutputTrigger(snippet, host)) continue; try { const matched = findMatchEndingAfter(scanWindow.text, snippet.triggerPattern, scanWindow.minEndOffset); if (!matched) { @@ -647,7 +650,7 @@ export function useOutputTriggers({ // ignore invalid regex } } - }, [hasOutputTriggers, hostId, onRunScript, sessionId, snippets]); + }, [hasOutputTriggers, host, onRunScript, sessionId, snippets]); const scanOutputRef = useRef(scanOutput); scanOutputRef.current = scanOutput; @@ -813,7 +816,7 @@ export function useOutputTriggers({ outputTriggerScanSuppressedRef.current = false; pendingDroppedOverflowFinalActionRef.current = null; pendingDroppedOverflowScanStateResetRef.current = false; - }, [sessionId, hostId, hasOutputTriggers, outputTriggerEventProcessor]); + }, [sessionId, host, hasOutputTriggers, outputTriggerEventProcessor]); useEffect(() => () => { outputTriggerEventProcessor.reset(); diff --git a/components/SelectGroupDialog.tsx b/components/SelectGroupDialog.tsx new file mode 100644 index 000000000..9afa5eb84 --- /dev/null +++ b/components/SelectGroupDialog.tsx @@ -0,0 +1,150 @@ +import { CheckSquare, FolderTree, Search, Square } from 'lucide-react'; +import React, { useMemo, useState } from 'react'; +import { useI18n } from '@/application/i18n/I18nProvider'; +import type { Host } from '@/domain/models'; +import { hostMatchesGroupPath } from '@/domain/selectHostSelection'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { cn } from '@/lib/utils'; + +export interface SelectGroupDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + hosts: Host[]; + customGroups?: string[]; + selectedGroupPaths: string[]; + onSelectionChange: (selectedGroupPaths: string[]) => void; +} + +function collectGroupPaths( + hosts: Host[], + customGroups: string[], + selectedGroupPaths: string[], +): string[] { + const paths = new Set(); + for (const groupPath of [...customGroups, ...selectedGroupPaths]) { + const parts = groupPath.split('/').filter(Boolean); + for (let index = 1; index <= parts.length; index += 1) { + paths.add(parts.slice(0, index).join('/')); + } + } + for (const host of hosts) { + const parts = host.group?.split('/').filter(Boolean) ?? []; + for (let index = 1; index <= parts.length; index += 1) { + paths.add(parts.slice(0, index).join('/')); + } + } + return [...paths].sort((left, right) => left.localeCompare(right)); +} + +export const SelectGroupDialog: React.FC = ({ + open, + onOpenChange, + hosts, + customGroups = [], + selectedGroupPaths, + onSelectionChange, +}) => { + const { t } = useI18n(); + const [searchQuery, setSearchQuery] = useState(''); + const selected = useMemo(() => new Set(selectedGroupPaths), [selectedGroupPaths]); + const groupPaths = useMemo( + () => collectGroupPaths(hosts, customGroups, selectedGroupPaths), + [customGroups, hosts, selectedGroupPaths], + ); + const displayedPaths = useMemo(() => { + const query = searchQuery.trim().toLocaleLowerCase(); + if (!query) return groupPaths; + return groupPaths.filter((path) => path.toLocaleLowerCase().includes(query)); + }, [groupPaths, searchQuery]); + + const togglePath = (path: string) => { + if (selected.has(path)) { + onSelectionChange(selectedGroupPaths.filter((candidate) => candidate !== path)); + return; + } + onSelectionChange([...selectedGroupPaths, path]); + }; + + return ( + + + + {t('snippets.targets.selectGroups')} + + +
+
+ + setSearchQuery(event.target.value)} + placeholder={t('common.searchPlaceholder')} + className="h-8 pl-8" + /> +
+
+ + +
+ {displayedPaths.length === 0 ? ( +

+ {t('snippets.targets.noGroups')} +

+ ) : displayedPaths.map((path) => { + const isSelected = selected.has(path); + const depth = Math.max(0, path.split('/').filter(Boolean).length - 1); + const label = path.split('/').filter(Boolean).pop() ?? path; + const hostCount = hosts.filter( + (host) => host.protocol !== 'serial' && hostMatchesGroupPath(host, path), + ).length; + return ( + + ); + })} +
+
+ + + + +
+
+ ); +}; diff --git a/components/SnippetsManager.tsx b/components/SnippetsManager.tsx index b6e71dbf5..ec82be7b7 100644 --- a/components/SnippetsManager.tsx +++ b/components/SnippetsManager.tsx @@ -484,8 +484,10 @@ const SnippetsManager: React.FC = ({ command: '', package: '', targets: [], + targetGroups: [], }); const [targetSelection, setTargetSelection] = useState([]); + const [targetGroupSelection, setTargetGroupSelection] = useState([]); const [copiedId, setCopiedId] = useState(null); const [selectedPackage, setSelectedPackage] = useState(null); const [newPackageName, setNewPackageName] = useState(''); @@ -735,12 +737,14 @@ const SnippetsManager: React.FC = ({ if (snippet) { setEditingSnippet(snippet); setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets || [])); + setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups || [])); } else { setEditingSnippet(asScript ? { label: '', command: DEFAULT_SCRIPT_TEMPLATE, package: selectedPackage || '', targets: [], + targetGroups: [], kind: 'script', language: 'javascript', trigger: 'manual', @@ -749,8 +753,10 @@ const SnippetsManager: React.FC = ({ command: '', package: selectedPackage || '', targets: [], + targetGroups: [], }); setTargetSelection([]); + setTargetGroupSelection([]); } setRightPanelMode('edit-snippet'); }, [selectedPackage]); @@ -773,6 +779,7 @@ const SnippetsManager: React.FC = ({ tags: editingSnippet.tags || [], package: editingSnippet.package || '', targets: editingSnippet.targetsAllHosts ? [] : targetSelection, + targetGroups: editingSnippet.targetsAllHosts ? [] : targetGroupSelection, targetsAllHosts: editingSnippet.targetsAllHosts || undefined, shortkey: editingSnippet.shortkey, noAutoRun: editingSnippet.noAutoRun, @@ -784,7 +791,7 @@ const SnippetsManager: React.FC = ({ trigger: editingSnippet.trigger, triggerPattern: editingSnippet.triggerPattern, }; - }, [editingSnippet, targetSelection]); + }, [editingSnippet, targetGroupSelection, targetSelection]); const syncHostsAfterSnippetSave = useCallback(( savedSnippet: Snippet, @@ -853,8 +860,9 @@ const SnippetsManager: React.FC = ({ const handleClosePanel = () => { setRightPanelMode('none'); - setEditingSnippet({ label: '', command: '', package: '', targets: [] }); + setEditingSnippet({ label: '', command: '', package: '', targets: [], targetGroups: [] }); setTargetSelection([]); + setTargetGroupSelection([]); }; const hostById = useMemo(() => ( @@ -1663,6 +1671,8 @@ const SnippetsManager: React.FC = ({ customGroups={customGroups} targetSelection={targetSelection} setTargetSelection={setTargetSelection} + targetGroupSelection={targetGroupSelection} + setTargetGroupSelection={setTargetGroupSelection} handleTargetSelect={handleTargetSelect} handleTargetSelectionChange={handleTargetSelectionChange} handleTargetPickerBack={handleTargetPickerBack} diff --git a/components/SnippetsRightPanel.tsx b/components/SnippetsRightPanel.tsx index c49862c99..21bc7560f 100644 --- a/components/SnippetsRightPanel.tsx +++ b/components/SnippetsRightPanel.tsx @@ -3,6 +3,7 @@ import { parseSnippetVariables } from '../domain/snippetVariables'; import { Check, Clock, Keyboard, Loader2, Package, RotateCcw, Trash2 } from 'lucide-react'; import { cn } from '../lib/utils'; import SelectHostPanel from './SelectHostPanel'; +import { SelectGroupDialog } from './SelectGroupDialog'; import { AsidePanel, AsidePanelContent, AsidePanelFooter } from './ui/aside-panel'; import { Button } from './ui/button'; import { Card } from './ui/card'; @@ -29,6 +30,8 @@ export const SnippetsRightPanel: React.FC = ({ customGroups, targetSelection, setTargetSelection, + targetGroupSelection, + setTargetGroupSelection, handleTargetSelect, handleTargetSelectionChange, handleTargetPickerBack, @@ -71,6 +74,7 @@ export const SnippetsRightPanel: React.FC = ({ [editingSnippet?.command], ); const [scriptEditorModalOpen, setScriptEditorModalOpen] = useState(false); + const [groupPickerOpen, setGroupPickerOpen] = useState(false); const isEditingScript = isScriptSnippet(editingSnippet as import('../types').Snippet); const snippetsPanelResizeProps = { resizable: true as const, @@ -81,8 +85,9 @@ export const SnippetsRightPanel: React.FC = ({ const runnableEditingSnippet = useMemo(() => ({ ...(editingSnippet as Snippet), targets: editingSnippet.targetsAllHosts ? [] : targetSelection, + targetGroups: editingSnippet.targetsAllHosts ? [] : targetGroupSelection, targetsAllHosts: editingSnippet.targetsAllHosts || undefined, - }), [editingSnippet, targetSelection]); + }), [editingSnippet, targetGroupSelection, targetSelection]); const runTargets = useMemo( () => getRunnableHostsForSnippet(runnableEditingSnippet, hosts), @@ -99,10 +104,12 @@ export const SnippetsRightPanel: React.FC = ({ const handleTargetsAllHostsChange = (checked: boolean) => { if (checked) { setTargetSelection([]); + setTargetGroupSelection([]); setEditingSnippet({ ...editingSnippet, targetsAllHosts: true, targets: [], + targetGroups: [], }); return; } @@ -137,6 +144,7 @@ export const SnippetsRightPanel: React.FC = ({ if (rightPanelMode === 'edit-snippet') { return ( + <> = ({ setGroupPickerOpen(true) : undefined} hint={isEditingScript ? (editingSnippet.trigger === 'onConnect' ? t('scripts.targets.connectOrderHint') @@ -386,11 +396,29 @@ export const SnippetsRightPanel: React.FC = ({ selectedHostIds={targetSelection} onSelectHost={handleTargetSelect} onSelectionChange={handleTargetSelectionChange} + selectedGroupPaths={targetGroupSelection} + onGroupSelectionChange={setTargetGroupSelection} targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)} onTargetsAllHostsChange={handleTargetsAllHostsChange} /> ) : null} + { + setTargetGroupSelection(nextGroups); + setEditingSnippet({ + ...editingSnippet, + targetGroups: nextGroups, + targetsAllHosts: undefined, + }); + }} + /> + ); } diff --git a/components/Terminal.tsx b/components/Terminal.tsx index de5dcea8d..45630c685 100644 --- a/components/Terminal.tsx +++ b/components/Terminal.tsx @@ -413,7 +413,7 @@ const TerminalComponent: React.FC = ({ const scriptSessionName = sessionDisplayName || host.label; const outputTriggers = useOutputTriggers({ sessionId, - hostId: host.id, + host, snippets, onRunScript: (snippet, sid) => runAutomationScript({ snippet, @@ -424,7 +424,7 @@ const TerminalComponent: React.FC = ({ hostname: host.hostname, username: host.username, }, - }).catch((err) => { + }).then(() => undefined).catch((err) => { const message = err instanceof Error ? err.message : String(err); toast.error(message.includes('Observer mode') ? t('scripts.observer.blocked') : message); throw err; diff --git a/components/VaultView.tsx b/components/VaultView.tsx index a5535eb77..9ec5e967f 100644 --- a/components/VaultView.tsx +++ b/components/VaultView.tsx @@ -69,6 +69,10 @@ import { upsertHostById, } from "../domain/host"; import { exportHostsToCsvWithStats } from "../domain/vaultImport"; +import { + remapSnippetTargetGroupPaths, + removeSnippetTargetGroupPaths, +} from "../domain/hostGroupPathMutations"; import { reorderVaultItems, reorderVaultStrings, @@ -1003,6 +1007,8 @@ const VaultViewInner: React.FC = ({ onUpdateCustomGroups(Array.from(new Set(updatedGroups))); onUpdateHosts(updatedHosts); + const updatedSnippets = remapSnippetTargetGroupPaths(snippets, renameTargetPath, nextPath); + if (updatedSnippets !== snippets) onUpdateSnippets(updatedSnippets); if ( selectedGroupPath && (selectedGroupPath === renameTargetPath || @@ -1072,6 +1078,8 @@ const VaultViewInner: React.FC = ({ } onUpdateCustomGroups(Array.from(new Set(updatedGroups))); onUpdateHosts(updatedHosts); + const nextSnippets = remapSnippetTargetGroupPaths(snippets, oldPath, newPath); + if (nextSnippets !== snippets) onUpdateSnippets(nextSnippets); // Update child config paths too const finalConfigs = updatedConfigs.map((c) => { if (c.path.startsWith(oldPath + "/")) @@ -1103,12 +1111,16 @@ const VaultViewInner: React.FC = ({ onUpdateCustomGroups, onUpdateHosts, onUpdateManagedSources, + onUpdateSnippets, + snippets, t, ], ); const handleDeletedGroupPaths = useCallback( (selectedRoots: string[]) => { + const nextSnippets = removeSnippetTargetGroupPaths(snippets, selectedRoots); + if (nextSnippets !== snippets) onUpdateSnippets(nextSnippets); if ( selectedGroupPath && selectedRoots.some( @@ -1120,7 +1132,7 @@ const VaultViewInner: React.FC = ({ setSelectedGroupPath(null); } }, - [selectedGroupPath], + [onUpdateSnippets, selectedGroupPath, snippets], ); const deleteGroupPaths = useVaultGroupDeletion({ customGroups, @@ -1174,6 +1186,8 @@ const VaultViewInner: React.FC = ({ } onUpdateCustomGroups(Array.from(new Set(updatedGroups))); onUpdateHosts(updatedHosts); + const nextSnippets = remapSnippetTargetGroupPaths(snippets, sourcePath, newPath); + if (nextSnippets !== snippets) onUpdateSnippets(nextSnippets); // Update group configs for moved paths const updatedGroupConfigs = groupConfigs.map((c) => { if (c.path === sourcePath) return { ...c, path: newPath }; @@ -1319,9 +1333,11 @@ const VaultViewInner: React.FC = ({ } = useHostTreeInlineGroupActions({ customGroups, hosts, + snippets, managedSources, onUpdateCustomGroups, onUpdateHosts, + onUpdateSnippets, onUpdateManagedSources, selectedGroupPath, setSelectedGroupPath, diff --git a/components/host/HostDetailsScriptsSection.tsx b/components/host/HostDetailsScriptsSection.tsx index fbcd53914..c7cdcb123 100644 --- a/components/host/HostDetailsScriptsSection.tsx +++ b/components/host/HostDetailsScriptsSection.tsx @@ -1,10 +1,11 @@ -import { Globe, Link2, Play, Trash2 } from 'lucide-react'; +import { FolderTree, Globe, Link2, Play, Trash2 } from 'lucide-react'; import React, { useCallback, useMemo, useState, type DragEvent } from 'react'; import type { Host, Snippet } from '@/domain/models'; import { appendHostConnectScript, getEditableHostConnectScriptIds, getGlobalConnectScripts, + getGroupConnectScriptsForHost, removeHostConnectScript, reorderHostConnectScript, } from '@/domain/hostConnectScripts.ts'; @@ -63,6 +64,16 @@ export const HostDetailsScriptsSection: React.FC () => getEditableHostConnectScriptIds(host, snippets), [host, snippets], ); + const groupScripts = useMemo( + () => getGroupConnectScriptsForHost(host, snippets).filter( + (script) => !script.id || !queueIds.includes(script.id), + ), + [host, queueIds, snippets], + ); + const groupScriptIds = useMemo( + () => new Set(groupScripts.map((script) => script.id)), + [groupScripts], + ); const queuedScripts = useMemo( () => queueIds .map((id) => scriptById(snippets, id)) @@ -73,10 +84,11 @@ export const HostDetailsScriptsSection: React.FC () => scripts.filter((script) => { if (!script.id) return false; if (script.targetsAllHosts) return false; + if (groupScriptIds.has(script.id)) return false; if (queueIds.includes(script.id)) return false; return true; }), - [queueIds, scripts], + [groupScriptIds, queueIds, scripts], ); const linkOptions = useMemo( () => linkableScripts.map((script) => ({ @@ -170,6 +182,41 @@ export const HostDetailsScriptsSection: React.FC ) : null} + {groupScripts.length > 0 ? ( +
+
+ + +
+

+ {t('hostDetails.automation.groupScriptsHint')} +

+
+ {groupScripts.map((script) => ( +
+ } + /> +
+
+ {script.label || t('scripts.running.unnamed')} +
+
+ {script.targetGroups?.join(', ')} +
+
+
+ ))} +
+
+ ) : null} +
{queuedScripts.length === 0 ? (

{t('hostDetails.automation.connectQueueEmpty')}

diff --git a/components/scripts/QuickScriptEditorDialog.tsx b/components/scripts/QuickScriptEditorDialog.tsx index 242a220ab..0d170f4c4 100644 --- a/components/scripts/QuickScriptEditorDialog.tsx +++ b/components/scripts/QuickScriptEditorDialog.tsx @@ -33,6 +33,7 @@ function createBlankScript(): Partial { command: DEFAULT_SCRIPT_TEMPLATE, package: '', targets: [], + targetGroups: [], kind: 'script', language: 'javascript', trigger: 'manual', @@ -54,11 +55,13 @@ export const QuickScriptEditorDialog: React.FC = ( const [open, setOpen] = useState(false); const [editingSnippet, setEditingSnippet] = useState>(createBlankScript); const [targetSelection, setTargetSelection] = useState([]); + const [targetGroupSelection, setTargetGroupSelection] = useState([]); useEffect(() => { const handler = () => { setEditingSnippet(createBlankScript()); setTargetSelection([]); + setTargetGroupSelection([]); setOpen(true); }; window.addEventListener('netcatty:scripts:add', handler); @@ -71,6 +74,7 @@ export const QuickScriptEditorDialog: React.FC = ( if (!snippet || !isScriptSnippet(snippet)) return; setEditingSnippet(snippet); setTargetSelection(snippet.targetsAllHosts ? [] : (snippet.targets ?? [])); + setTargetGroupSelection(snippet.targetsAllHosts ? [] : (snippet.targetGroups ?? [])); setOpen(true); }; window.addEventListener('netcatty:snippets:edit', handler); @@ -98,6 +102,7 @@ export const QuickScriptEditorDialog: React.FC = ( command: detail.code, package: packagePath, targets: [], + targetGroups: [], kind: 'script', language: 'javascript', trigger: 'manual', @@ -112,6 +117,7 @@ export const QuickScriptEditorDialog: React.FC = ( if (detail.editAfterSave) { setEditingSnippet(snippet); setTargetSelection([]); + setTargetGroupSelection([]); setOpen(true); } }; @@ -132,8 +138,9 @@ export const QuickScriptEditorDialog: React.FC = ( const runnableSnippet = useMemo(() => ({ ...(editingSnippet as Snippet), targets: editingSnippet.targetsAllHosts ? [] : targetSelection, + targetGroups: editingSnippet.targetsAllHosts ? [] : targetGroupSelection, targetsAllHosts: editingSnippet.targetsAllHosts || undefined, - }), [editingSnippet, targetSelection]); + }), [editingSnippet, targetGroupSelection, targetSelection]); const runTargets = useMemo( () => getRunnableHostsForSnippet(runnableSnippet, hosts), @@ -174,6 +181,7 @@ export const QuickScriptEditorDialog: React.FC = ( tags: editingSnippet.tags ?? [], package: packagePath, targets: editingSnippet.targetsAllHosts ? [] : targetSelection, + targetGroups: editingSnippet.targetsAllHosts ? [] : targetGroupSelection, targetsAllHosts: editingSnippet.targetsAllHosts || undefined, kind: 'script', language: editingSnippet.language ?? 'javascript', @@ -182,7 +190,7 @@ export const QuickScriptEditorDialog: React.FC = ( triggerPattern: editingSnippet.triggerPattern, order: editingSnippet.order, }; - }, [editingSnippet, onCreatePackage, packages, targetSelection]); + }, [editingSnippet, onCreatePackage, packages, targetGroupSelection, targetSelection]); const persistSnippet = useCallback((): Snippet | null => { const savedSnippet = buildSavedSnippet(); @@ -238,10 +246,12 @@ export const QuickScriptEditorDialog: React.FC = ( const handleTargetsAllHostsChange = useCallback((checked: boolean) => { if (checked) { setTargetSelection([]); + setTargetGroupSelection([]); setEditingSnippet((prev) => ({ ...prev, targetsAllHosts: true, targets: [], + targetGroups: [], })); return; } @@ -266,6 +276,8 @@ export const QuickScriptEditorDialog: React.FC = ( selectedHostIds={targetSelection} onSelectHost={handleSelectHost} onSelectionChange={handleSelectionChange} + selectedGroupPaths={targetGroupSelection} + onGroupSelectionChange={setTargetGroupSelection} targetsAllHosts={Boolean(editingSnippet.targetsAllHosts)} onTargetsAllHostsChange={handleTargetsAllHostsChange} /> diff --git a/components/scripts/ScriptEditorModal.tsx b/components/scripts/ScriptEditorModal.tsx index 15980c315..1b82699c7 100644 --- a/components/scripts/ScriptEditorModal.tsx +++ b/components/scripts/ScriptEditorModal.tsx @@ -5,6 +5,7 @@ import type { Host, Snippet } from '@/domain/models'; import { DEFAULT_SCRIPT_TEMPLATE } from '@/domain/snippetScript.ts'; import { scheduleWindowInputFocus } from '@/application/state/windowInputFocus'; import { SelectHostDialog } from '@/components/SelectHostDialog'; +import { SelectGroupDialog } from '@/components/SelectGroupDialog'; import { ScriptCodeEditor } from './ScriptCodeEditor'; import { ScriptMetaFields } from './ScriptMetaFields'; import { SnippetTargetsSection } from '@/components/snippets/SnippetTargetsSection'; @@ -26,6 +27,8 @@ export interface ScriptEditorModalProps { selectedHostIds: string[]; onSelectHost: (host: Host) => void; onSelectionChange?: (selectedHostIds: string[]) => void; + selectedGroupPaths?: string[]; + onGroupSelectionChange?: (selectedGroupPaths: string[]) => void; targetsAllHosts?: boolean; onTargetsAllHostsChange?: (checked: boolean) => void; } @@ -53,11 +56,14 @@ export const ScriptEditorModal: React.FC = ({ selectedHostIds, onSelectHost, onSelectionChange, + selectedGroupPaths = [], + onGroupSelectionChange, targetsAllHosts = false, onTargetsAllHostsChange, }) => { const { t } = useI18n(); const [targetPickerOpen, setTargetPickerOpen] = useState(false); + const [groupPickerOpen, setGroupPickerOpen] = useState(false); const handleOpenChange = useCallback((isOpen: boolean) => { if (!isOpen) { @@ -81,9 +87,10 @@ export const ScriptEditorModal: React.FC = ({ onChange({ ...snippet, targets: selectedHostIds, + targetGroups: selectedGroupPaths, targetsAllHosts: undefined, }); - }, [onChange, selectedHostIds, snippet]); + }, [onChange, selectedGroupPaths, selectedHostIds, snippet]); const language = 'javascript'; const editorValue = snippet.command || DEFAULT_SCRIPT_TEMPLATE; @@ -146,9 +153,13 @@ export const ScriptEditorModal: React.FC = ({ variant="embedded" t={t} targetHosts={targetHosts} + targetGroups={selectedGroupPaths} onEditTargets={() => { if (!targetsAllHosts) setTargetPickerOpen(true); }} + onEditGroups={onGroupSelectionChange ? () => { + if (!targetsAllHosts) setGroupPickerOpen(true); + } : undefined} hint={t('scripts.targets.hint')} targetsAllHosts={targetsAllHosts} onTargetsAllHostsChange={onTargetsAllHostsChange} @@ -192,6 +203,21 @@ export const ScriptEditorModal: React.FC = ({ onSelectionChange={onSelectionChange} onConfirm={handleTargetsConfirm} /> + { + onGroupSelectionChange?.(nextGroups); + onChange({ + ...snippet, + targetGroups: nextGroups, + targetsAllHosts: undefined, + }); + }} + /> ); }; diff --git a/components/snippets/SnippetTargetsSection.test.tsx b/components/snippets/SnippetTargetsSection.test.tsx new file mode 100644 index 000000000..261b08358 --- /dev/null +++ b/components/snippets/SnippetTargetsSection.test.tsx @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { SnippetTargetsSection } from './SnippetTargetsSection'; + +const t = (key: string) => ({ + 'snippets.targets.title': 'Targets', + 'snippets.targets.selectHosts': 'Select hosts', + 'snippets.targets.selectGroups': 'Select groups', + 'snippets.targets.add': 'Add targets', + 'snippets.targets.allHosts': 'All hosts', + 'snippets.targets.allHostsShort': 'All hosts', + 'snippets.targets.allHostsActive': 'Every host', +}[key] ?? key); + +test('renders dynamic group targets separately from explicit hosts', () => { + const markup = renderToStaticMarkup( + undefined} + onEditGroups={() => undefined} + />, + ); + assert.match(markup, /Production\/Web/); + assert.match(markup, /Select hosts/); + assert.match(markup, /Select groups/); +}); + +test('all-host mode hides host and group pickers', () => { + const markup = renderToStaticMarkup( + undefined} + onEditGroups={() => undefined} + targetsAllHosts + onTargetsAllHostsChange={() => undefined} + />, + ); + assert.doesNotMatch(markup, /Select hosts/); + assert.doesNotMatch(markup, /Select groups/); + assert.match(markup, /Every host/); +}); diff --git a/components/snippets/SnippetTargetsSection.tsx b/components/snippets/SnippetTargetsSection.tsx index 518b48395..c29517ab3 100644 --- a/components/snippets/SnippetTargetsSection.tsx +++ b/components/snippets/SnippetTargetsSection.tsx @@ -1,3 +1,4 @@ +import { FolderTree, Server } from 'lucide-react'; import React from 'react'; import { DistroAvatar } from '../DistroAvatar'; import { Button } from '@/components/ui/button'; @@ -10,6 +11,8 @@ export interface SnippetTargetsSectionProps { t: (key: string, params?: Record) => string; targetHosts: Host[]; onEditTargets: () => void; + targetGroups?: string[]; + onEditGroups?: () => void; hint?: string; variant?: 'card' | 'embedded'; targetsAllHosts?: boolean; @@ -25,6 +28,8 @@ const TargetsBody: React.FC<{ t: SnippetTargetsSectionProps['t']; targetHosts: Host[]; onEditTargets: () => void; + targetGroups?: string[]; + onEditGroups?: () => void; hint?: string; embedded?: boolean; targetsAllHosts?: boolean; @@ -33,6 +38,8 @@ const TargetsBody: React.FC<{ t, targetHosts, onEditTargets, + targetGroups = [], + onEditGroups, hint, embedded = false, targetsAllHosts = false, @@ -50,14 +57,28 @@ const TargetsBody: React.FC<{
{!targetsAllHosts ? ( - + <> + + {onEditGroups ? ( + + ) : null} + ) : null} {onTargetsAllHostsChange ? ( @@ -91,7 +112,7 @@ const TargetsBody: React.FC<{ > {t('snippets.targets.allHostsActive')}

- ) : targetHosts.length === 0 ? ( + ) : targetHosts.length === 0 && targetGroups.length === 0 ? ( embedded ? null : (