feat: add node plugin executor functionality

This commit is contained in:
kastov 2026-02-26 23:52:11 +03:00
parent 5fa8f9f973
commit 9571965410
No known key found for this signature in database
GPG key ID: 1B27BE29057F4C90
10 changed files with 596 additions and 6 deletions

8
package-lock.json generated
View file

@ -35,7 +35,7 @@
"@monaco-editor/react": "^4.7.0",
"@noble/post-quantum": "^0.5.4",
"@paralleldrive/cuid2": "3.3.0",
"@remnawave/backend-contract": "2.6.23",
"@remnawave/backend-contract": "2.6.25",
"@remnawave/node-plugins": "^0.1.0",
"@remnawave/subscription-page-types": "0.4.0",
"@simplewebauthn/browser": "^13.2.2",
@ -2988,9 +2988,9 @@
}
},
"node_modules/@remnawave/backend-contract": {
"version": "2.6.23",
"resolved": "https://registry.npmjs.org/@remnawave/backend-contract/-/backend-contract-2.6.23.tgz",
"integrity": "sha512-aJr+At5vUP+nUShMEmJV3+lbiBfVRKtWIS53iKXJS/qxYR+8srg/fTfKJFvsWr/DtuBd0Jy6v+4l2a75ecL/FA==",
"version": "2.6.25",
"resolved": "https://registry.npmjs.org/@remnawave/backend-contract/-/backend-contract-2.6.25.tgz",
"integrity": "sha512-mdhdIy2k6JchVCAn2RftHBEhnUc3VAvlor6qHO4ZdzlY97fuvmKh8LiH9AL1zaClIi6fqD0QbY2fJ8teKYisEg==",
"license": "AGPL-3.0-only",
"dependencies": {
"zod": "3.25.76"

View file

@ -59,7 +59,7 @@
"@monaco-editor/react": "^4.7.0",
"@noble/post-quantum": "^0.5.4",
"@paralleldrive/cuid2": "3.3.0",
"@remnawave/backend-contract": "2.6.23",
"@remnawave/backend-contract": "2.6.25",
"@remnawave/node-plugins": "^0.1.0",
"@remnawave/subscription-page-types": "0.4.0",
"@simplewebauthn/browser": "^13.2.2",

View file

@ -1992,5 +1992,27 @@
"no-node-plugins-yet": "No node plugins yet",
"create-a-plugin-to-extend-node-capabilities-with": "Create a plugin to extend node capabilities with"
}
},
"node-plugin-executor": {
"content": {
"enter-at-least-one-ip-address": "Enter at least one IP address.",
"block-specific-ip-addresses-on-selected-nodes": "Block specific IP addresses on selected nodes",
"block-ips": "Block IPs",
"remove-ip-blocks-on-selected-nodes": "Remove IP blocks on selected nodes",
"unblock-ips": "Unblock IPs",
"executor-description": "IP blocks are temporary — they persist only until the node or its core restarts. Blocks may also be reset if the plugin configuration changes.",
"recreate-nftables-rules-on-selected-nodes": "Recreate nftables rules on selected nodes",
"recreate-tables": "Recreate Tables",
"block-ips-decription": "One entry per line. Timeout 0 = block until restart.",
"unblock-ips-decription": "One IP address per line.",
"ips-to-block": "IPs to block",
"ips-to-unblock": "IPs to unblock",
"format-one-per-line": "Format (one per line):",
"next": "Next",
"no-connected-nodes-available": "No connected nodes available.",
"deselect-all": "Deselect all",
"select-all": "Select all",
"execute": "Execute"
}
}
}

View file

@ -10,6 +10,7 @@ export const MODALS = {
CONFIG_PROFILES_SHOW_ACTIVE_NODE: 'CONFIG_PROFILES_SHOW_ACTIVE_NODE',
INTERNAL_SQUAD_SHOW_INBOUNDS: 'INTERNAL_SQUAD_SHOW_INBOUNDS',
USER_HWID_DEVICES_DRAWER: 'USER_HWID_DEVICES_DRAWER',
NODE_PLUGIN_EXECUTOR_DRAWER: 'NODE_PLUGIN_EXECUTOR_DRAWER',
USER_SUBSCRIPTION_REQUESTS_DRAWER: 'USER_SUBSCRIPTION_REQUESTS_DRAWER',
EXTERNAL_SQUAD_DRAWER: 'EXTERNAL_SQUAD_DRAWER',
USER_ACCESSIBLE_NODES_DRAWER: 'USER_ACCESSIBLE_NODES_DRAWER',
@ -52,6 +53,7 @@ export interface ModalInternalStates {
INTERNAL_SQUAD_SHOW_INBOUNDS: {
squadUuid: string
}
NODE_PLUGIN_EXECUTOR_DRAWER: undefined
RENAME_SQUAD_OR_CONFIG_PROFILE_MODAL: {
name: string
uuid: string

View file

@ -8,14 +8,15 @@ import {
TextInput,
Tooltip
} from '@mantine/core'
import { TbFile, TbPlus, TbRefresh, TbTerminal } from 'react-icons/tb'
import { CreateNodePluginCommand } from '@remnawave/backend-contract'
import { generatePath, useNavigate } from 'react-router-dom'
import { TbFile, TbPlus, TbRefresh } from 'react-icons/tb'
import { useDisclosure } from '@mantine/hooks'
import { useTranslation } from 'react-i18next'
import { useField } from '@mantine/form'
import { QueryKeys, useCreateNodePlugin, useGetNodePlugins } from '@shared/api/hooks'
import { MODALS, useModalsStoreOpenWithData } from '@entities/dashboard/modal-store'
import { UniversalSpotlightActionIconShared } from '@shared/ui/universal-spotlight'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { ROUTES } from '@shared/constants'
@ -26,6 +27,8 @@ export const NodePluginsHeaderActionButtonsFeature = () => {
const { isFetching } = useGetNodePlugins()
const openModalWithData = useModalsStoreOpenWithData()
const [opened, { open, close }] = useDisclosure(false)
const navigate = useNavigate()
@ -64,6 +67,21 @@ export const NodePluginsHeaderActionButtonsFeature = () => {
<Group grow preventGrowOverflow={false} wrap="wrap">
<UniversalSpotlightActionIconShared />
<ActionIconGroup>
<Tooltip label="Executor" withArrow>
<ActionIcon
color="grape"
onClick={() =>
openModalWithData(MODALS.NODE_PLUGIN_EXECUTOR_DRAWER, undefined)
}
size="input-md"
variant="light"
>
<TbTerminal size="24px" />
</ActionIcon>
</Tooltip>
</ActionIconGroup>
<ActionIconGroup>
<Tooltip label={t('common.refresh')} withArrow>
<ActionIcon

View file

@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import { TbFile } from 'react-icons/tb'
import { motion } from 'motion/react'
import { NodePluginExecutorDrawer } from '@widgets/dashboard/node-plugins/node-plugin-executor/node-plugin-executor.drawer'
import { NodePluginsGridWidget } from '@widgets/dashboard/node-plugins/node-plugins-grid/node-plugins-grid.widget'
import { NodePluginsHeaderActionButtonsFeature } from '@features/ui/dashboard/node-plugins/header-action-buttons'
import { NodePluginsSpotlightWidget } from '@widgets/dashboard/node-plugins/node-plugins-spotlight'
@ -37,6 +38,7 @@ export const NodePluginsBasePageComponent = (props: Props) => {
<NodePluginsSpotlightWidget plugins={plugins} />
<RenameModalShared key="rename-node-plugin-modal" renameFrom="nodePlugin" />
<NodePluginExecutorDrawer />
</Page>
)
}

View file

@ -2,6 +2,7 @@ import {
CloneNodePluginCommand,
CreateNodePluginCommand,
DeleteNodePluginCommand,
PluginExecutorCommand,
ReorderNodePluginCommand,
UpdateNodePluginCommand
} from '@remnawave/backend-contract'
@ -121,3 +122,27 @@ export const useCloneNodePlugin = createMutationHook({
}
}
})
export const useNodePluginExecutor = createMutationHook({
endpoint: PluginExecutorCommand.TSQ_url,
bodySchema: PluginExecutorCommand.RequestSchema,
responseSchema: PluginExecutorCommand.ResponseSchema,
requestMethod: PluginExecutorCommand.endpointDetails.REQUEST_METHOD,
rMutationParams: {
onSuccess: () => {
notifications.show({
title: 'Success',
message: 'Request sent',
color: 'teal'
})
},
onError: (error) => {
notifications.show({
title: `Node Plugin Executor`,
message:
error instanceof Error ? error.message : `Request failed with unknown error.`,
color: 'red'
})
}
}
})

View file

@ -0,0 +1 @@
export * from './node-plugin-executor.drawer'

View file

@ -0,0 +1,470 @@
import {
ActionIcon,
Box,
Button,
Checkbox,
Code,
Group,
ScrollArea,
Stack,
Text,
Textarea,
ThemeIcon
} from '@mantine/core'
import {
TbAlertTriangle,
TbArrowBackUp,
TbLock,
TbLockOpen,
TbRefresh,
TbSend,
TbServer2
} from 'react-icons/tb'
import { GetAllNodesCommand } from '@remnawave/backend-contract'
import ReactCountryFlag from 'react-country-flag'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import { z } from 'zod'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { useNodePluginExecutor } from '@shared/api/hooks'
import { ActionCardShared } from '@shared/ui/action-card'
import { SectionCard } from '@shared/ui/section-card'
interface IProps {
nodes: GetAllNodesCommand.Response['response']
onClose: () => void
}
type CommandType = 'blockIps' | 'recreateTables' | 'unblockIps'
type Step = 'command' | 'configure' | 'target'
const BLOCK_PLACEHOLDER = `192.168.1.1;0
10.0.0.1;3600
172.16.0.1;60`
const UNBLOCK_PLACEHOLDER = `192.168.1.1
10.0.0.1
172.16.0.1`
const ipSchema = z.string().ip({ message: 'Invalid IP address' })
export const NodePluginExecutorContent = (props: IProps) => {
const { nodes, onClose } = props
const { t } = useTranslation()
const { mutate: executeNodePlugin, isPending } = useNodePluginExecutor({
mutationFns: {
onSuccess: () => {
onClose()
}
}
})
const [step, setStep] = useState<Step>('command')
const [command, setCommand] = useState<CommandType | null>(null)
const [selectedNodeUuids, setSelectedNodeUuids] = useState<Set<string>>(new Set())
const [blockText, setBlockText] = useState('')
const [unblockText, setUnblockText] = useState('')
const [textError, setTextError] = useState<null | string>(null)
const connectedNodes = nodes.filter((n) => n.isConnected)
const resetAll = () => {
setCommand(null)
setSelectedNodeUuids(new Set())
setBlockText('')
setUnblockText('')
setTextError(null)
}
const selectCommand = (cmd: CommandType) => {
setCommand(cmd)
if (cmd === 'recreateTables') {
setStep('target')
} else {
setStep('configure')
}
}
const goBack = () => {
if (step === 'target' && command !== 'recreateTables') {
setStep('configure')
} else {
setStep('command')
resetAll()
}
}
const toggleNode = useCallback((uuid: string) => {
setSelectedNodeUuids((prev) => {
const next = new Set(prev)
if (next.has(uuid)) {
next.delete(uuid)
} else {
next.add(uuid)
}
return next
})
}, [])
const parseBlockText = () => {
const lines = blockText
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errors: string[] = []
const entries: { ip: string; timeout: number }[] = []
lines.forEach((line, i) => {
const parts = line.split(';')
const ip = parts[0]?.trim() ?? ''
const timeout = parseInt(parts[1]?.trim() ?? '0', 10)
const result = ipSchema.safeParse(ip)
if (!result.success) {
errors.push(`Line ${i + 1}: "${ip}" — ${result.error.errors[0].message}`)
} else {
entries.push({ ip, timeout: Number.isNaN(timeout) ? 0 : timeout })
}
})
return { entries, errors }
}
const parseUnblockText = () => {
const lines = unblockText
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
const errors: string[] = []
const ips: string[] = []
lines.forEach((line, i) => {
const result = ipSchema.safeParse(line)
if (!result.success) {
errors.push(`Line ${i + 1}: "${line}" — ${result.error.errors[0].message}`)
} else {
ips.push(line)
}
})
return { errors, ips }
}
const validateAndProceed = (): boolean => {
if (command === 'blockIps') {
const { entries, errors } = parseBlockText()
if (entries.length === 0 && errors.length === 0) {
setTextError(t('node-plugin-executor.content.enter-at-least-one-ip-address'))
return false
}
if (errors.length > 0) {
setTextError(errors.join('\n'))
return false
}
setTextError(null)
return true
}
if (command === 'unblockIps') {
const { errors, ips } = parseUnblockText()
if (ips.length === 0 && errors.length === 0) {
setTextError(t('node-plugin-executor.content.enter-at-least-one-ip-address'))
return false
}
if (errors.length > 0) {
setTextError(errors.join('\n'))
return false
}
setTextError(null)
return true
}
return true
}
const handleSubmit = () => {
const targetNodes = {
target: 'specificNodes' as const,
nodeUuids: Array.from(selectedNodeUuids)
}
let commandPayload
switch (command) {
case 'blockIps': {
const { entries } = parseBlockText()
commandPayload = { command: 'blockIps' as const, ips: entries }
break
}
case 'recreateTables':
commandPayload = { command: 'recreateTables' as const }
break
case 'unblockIps': {
const { ips } = parseUnblockText()
commandPayload = { command: 'unblockIps' as const, ips }
break
}
default:
commandPayload = { command: 'recreateTables' as const }
}
executeNodePlugin({ variables: { command: commandPayload, targetNodes } })
}
const STEP_MIN_HEIGHT = 380
if (step === 'command') {
return (
<Box mih={STEP_MIN_HEIGHT}>
<Stack gap="md">
<SectionCard.Root>
<SectionCard.Section>
<BaseOverlayHeader
IconComponent={TbAlertTriangle}
iconVariant="gradient-orange"
subtitle={t('node-plugin-executor.content.executor-description')}
title={t('node-plugins-grid.widget.warning')}
titleOrder={5}
/>
</SectionCard.Section>
</SectionCard.Root>
<Stack gap="xs">
<ActionCardShared
description={t(
'node-plugin-executor.content.block-specific-ip-addresses-on-selected-nodes'
)}
icon={<TbLock size={20} />}
onClick={() => selectCommand('blockIps')}
title={t('node-plugin-executor.content.block-ips')}
variant="gradient-red"
/>
<ActionCardShared
description={t(
'node-plugin-executor.content.remove-ip-blocks-on-selected-nodes'
)}
icon={<TbLockOpen size={20} />}
onClick={() => selectCommand('unblockIps')}
title={t('node-plugin-executor.content.unblock-ips')}
variant="gradient-teal"
/>
<ActionCardShared
description={t(
'node-plugin-executor.content.recreate-nftables-rules-on-selected-nodes'
)}
icon={<TbRefresh size={20} />}
onClick={() => selectCommand('recreateTables')}
title={t('node-plugin-executor.content.recreate-tables')}
variant="gradient-orange"
/>
</Stack>
</Stack>
</Box>
)
}
if (step === 'configure') {
const isBlock = command === 'blockIps'
return (
<Box mih={STEP_MIN_HEIGHT}>
<SectionCard.Root>
<SectionCard.Section>
<Group align="flex-start" justify="space-between">
<BaseOverlayHeader
IconComponent={isBlock ? TbLock : TbLockOpen}
iconVariant={isBlock ? 'gradient-cyan' : 'gradient-teal'}
subtitle={
isBlock
? t('node-plugin-executor.content.block-ips-decription')
: t('node-plugin-executor.content.unblock-ips-decription')
}
title={
isBlock
? t('node-plugin-executor.content.ips-to-block')
: t('node-plugin-executor.content.ips-to-unblock')
}
titleOrder={5}
/>
<ActionIcon onClick={goBack} size="lg" variant="default">
<TbArrowBackUp size={20} />
</ActionIcon>
</Group>
</SectionCard.Section>
<SectionCard.Section>
<Text c="dimmed" mb="xs" size="xs">
{t('node-plugin-executor.content.format-one-per-line')}{' '}
<Code>{isBlock ? 'IP;timeout' : 'IP'}</Code>
</Text>
<Textarea
autosize
error={textError}
maxRows={10}
minRows={5}
onChange={(e) => {
if (isBlock) {
setBlockText(e.currentTarget.value)
} else {
setUnblockText(e.currentTarget.value)
}
if (textError) setTextError(null)
}}
placeholder={isBlock ? BLOCK_PLACEHOLDER : UNBLOCK_PLACEHOLDER}
styles={{
input: {
fontFamily: 'var(--mantine-font-family-monospace)'
}
}}
value={isBlock ? blockText : unblockText}
/>
</SectionCard.Section>
<SectionCard.Section>
<Group justify="flex-end">
<Button
onClick={() => {
if (validateAndProceed()) setStep('target')
}}
>
{t('node-plugin-executor.content.next')}
</Button>
</Group>
</SectionCard.Section>
</SectionCard.Root>
</Box>
)
}
const countryFlag = (countryCode: null | string) => {
if (!countryCode || countryCode === 'XX') return <TbServer2 size={14} />
return (
<ReactCountryFlag
countryCode={countryCode}
style={{ fontSize: '1.1em', borderRadius: '2px' }}
/>
)
}
return (
<Box mih={STEP_MIN_HEIGHT}>
<SectionCard.Root>
<SectionCard.Section>
<Group align="flex-start" justify="space-between">
<BaseOverlayHeader
IconComponent={TbServer2}
iconVariant="gradient-violet"
subtitle={`${selectedNodeUuids.size} selected`}
title={t('constants.nodes')}
titleOrder={5}
/>
<ActionIcon onClick={goBack} size="lg" variant="default">
<TbArrowBackUp size={20} />
</ActionIcon>
</Group>
</SectionCard.Section>
{connectedNodes.length > 0 && (
<SectionCard.Section>
<ScrollArea.Autosize mah={280} offsetScrollbars>
<Stack gap={6}>
{connectedNodes.map((node) => {
const isSelected = selectedNodeUuids.has(node.uuid)
return (
<Checkbox.Card
checked={isSelected}
key={node.uuid}
onClick={() => toggleNode(node.uuid)}
p="sm"
radius="md"
style={{
border: isSelected
? '1px solid var(--mantine-color-cyan-6)'
: '1px solid rgba(255,255,255,0.06)',
background: isSelected
? 'var(--mantine-color-cyan-light)'
: 'transparent',
transition: 'all 0.15s ease'
}}
>
<Group gap="sm" wrap="nowrap">
<Checkbox.Indicator size="sm" />
<ThemeIcon
color={isSelected ? 'cyan' : 'gray'}
radius="md"
size="md"
variant="light"
>
{countryFlag(node.countryCode)}
</ThemeIcon>
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Text fw={600} lineClamp={1} size="sm">
{node.name}
</Text>
{node.address && (
<Text
c="dimmed"
ff="monospace"
lineClamp={1}
size="xs"
>
{node.address}
</Text>
)}
</Stack>
</Group>
</Checkbox.Card>
)
})}
</Stack>
</ScrollArea.Autosize>
</SectionCard.Section>
)}
{connectedNodes.length === 0 && (
<SectionCard.Section>
<Text c="dimmed" py="md" size="sm" ta="center">
{t('node-plugin-executor.content.no-connected-nodes-available')}
</Text>
</SectionCard.Section>
)}
<SectionCard.Section>
<Group justify="flex-end">
{connectedNodes.length > 0 && (
<Button
onClick={() => {
if (selectedNodeUuids.size === connectedNodes.length) {
setSelectedNodeUuids(new Set())
} else {
setSelectedNodeUuids(
new Set(connectedNodes.map((n) => n.uuid))
)
}
}}
size="compact-xs"
variant="subtle"
>
{selectedNodeUuids.size === connectedNodes.length
? t('node-plugin-executor.content.deselect-all')
: t('node-plugin-executor.content.select-all')}
</Button>
)}
<Button
color="cyan"
disabled={selectedNodeUuids.size === 0}
loading={isPending}
onClick={handleSubmit}
rightSection={<TbSend size={16} />}
>
{t('node-plugin-executor.content.execute')}
</Button>
</Group>
</SectionCard.Section>
</SectionCard.Root>
</Box>
)
}

View file

@ -0,0 +1,50 @@
import { useMediaQuery } from '@mantine/hooks'
import { TbTerminal } from 'react-icons/tb'
import { em, Modal } from '@mantine/core'
import { motion } from 'motion/react'
import { MODALS, useModalClose, useModalState } from '@entities/dashboard/modal-store'
import { BaseOverlayHeader } from '@shared/ui/overlays/base-overlay-header'
import { LoaderModalShared } from '@shared/ui/loader-modal'
import { useGetNodes } from '@shared/api/hooks'
import { NodePluginExecutorContent } from './node-plugin-executor.content'
export const NodePluginExecutorDrawer = () => {
const { isOpen } = useModalState(MODALS.NODE_PLUGIN_EXECUTOR_DRAWER)
const close = useModalClose(MODALS.NODE_PLUGIN_EXECUTOR_DRAWER)
const { data: nodes, isLoading } = useGetNodes()
const isMobile = useMediaQuery(`(max-width: ${em(768)})`)
return (
<Modal
centered
fullScreen={isMobile}
onClose={close}
opened={isOpen}
size="800px"
title={
<BaseOverlayHeader
IconComponent={TbTerminal}
iconVariant="gradient-cyan"
title="Executor"
/>
}
transitionProps={isMobile ? { transition: 'fade', duration: 200 } : undefined}
>
{isLoading || !nodes ? (
<motion.div
animate={{ opacity: 1 }}
initial={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<LoaderModalShared h="78vh" />
</motion.div>
) : (
<NodePluginExecutorContent nodes={nodes} onClose={close} />
)}
</Modal>
)
}