Node Manager Pm2

PHP

Plesk extension for managing PM2-powered Node.js applications with process control, monitoring, deployment automation, and seamless server integration.

Stars
16
Forks
2
Downloads
N/A
Open Issues
0
Files main

Repository Files

Loading file structure...
frontend/index.js
import { createElement, Fragment, useEffect, useMemo, useRef, useState } from 'react';
import { createRoot } from 'react-dom';
import {
    Button,
    CodeEditor,
    Dialog,
    Drawer,
    FormField,
    Grid,
    GridCol,
    Icon,
    Item,
    List,
    ListAction,
    ListActions,
    Pagination,
    Select,
    SelectOption,
    Status,
    Switch,
    Tab,
    Tabs,
    Toaster,
    Toolbar,
    ToolbarGroup,
} from '@plesk/plesk-ext-sdk';
import PropTypes from 'prop-types';

const DEFAULT_PERMISSIONS = {
    admin: false,
    canInstallPm2: false,
    canManage: false,
    canControl: false,
    canLogs: false,
};

const DEFAULT_CREATE_FORM = {
    name: '',
    scriptPath: '',
    cwd: '.',
    envName: 'production',
    instances: 1,
    autorestart: true,
    maxRestarts: '',
    restartDelay: '',
    gitRepo: '',
    gitBranch: 'main',
};

const DEFAULT_DEPLOY_FORM = {
    npmInstall: true,
    production: true,
    reload: true,
};

const DEFAULT_PICKER = {
    open: false,
    loading: false,
    field: '',
    mode: 'file',
    title: '',
    currentPath: '',
    currentValue: '.',
    parentValue: null,
    rootPath: '',
    homePath: '',
    entries: [],
    error: '',
};

const ITEMS_PER_PAGE_OPTIONS = [10, 25, 100, 'all'];
const METRICS_PER_PAGE_OPTIONS = [5, 10, 25, 100, 'all'];
const DEFAULT_ITEMS_PER_PAGE = 10;

const getProps = element => {
    try {
        return JSON.parse(element.getAttribute('data-nm-ui-props') || '{}');
    } catch {
        return {};
    }
};

const pleskForgeryToken = () => {
    const meta = document.querySelector('meta[name="forgery_protection_token"], meta#forgery_protection_token');
    if (meta && meta.getAttribute('content')) {
        return meta.getAttribute('content');
    }

    const input = document.querySelector('input[name="forgery_protection_token"]');
    return input && input.value ? input.value : '';
};

const withParams = (url, params = {}) => {
    const query = new URLSearchParams();
    Object.keys(params).forEach(key => {
        if (params[key] !== null && params[key] !== undefined && params[key] !== '') {
            query.set(key, params[key]);
        }
    });
    const qs = query.toString();
    return qs ? `${url}${url.indexOf('?') === -1 ? '?' : '&'}${qs}` : url;
};

const request = (url, options = {}) => {
    const nextOptions = {
        credentials: 'same-origin',
        headers: {
            'X-Requested-With': 'XMLHttpRequest',
            ...(options.headers || {}),
        },
        ...options,
    };

    return fetch(url, nextOptions).then(response => response.text().then(text => {
        let data;
        try {
            data = text ? JSON.parse(text) : {};
        } catch {
            data = {
                success: false,
                error: {
                    message: `${response.status} ${response.statusText}. The server returned a non-JSON response. Check the Plesk extension log for PHP errors.`,
                },
            };
        }

        if (!response.ok && data.success !== false) {
            data = { success: false, error: { message: `${response.status} ${response.statusText}` } };
        }

        return { data };
    }));
};

const unwrap = response => {
    if (!response.data || !response.data.success) {
        const message = response.data && response.data.error
            ? response.data.error.message
            : 'Request failed.';
        throw new Error(message);
    }
    return response.data.data;
};

const formatBytes = bytes => {
    const value = Number(bytes || 0);
    if (value < 1024) return `${value} B`;
    if (value < 1048576) return `${(value / 1024).toFixed(1)} KB`;
    if (value < 1073741824) return `${(value / 1048576).toFixed(1)} MB`;
    return `${(value / 1073741824).toFixed(1)} GB`;
};

const formatUptime = seconds => {
    const value = Number(seconds || 0);
    const days = Math.floor(value / 86400);
    const hours = Math.floor((value % 86400) / 3600);
    const minutes = Math.floor((value % 3600) / 60);
    if (days) return `${days}d ${hours}h`;
    if (hours) return `${hours}h ${minutes}m`;
    return `${minutes}m`;
};

const runtimeLabel = key => ({
    node: 'Node.js',
    npm: 'npm',
    pm2: 'PM2',
    git: 'Git',
}[key] || key);

const settingLabel = key => ({
    pm2Binary: 'PM2 binary',
    nodeBinary: 'Node.js binary',
    npmBinary: 'npm binary',
    gitBinary: 'Git binary',
    extraPath: 'Extra PATH entries',
    pollInterval: 'Polling interval, ms',
    maxLogBytes: 'Max log bytes',
    metricsRetentionDays: 'Metrics retention, days',
    deploymentTimeout: 'Deployment timeout, seconds',
}[key] || key);

const statusIntent = status => {
    const normalized = String(status || '').toLowerCase();
    if (normalized === 'online' || normalized === 'ready') return 'success';
    if (normalized === 'stopped' || normalized === 'waiting restart') return 'warning';
    if (normalized === 'errored' || normalized === 'missing') return 'danger';
    return 'info';
};

const getPageCount = (items, itemsPerPage) => {
    if (!items.length || itemsPerPage === 'all') {
        return 1;
    }

    return Math.max(1, Math.ceil(items.length / itemsPerPage));
};

const getVisibleRows = (items, currentPage, itemsPerPage) => {
    if (itemsPerPage === 'all') {
        return items;
    }

    const offset = (currentPage - 1) * itemsPerPage;
    return items.slice(offset, offset + itemsPerPage);
};

const useListPagination = (items, initialItemsPerPage = DEFAULT_ITEMS_PER_PAGE, pageOptions = ITEMS_PER_PAGE_OPTIONS) => {
    const [currentPage, setCurrentPage] = useState(1);
    const [itemsPerPage, setItemsPerPage] = useState(initialItemsPerPage);
    const totalPages = getPageCount(items, itemsPerPage);

    useEffect(() => {
        if (currentPage > totalPages) {
            setCurrentPage(totalPages);
        }
    }, [currentPage, totalPages]);

    const visibleRows = useMemo(
        () => getVisibleRows(items, currentPage, itemsPerPage),
        [items, currentPage, itemsPerPage],
    );

    const pagination = items.length ? (
        <Pagination
            current={currentPage}
            itemsPerPage={itemsPerPage}
            itemsPerPageOptions={pageOptions}
            onItemsPerPageChange={value => {
                setItemsPerPage(value);
                setCurrentPage(1);
            }}
            onSelect={setCurrentPage}
            total={totalPages}
        />
    ) : null;

    return { pagination, visibleRows };
};

const Feedback = ({ message, intent }) => {
    if (!message) {
        return null;
    }

    return (
        <div className={`nm-ui-message nm-ui-message-${intent}`}>
            {message}
        </div>
    );
};

Feedback.propTypes = {
    intent: PropTypes.string,
    message: PropTypes.string,
};

Feedback.defaultProps = {
    intent: 'info',
    message: '',
};

const AppTabs = ({ active, items, onSelect }) => {
    const visible = items.filter(item => !item.hidden);
    if (!visible.length) {
        return null;
    }

    return (
        <div className="nm-tabs" role="tablist">
            {visible.map(item => (
                <button
                    key={item.key}
                    type="button"
                    className={`nm-tab${item.key === active ? ' nm-tab-active' : ''}`}
                    onClick={() => onSelect(item.key)}
                    role="tab"
                    aria-selected={item.key === active ? 'true' : 'false'}
                >
                    {item.title}
                </button>
            ))}
        </div>
    );
};

AppTabs.propTypes = {
    active: PropTypes.string.isRequired,
    items: PropTypes.arrayOf(PropTypes.shape({
        hidden: PropTypes.bool,
        key: PropTypes.string,
        title: PropTypes.string,
    })).isRequired,
    onSelect: PropTypes.func.isRequired,
};

const EmptyView = ({ title, description, icon }) => (
    <div className="nm-empty-view">
        <Icon name={icon} />
        <div>
            <strong>{title}</strong>
            {description && <span>{description}</span>}
        </div>
    </div>
);

EmptyView.propTypes = {
    description: PropTypes.string,
    icon: PropTypes.string,
    title: PropTypes.string.isRequired,
};

EmptyView.defaultProps = {
    description: '',
    icon: 'info-circle',
};

const SkeletonBlock = ({ className }) => <span className={`nm-skeleton ${className || ''}`} />;

SkeletonBlock.propTypes = {
    className: PropTypes.string,
};

SkeletonBlock.defaultProps = {
    className: '',
};

const WorkspaceSkeleton = () => (
    <div className="nm-skeleton-stack" aria-label="Loading PM2 workspace" aria-busy="true">
        <div className="nm-panel nm-domain-card">
            <div className="nm-domain-grid">
                <div className="nm-field">
                    <SkeletonBlock className="nm-skeleton-label" />
                    <SkeletonBlock className="nm-skeleton-input" />
                </div>
                <div className="nm-field">
                    <SkeletonBlock className="nm-skeleton-label" />
                    <SkeletonBlock className="nm-skeleton-text" />
                </div>
                <div className="nm-field nm-field-root">
                    <SkeletonBlock className="nm-skeleton-label" />
                    <SkeletonBlock className="nm-skeleton-path" />
                </div>
                <div className="nm-field">
                    <SkeletonBlock className="nm-skeleton-label" />
                    <SkeletonBlock className="nm-skeleton-short" />
                </div>
            </div>
        </div>
        <div className="nm-skeleton-tabs">
            <SkeletonBlock className="nm-skeleton-tab" />
            <SkeletonBlock className="nm-skeleton-tab" />
            <SkeletonBlock className="nm-skeleton-tab" />
            <SkeletonBlock className="nm-skeleton-tab" />
        </div>
        <div className="nm-stat-grid">
            {[0, 1, 2, 3, 4].map(item => (
                <div className="nm-stat-card" key={item}>
                    <SkeletonBlock className="nm-skeleton-label" />
                    <SkeletonBlock className="nm-skeleton-number" />
                </div>
            ))}
        </div>
        <div className="nm-panel nm-skeleton-table">
            {[0, 1, 2, 3, 4].map(row => (
                <div className="nm-skeleton-row" key={row}>
                    <SkeletonBlock className="nm-skeleton-cell-wide" />
                    <SkeletonBlock className="nm-skeleton-cell" />
                    <SkeletonBlock className="nm-skeleton-cell" />
                    <SkeletonBlock className="nm-skeleton-cell" />
                </div>
            ))}
        </div>
    </div>
);

const MetricChart = ({ metrics }) => {
    const points = metrics.slice(-48);
    const maxMemory = points.reduce((max, point) => Math.max(max, Number(point.memory || 0)), 1);

    const polyline = (field, max) => {
        if (!points.length) {
            return '';
        }
        if (points.length === 1) {
            const singleValue = Math.max(0, Math.min(Number(points[0][field] || 0), max || 1));
            const singleY = 164 - (singleValue / Math.max(max || 1, 1)) * 140;
            return `0,${singleY.toFixed(1)} 600,${singleY.toFixed(1)}`;
        }

        const limit = Math.max(max || 1, 1);
        return points.map((point, index) => {
            const x = (index / (points.length - 1)) * 600;
            const value = Math.max(0, Math.min(Number(point[field] || 0), limit));
            const y = 164 - (value / limit) * 140;
            return `${x.toFixed(1)},${y.toFixed(1)}`;
        }).join(' ');
    };

    return (
        <div className="nm-panel nm-view-card">
            <div className="nm-panel-header">
                <div>
                    <h3>CPU and memory</h3>
                    <p className="nm-muted">{points.length} sample{points.length === 1 ? '' : 's'}</p>
                </div>
                <div className="nm-metric-legend">
                    <span><i className="nm-legend-cpu" /> CPU</span>
                    <span><i className="nm-legend-memory" /> Memory</span>
                </div>
            </div>
            <svg className="nm-metric-chart" viewBox="0 0 600 180" preserveAspectRatio="none" aria-hidden="true">
                {[24, 59, 94, 129, 164].map(line => (
                    <line key={line} x1="0" y1={line} x2="600" y2={line} className="grid" />
                ))}
                {points.length > 0 && <polyline points={polyline('memory', maxMemory)} className="memory" />}
                {points.length > 0 && <polyline points={polyline('cpu', 100)} className="cpu" />}
            </svg>
            {!points.length && <p className="nm-muted">No metrics collected yet.</p>}
        </div>
    );
};

MetricChart.propTypes = {
    metrics: PropTypes.arrayOf(PropTypes.object).isRequired,
};

const MetricsPanel = ({ metrics }) => {
    const { pagination, visibleRows } = useListPagination(metrics.slice().reverse(), 5, METRICS_PER_PAGE_OPTIONS);
    const columns = [
        { key: 'collected_at', title: 'Time', width: '44%' },
        {
            key: 'status',
            title: 'Status',
            width: '20%',
            render: row => (
                <Status intent={statusIntent(row.status)} compact>
                    {row.status || 'unknown'}
                </Status>
            ),
        },
        { key: 'cpu', title: 'CPU', width: '14%', render: row => `${row.cpu || 0}%` },
        { key: 'memory', title: 'Memory', width: '22%', render: row => formatBytes(row.memory) },
    ];

    return (
        <div className="nm-detail-stack">
            <MetricChart metrics={metrics} />
            <List
                columns={columns}
                data={visibleRows}
                emptyView={<EmptyView title="No metrics found" description="Metrics are collected while process status is refreshed." icon="graph" />}
                emptyViewMode="items"
                pagination={pagination}
                rowKey="id"
                totalRows={metrics.length}
            />
        </div>
    );
};

MetricsPanel.propTypes = {
    metrics: PropTypes.arrayOf(PropTypes.object).isRequired,
};

const NativeInput = props => <input className="nm-native-input" {...props} />;

const NodeManagerApp = ({ config }) => {
    const endpoints = config.endpoints || {};
    const toasterRef = useRef(null);
    const pollTimer = useRef(null);
    const logTimer = useRef(null);
    const alertsToastRef = useRef('');
    const runtimeActionRef = useRef('');
    const processActionRef = useRef({});

    const [loading, setLoading] = useState(true);
    const [busy, setBusy] = useState(false);
    const [runtimeAction, setRuntimeAction] = useState('');
    const [processActions, setProcessActions] = useState({});
    const [accessEnabled, setAccessEnabled] = useState(true);
    const [accessMessage, setAccessMessage] = useState('');
    const [domains, setDomains] = useState([]);
    const [selectedDomainId, setSelectedDomainId] = useState(config.initialDomainId || null);
    const [domainLocked, setDomainLocked] = useState(Boolean(config.domainLocked));
    const [permissions, setPermissions] = useState(DEFAULT_PERMISSIONS);
    const [settings, setSettings] = useState({});
    const [runtime, setRuntime] = useState(null);
    const [alerts, setAlerts] = useState([]);
    const [processes, setProcesses] = useState([]);
    const [activeView, setActiveView] = useState('processes');
    const [showCreate, setShowCreate] = useState(false);
    const [createForm, setCreateForm] = useState(DEFAULT_CREATE_FORM);
    const [picker, setPicker] = useState(DEFAULT_PICKER);
    const [selectedProcess, setSelectedProcess] = useState(null);
    const [activeDetail, setActiveDetail] = useState('logs');
    const [logStream, setLogStream] = useState('stdout');
    const [logBytes] = useState(120000);
    const [logContent, setLogContent] = useState('');
    const [logMeta, setLogMeta] = useState(null);
    const [metrics, setMetrics] = useState([]);
    const [envRows, setEnvRows] = useState([]);
    const [newEnv, setNewEnv] = useState({ name: '', value: '', isSecret: false });
    const [ecosystem, setEcosystem] = useState({ path: '', content: '' });
    const [deployForm, setDeployForm] = useState(DEFAULT_DEPLOY_FORM);
    const [webhook, setWebhook] = useState({ enabled: false, url: '', token: '' });
    const [backups, setBackups] = useState([]);
    const [fileEditor, setFileEditor] = useState({
        open: false,
        path: '',
        value: '',
        content: '',
        originalContent: '',
        loading: false,
        saving: false,
        error: '',
    });

    const selectedDomain = useMemo(() => {
        const id = String(selectedDomainId || '');
        return domains.find(domain => String(domain.id) === id) || null;
    }, [domains, selectedDomainId]);

    const runtimeReady = Boolean(runtime && runtime.ready);
    const nodeReady = Boolean(runtime && runtime.nodeReady);
    const pm2Ready = Boolean(runtime && runtime.pm2Ready);
    const setupNeeded = Boolean(runtime && !runtimeReady);
    const selectedDomainPermissions = selectedDomain && selectedDomain.permissions ? selectedDomain.permissions : {};
    const canManage = Boolean(permissions.admin || permissions.canManage || selectedDomainPermissions.manage);
    const canControl = Boolean(permissions.admin || permissions.canControl || selectedDomainPermissions.control);
    const canLogs = Boolean(permissions.admin || permissions.canLogs || selectedDomainPermissions.logs);
    const selectedRootPath = selectedDomain ? (selectedDomain.documentRoot || selectedDomain.homePath || '') : '';
    const selectedAppId = selectedProcess && selectedProcess.appId ? selectedProcess.appId : '';

    const totals = useMemo(() => {
        let online = 0;
        let memory = 0;
        let cpu = 0;
        let restarts = 0;
        processes.forEach(process => {
            if (process.status === 'online') online += 1;
            memory += Number(process.memory || 0);
            cpu += Number(process.cpu || 0);
            restarts += Number(process.restarts || 0);
        });

        return {
            total: processes.length,
            online,
            memory,
            cpu: Math.round(cpu * 100) / 100,
            restarts,
        };
    }, [processes]);

    const processPagination = useListPagination(processes, 10);

    function clearFeedback() {
    }

    function notify(intent, message) {
        if (toasterRef.current && typeof toasterRef.current.add === 'function') {
            toasterRef.current.add({ intent, message });
        }
    }

    function fail(errorObject) {
        notify('danger', errorObject && errorObject.message ? errorObject.message : 'Request failed.');
    }

    function post(url, data = {}) {
        const token = pleskForgeryToken();
        const payload = token && !data.forgery_protection_token
            ? { ...data, forgery_protection_token: token }
            : data;
        const headers = {
            'Content-Type': 'application/json',
            'X-Node-Manager-CSRF': config.csrf || '',
        };

        if (token) {
            headers['X-Forgery-Protection-Token'] = token;
        }

        return request(url, { method: 'POST', headers, body: JSON.stringify(payload) });
    }

    function get(url) {
        return request(url, { method: 'GET' });
    }

    function bootstrap() {
        setLoading(true);
        return get(withParams(endpoints.bootstrap, {
            domainId: config.initialDomainId || '',
            contextDomainId: config.domainContextId || '',
        }))
            .then(unwrap)
            .then(data => {
                const nextDomains = data.domains || [];
                const enabled = data.accessEnabled !== false;
                const nextRuntime = data.runtime || null;
                setDomains(nextDomains);
                setAccessEnabled(enabled);
                setAccessMessage(data.accessMessage || '');
                setSelectedDomainId(data.selectedDomainId);
                setDomainLocked(Boolean(data.domainLocked || config.domainLocked));
                setPermissions(data.permissions || DEFAULT_PERMISSIONS);
                setSettings(data.settings || {});
                setRuntime(nextRuntime);
                setAlerts(data.alerts || []);

                if (!enabled) {
                    setProcesses([]);
                    setSelectedProcess(null);
                    setActiveView('disabled');
                    return null;
                }

                if (nextRuntime && nextRuntime.ready) {
                    setActiveView('processes');
                    return refreshProcesses(true, data.selectedDomainId);
                }

                setProcesses([]);
                setSelectedProcess(null);
                setActiveView('runtime');
                return null;
            })
            .catch(fail)
            .finally(() => setLoading(false));
    }

    function runtimeInfo(domainId = selectedDomainId) {
        if (!domainId) {
            return Promise.resolve(null);
        }

        return post(endpoints.runtime, { domainId })
            .then(unwrap)
            .then(data => {
                setRuntime(data.runtime || null);
                if (!data.runtime || !data.runtime.ready) {
                    setActiveView('runtime');
                }
                return data.runtime || null;
            })
            .catch(fail);
    }

    function refreshProcesses(silent = false, domainId = selectedDomainId) {
        if (!domainId) {
            return Promise.resolve(null);
        }
        if (!silent) {
            setBusy(true);
        }

        return get(withParams(endpoints.processes, { domainId }))
            .then(unwrap)
            .then(data => {
                const rows = data.processes || [];
                setProcesses(rows);
                setSelectedProcess(current => {
                    if (!current) {
                        return current;
                    }
                    return rows.find(process => process.pm2Name === current.pm2Name) || null;
                });
                return rows;
            })
            .catch(fail)
            .finally(() => {
                if (!silent) {
                    setBusy(false);
                }
            });
    }

    function refreshAll() {
        clearFeedback();
        if (!accessEnabled) {
            return bootstrap();
        }

        setBusy(true);
        return runtimeInfo()
            .then(nextRuntime => {
                if (nextRuntime && nextRuntime.ready) {
                    return refreshProcesses(true);
                }
                return null;
            })
            .finally(() => setBusy(false));
    }

    function selectDomain(domainId) {
        if (domainLocked) {
            setSelectedDomainId(config.domainContextId || selectedDomainId);
            return;
        }

        clearFeedback();
        setShowCreate(false);
        setSelectedDomainId(domainId);
        setSelectedProcess(null);
        setLogContent('');
        runtimeInfo(domainId).then(nextRuntime => {
            if (nextRuntime && nextRuntime.ready) {
                setActiveView('processes');
                refreshProcesses(false, domainId);
            } else {
                setActiveView('runtime');
                setProcesses([]);
            }
        });
    }

    function applyDetectedPaths() {
        if (runtimeActionRef.current) {
            return Promise.resolve(null);
        }
        runtimeActionRef.current = 'paths';
        setRuntimeAction('paths');
        setBusy(true);
        clearFeedback();
        return post(endpoints.runtime, { domainId: selectedDomainId, applyDetectedPaths: true })
            .then(unwrap)
            .then(data => {
                setRuntime(data.runtime || null);
                setSettings(data.settings || settings);
                notify('success', 'Detected runtime paths applied.');
                if (data.runtime && data.runtime.ready) {
                    return refreshProcesses(true);
                }
                return null;
            })
            .catch(fail)
            .finally(() => {
                runtimeActionRef.current = '';
                setRuntimeAction('');
                setBusy(false);
            });
    }

    function installPm2() {
        if (runtimeActionRef.current) {
            return Promise.resolve(null);
        }
        runtimeActionRef.current = 'pm2';
        setRuntimeAction('pm2');
        setBusy(true);
        clearFeedback();
        return post(endpoints.runtime, { domainId: selectedDomainId, installPm2: true })
            .then(unwrap)
            .then(data => {
                setRuntime(data.runtime || null);
                notify('success', 'PM2 install/update completed.');
                if (data.runtime && data.runtime.ready) {
                    setActiveView('processes');
                    return refreshProcesses(true);
                }
                return null;
            })
            .catch(fail)
            .finally(() => {
                runtimeActionRef.current = '';
                setRuntimeAction('');
                setBusy(false);
            });
    }

    function setView(view) {
        clearFeedback();
        setShowCreate(false);
        if (!runtimeReady && view !== 'runtime') {
            setActiveView('runtime');
            fail(new Error('Install PM2 before opening this section.'));
            return;
        }
        setActiveView(view);
    }

    function processAction(process, action) {
        if (action === 'delete' && !canManage) {
            fail(new Error('You do not have permission to delete PM2 applications for this domain.'));
            return Promise.resolve();
        }
        if (action !== 'delete' && !canControl) {
            fail(new Error('You do not have permission to control PM2 processes for this domain.'));
            return Promise.resolve();
        }

        const actionKey = `${process.pm2Name}:${action}`;
        if (processActionRef.current[actionKey]) {
            return Promise.resolve();
        }
        processActionRef.current[actionKey] = true;
        setProcessActions(current => ({ ...current, [actionKey]: true }));
        setBusy(true);
        return post(endpoints.process, {
            domainId: selectedDomainId,
            pm2Name: process.pm2Name,
            action,
        })
            .then(unwrap)
            .then(() => {
                notify('success', 'Action completed.');
                return refreshProcesses(true);
            })
            .catch(fail)
            .finally(() => {
                delete processActionRef.current[actionKey];
                setProcessActions(current => {
                    const next = { ...current };
                    delete next[actionKey];
                    return next;
                });
                setBusy(false);
            });
    }

    function scaleProcess(process, delta) {
        if (!canControl) {
            fail(new Error('You do not have permission to scale PM2 processes for this domain.'));
            return Promise.resolve();
        }

        const target = Math.max(1, Number(process.instances || 1) + delta);
        setBusy(true);
        return post(endpoints.process, {
            domainId: selectedDomainId,
            pm2Name: process.pm2Name,
            action: 'scale',
            instances: target,
        })
            .then(unwrap)
            .then(() => {
                notify('success', 'Scale updated.');
                return refreshProcesses(true);
            })
            .catch(fail)
            .finally(() => setBusy(false));
    }

    function openProcess(process, detail = null) {
        clearFeedback();
        setSelectedProcess(process);
        changeDetail(detail || (canLogs ? 'logs' : 'metrics'), process);
    }

    function closeProcess() {
        if (logTimer.current) {
            clearInterval(logTimer.current);
        }
        logTimer.current = null;
        setSelectedProcess(null);
        setLogContent('');
    }

    function changeDetail(detail, process = selectedProcess) {
        if (!process) {
            return;
        }
        if ((detail === 'env' || detail === 'deploy' || detail === 'ecosystem') && !process.appId) {
            fail(new Error('This PM2 process was not created or imported by Node Manager for this domain yet. Delete and recreate it from this domain to manage environment, deployment, and ecosystem settings.'));
            return;
        }
        clearFeedback();
        setActiveDetail(detail);
    }

    function loadLogs(silent = false) {
        if (!selectedProcess || !canLogs) {
            return Promise.resolve(null);
        }

        return get(withParams(endpoints.logs, {
            domainId: selectedDomainId,
            pm2Name: selectedProcess.pm2Name,
            stream: logStream,
            bytes: logBytes,
        }))
            .then(unwrap)
            .then(data => {
                setLogContent(data.content || '');
                setLogMeta(data);
                if (!silent) {
                    window.setTimeout(() => {
                        const terminal = document.querySelector('.nm-terminal');
                        if (terminal) terminal.scrollTop = terminal.scrollHeight;
                    }, 0);
                }
                return data;
            })
            .catch(fail);
    }

    function clearLogs() {
        if (!selectedProcess) {
            return;
        }
        if (!canManage) {
            fail(new Error('You do not have permission to clear PM2 logs for this domain.'));
            return;
        }

        post(endpoints.clearLogs, {
            domainId: selectedDomainId,
            pm2Name: selectedProcess.pm2Name,
            stream: logStream,
        })
            .then(unwrap)
            .then(() => {
                setLogContent('');
                setLogMeta(current => current ? { ...current, content: '', size: 0, truncated: false } : { size: 0, truncated: false });
                notify('success', 'Logs cleared.');
                return loadLogs(true);
            })
            .catch(fail);
    }

    function downloadLogsUrl() {
        if (!selectedProcess || !canLogs) {
            return '#';
        }

        return withParams(endpoints.downloadLogs, {
            domainId: selectedDomainId,
            pm2Name: selectedProcess.pm2Name,
            stream: logStream,
        });
    }

    function loadMetrics() {
        if (!selectedProcess) {
            return Promise.resolve(null);
        }

        return get(withParams(endpoints.metrics, {
            domainId: selectedDomainId,
            pm2Name: selectedProcess.pm2Name,
        }))
            .then(unwrap)
            .then(data => {
                setMetrics(data.metrics || []);
                return data.metrics || [];
            })
            .catch(() => null);
    }

    function loadEnv() {
        if (!selectedAppId || !canManage) {
            return Promise.resolve(null);
        }

        return get(withParams(endpoints.env, {
            domainId: selectedDomainId,
            appId: selectedAppId,
        }))
            .then(unwrap)
            .then(data => {
                setEnvRows(data.env || []);
                return data.env || [];
            })
            .catch(fail);
    }

    function saveEnv() {
        if (!selectedAppId) {
            return;
        }
        if (!canManage) {
            fail(new Error('You do not have permission to edit environment variables for this domain.'));
            return;
        }

        post(endpoints.env, {
            domainId: selectedDomainId,
            appId: selectedAppId,
            name: newEnv.name,
            value: newEnv.value,
            isSecret: newEnv.isSecret,
        })
            .then(unwrap)
            .then(data => {
                setEnvRows(data.env || []);
                setNewEnv({ name: '', value: '', isSecret: false });
                notify('success', 'Environment updated.');
            })
            .catch(fail);
    }

    function deleteEnv(row) {
        if (!canManage) {
            fail(new Error('You do not have permission to edit environment variables for this domain.'));
            return;
        }

        post(endpoints.env, {
            domainId: selectedDomainId,
            appId: selectedAppId,
            name: row.name,
            delete: true,
        })
            .then(unwrap)
            .then(data => {
                setEnvRows(data.env || []);
                notify('success', 'Environment updated.');
            })
            .catch(fail);
    }

    function loadEcosystem() {
        if (!selectedAppId || !canManage) {
            return Promise.resolve(null);
        }

        return get(withParams(endpoints.ecosystem, {
            domainId: selectedDomainId,
            appId: selectedAppId,
        }))
            .then(unwrap)
            .then(data => {
                setEcosystem(data || { path: '', content: '' });
                return data;
            })
            .catch(fail);
    }

    function saveEcosystem(start) {
        if (!canManage) {
            fail(new Error('You do not have permission to edit ecosystem configs for this domain.'));
            return;
        }

        post(endpoints.ecosystem, {
            domainId: selectedDomainId,
            appId: selectedAppId,
            content: ecosystem.content,
            start: Boolean(start),
        })
            .then(unwrap)
            .then(data => {
                setEcosystem(data || ecosystem);
                notify('success', start ? 'Ecosystem started.' : 'Ecosystem saved.');
                return refreshProcesses(true);
            })
            .catch(fail);
    }

    function deploy() {
        if (!canManage) {
            fail(new Error('You do not have permission to deploy PM2 applications for this domain.'));
            return;
        }
        if (!selectedAppId) {
            return;
        }

        setBusy(true);
        post(endpoints.deploy, {
            ...deployForm,
            domainId: selectedDomainId,
            appId: selectedAppId,
        })
            .then(unwrap)
            .then(data => {
                notify('success', data.message || 'Deployment completed.');
                return refreshProcesses(true);
            })
            .catch(fail)
            .finally(() => setBusy(false));
    }

    function toggleWebhook(enabled) {
        if (!canManage) {
            fail(new Error('You do not have permission to manage webhooks for this domain.'));
            return;
        }

        post(endpoints.webhook, {
            domainId: selectedDomainId,
            appId: selectedAppId,
            enabled,
        })
            .then(unwrap)
            .then(data => {
                setWebhook({
                    enabled: Boolean(data.enabled),
                    url: data.url || '',
                    token: data.token || '',
                });
                notify('success', 'Webhook updated.');
            })
            .catch(fail);
    }

    function createProcess() {
        clearFeedback();
        if (busy) {
            return;
        }
        if (!runtimeReady) {
            setActiveView('runtime');
            fail(new Error('Complete runtime setup before creating a PM2 process.'));
            return;
        }
        if (!canManage) {
            fail(new Error('You do not have permission to create PM2 applications for this domain.'));
            return;
        }

        setBusy(true);
        post(endpoints.createProcess, { ...createForm, domainId: selectedDomainId })
            .then(unwrap)
            .then(() => {
                notify('success', 'Process created.');
                setCreateForm(DEFAULT_CREATE_FORM);
                setShowCreate(false);
                setActiveView('processes');
                return refreshProcesses(true);
            })
            .catch(fail)
            .finally(() => setBusy(false));
    }

    function saveSettings() {
        post(endpoints.settings, settings)
            .then(unwrap)
            .then(data => {
                setSettings(data.settings || settings);
                notify('success', 'Settings saved.');
                return runtimeInfo();
            })
            .catch(fail);
    }

    function listBackups() {
        return post(endpoints.backup, {})
            .then(unwrap)
            .then(data => {
                setBackups(data.backups || []);
                return data.backups || [];
            })
            .catch(fail);
    }

    function createBackup() {
        post(endpoints.backup, { create: true })
            .then(unwrap)
            .then(() => {
                notify('success', 'Backup created.');
                return listBackups();
            })
            .catch(fail);
    }

    function restoreBackup(backup) {
        post(endpoints.backup, { restore: backup.name })
            .then(unwrap)
            .then(() => {
                notify('success', 'Backup restored.');
                return bootstrap();
            })
            .catch(fail);
    }

    function updateCreateField(field, value) {
        setCreateForm(current => ({ ...current, [field]: value }));
    }

    function pickerStartPath(field, mode) {
        const value = createForm[field] || '';
        if (!value) return '.';
        if (mode === 'directory') return value;
        const slash = value.lastIndexOf('/');
        if (slash === -1) return '.';
        if (slash === 0) return '/';
        return value.slice(0, slash);
    }

    function openPicker(field, mode) {
        if (!canManage) {
            fail(new Error('You do not have permission to browse files for this domain.'));
            return;
        }

        const title = mode === 'directory' ? 'Select working directory' : 'Select script file';
        setPicker({
            ...DEFAULT_PICKER,
            open: true,
            field,
            mode,
            title,
        });
        browsePath(pickerStartPath(field, mode), mode);
    }

    function closePicker() {
        setPicker(DEFAULT_PICKER);
    }

    function browsePath(path, mode = picker.mode) {
        setPicker(current => ({ ...current, loading: true, error: '' }));
        return get(withParams(endpoints.browse, {
            domainId: selectedDomainId,
            mode,
            path: path || '.',
        }))
            .then(unwrap)
            .then(data => {
                setPicker(current => ({
                    ...current,
                    currentPath: data.currentPath || '',
                    currentValue: data.currentValue || '.',
                    parentValue: data.parentValue || null,
                    rootPath: data.rootPath || '',
                    homePath: data.homePath || '',
                    entries: data.entries || [],
                    loading: false,
                }));
            })
            .catch(errorObject => {
                setPicker(current => ({
                    ...current,
                    error: errorObject && errorObject.message ? errorObject.message : 'Unable to browse files.',
                    loading: false,
                }));
            });
    }

    function choosePickerEntry(entry) {
        if (entry.type === 'directory') {
            browsePath(entry.value);
            return;
        }
        if (!entry.selectable) {
            return;
        }
        updateCreateField(picker.field, entry.value);
        closePicker();
    }

    function chooseCurrentDirectory() {
        if (picker.mode !== 'directory') {
            return;
        }
        updateCreateField(picker.field, picker.currentValue || '.');
        closePicker();
    }

    function openFileEditor(entry) {
        if (!entry || entry.type !== 'file') {
            return;
        }
        setFileEditor({
            open: true,
            path: entry.value,
            value: entry.value,
            content: '',
            originalContent: '',
            loading: true,
            saving: false,
            error: '',
        });
        closePicker();
        post(endpoints.file, { domainId: selectedDomainId, path: entry.value })
            .then(unwrap)
            .then(data => {
                setFileEditor(current => ({
                    ...current,
                    path: data.path || current.path,
                    value: data.value || entry.value,
                    content: data.content || '',
                    originalContent: data.content || '',
                    loading: false,
                }));
            })
            .catch(errorObject => {
                setFileEditor(current => ({
                    ...current,
                    error: errorObject && errorObject.message ? errorObject.message : 'Unable to open file.',
                    loading: false,
                }));
            });
    }

    function closeFileEditor() {
        setFileEditor({
            open: false,
            path: '',
            value: '',
            content: '',
            originalContent: '',
            loading: false,
            saving: false,
            error: '',
        });
    }

    function saveFileEditor(content) {
        setFileEditor(current => ({ ...current, saving: true, error: '' }));
        post(endpoints.file, {
            domainId: selectedDomainId,
            path: fileEditor.value,
            content,
        })
            .then(unwrap)
            .then(data => {
                setFileEditor(current => ({
                    ...current,
                    path: data.path || current.path,
                    value: data.value || current.value,
                    content: data.content || content,
                    saving: false,
                }));
                notify('success', 'File saved.');
                closeFileEditor();
            })
            .catch(errorObject => {
                setFileEditor(current => ({
                    ...current,
                    error: errorObject && errorObject.message ? errorObject.message : 'Unable to save file.',
                    saving: false,
                }));
            });
    }

    useEffect(() => {
        bootstrap();
        return () => {
            if (pollTimer.current) clearInterval(pollTimer.current);
            if (logTimer.current) clearInterval(logTimer.current);
        };
    }, []);

    useEffect(() => {
        if (!alerts.length) {
            alertsToastRef.current = '';
            return;
        }

        const message = `${alerts.length} alert${alerts.length === 1 ? '' : 's'}: ${alerts.slice(0, 3).map(alert => alert.message).join(' ')}`;
        if (alertsToastRef.current !== message) {
            alertsToastRef.current = message;
            notify('warning', message);
        }
    }, [alerts]);

    useEffect(() => {
        if (pollTimer.current) {
            clearInterval(pollTimer.current);
            pollTimer.current = null;
        }
        if (!accessEnabled || !runtimeReady || activeView !== 'processes') {
            return undefined;
        }
        const interval = Math.max(3000, Number(settings.pollInterval || 5000));
        pollTimer.current = setInterval(() => refreshProcesses(true), interval);
        return () => {
            if (pollTimer.current) clearInterval(pollTimer.current);
            pollTimer.current = null;
        };
    }, [accessEnabled, runtimeReady, activeView, selectedDomainId, settings.pollInterval]);

    useEffect(() => {
        if (activeView === 'backups' && runtimeReady && permissions.admin) {
            listBackups();
        }
    }, [activeView, runtimeReady, permissions.admin]);

    useEffect(() => {
        if (logTimer.current) {
            clearInterval(logTimer.current);
            logTimer.current = null;
        }

        if (!selectedProcess) {
            return undefined;
        }

        if (activeDetail === 'logs') {
            loadLogs();
            logTimer.current = setInterval(() => loadLogs(true), 2500);
        }
        if (activeDetail === 'metrics') loadMetrics();
        if (activeDetail === 'env') loadEnv();
        if (activeDetail === 'ecosystem') loadEcosystem();
        if (!selectedProcess.appId) {
            setEnvRows([]);
            setEcosystem({ path: '', content: '' });
        }

        return () => {
            if (logTimer.current) clearInterval(logTimer.current);
            logTimer.current = null;
        };
    }, [selectedProcess ? selectedProcess.pm2Name : '', activeDetail, logStream]);

    const runtimeRows = ['node', 'npm', 'pm2', 'git'].map(key => ({
        key,
        ...(runtime && runtime.items && runtime.items[key] ? runtime.items[key] : {}),
    }));

    const viewTabs = accessEnabled ? [
        { key: 'processes', title: 'Processes', hidden: !runtimeReady },
        { key: 'runtime', title: 'Runtime' },
        { key: 'settings', title: 'Settings', hidden: !runtimeReady || !permissions.admin },
        { key: 'backups', title: 'Backups', hidden: !runtimeReady || !permissions.admin },
    ] : [];

    const processColumns = [
        {
            key: 'name',
            title: 'Name',
            type: 'title',
            width: '30%',
            render: row => (
                <span>
                    <strong>{row.name || row.pm2Name}</strong>
                    <small className="nm-muted nm-path">{row.scriptPath || '-'}</small>
                </span>
            ),
        },
        {
            key: 'status',
            title: 'Status',
            width: 130,
            render: row => (
                <Status intent={statusIntent(row.status)} compact>
                    {row.status || 'unknown'}
                </Status>
            ),
        },
        { key: 'execMode', title: 'Mode', width: 120 },
        { key: 'cpu', title: 'CPU', width: 90, render: row => `${row.cpu || 0}%` },
        { key: 'memory', title: 'Memory', width: 110, render: row => formatBytes(row.memory) },
        { key: 'uptime', title: 'Uptime', width: 100, render: row => formatUptime(row.uptime) },
        { key: 'restarts', title: 'Restarts', width: 100 },
        {
            key: 'instances',
            title: 'Instances',
            type: 'controls',
            width: 150,
            render: row => (
                <div className="nm-inline-actions" onClick={event => event.stopPropagation()}>
                    {canControl && (
                        <Button icon="minus" tooltip="Decrease instances" tooltipAsLabel onClick={() => scaleProcess(row, -1)} />
                    )}
                    <strong>{row.instances || 1}</strong>
                    {canControl && (
                        <Button icon="plus" tooltip="Increase instances" tooltipAsLabel onClick={() => scaleProcess(row, 1)} />
                    )}
                </div>
            ),
        },
        {
            key: 'actions',
            title: 'Actions',
            type: 'actions',
            width: 280,
            render: row => (
                <div onClick={event => event.stopPropagation()}>
                    <ListActions>
                    {canLogs && <ListAction onClick={() => openProcess(row, 'logs')}>Logs</ListAction>}
                    {canControl && <ListAction disabled={Boolean(processActions[`${row.pm2Name}:start`])} onClick={() => processAction(row, 'start')}>Start</ListAction>}
                    {canControl && <ListAction disabled={Boolean(processActions[`${row.pm2Name}:stop`])} onClick={() => processAction(row, 'stop')}>Stop</ListAction>}
                    {canControl && <ListAction disabled={Boolean(processActions[`${row.pm2Name}:restart`])} onClick={() => processAction(row, 'restart')}>Restart</ListAction>}
                    {canControl && <ListAction disabled={Boolean(processActions[`${row.pm2Name}:reload`])} onClick={() => processAction(row, 'reload')}>Reload</ListAction>}
                    {canManage && <ListAction disabled={Boolean(processActions[`${row.pm2Name}:delete`])} onClick={() => processAction(row, 'delete')}>Delete</ListAction>}
                    </ListActions>
                </div>
            ),
        },
    ];

    const runtimeColumns = [
        {
            key: 'name',
            title: 'Runtime',
            width: '18%',
            render: row => runtimeLabel(row.key),
        },
        {
            key: 'status',
            title: 'Status',
            width: 120,
            render: row => (
                <Status intent={row.available ? 'success' : 'danger'} compact>
                    {row.available ? 'Ready' : 'Missing'}
                </Status>
            ),
        },
        {
            key: 'version',
            title: 'Detected version',
            width: '30%',
            render: row => row.version || 'Not detected',
        },
        {
            key: 'path',
            title: 'Path',
            render: row => (
                <span>
                    {row.path || row.launcher || row.detected || '-'}
                    {row.error && <small className="nm-error-line">{row.error}</small>}
                </span>
            ),
        },
    ];

    const envColumns = [
        { key: 'name', title: 'Name', type: 'title', width: '30%' },
        { key: 'value', title: 'Value', render: row => (row.is_secret ? 'Stored encrypted' : row.value) },
        {
            key: 'actions',
            title: 'Actions',
            type: 'actions',
            width: 90,
            render: row => (
                <ListActions>
                    <ListAction onClick={() => deleteEnv(row)}>Delete</ListAction>
                </ListActions>
            ),
        },
    ];

    const backupColumns = [
        { key: 'name', title: 'Name', type: 'title' },
        { key: 'size', title: 'Size', width: 120, render: row => formatBytes(row.size) },
        { key: 'createdAt', title: 'Created', width: 220 },
        {
            key: 'actions',
            title: 'Actions',
            type: 'actions',
            width: 100,
            render: row => (
                <ListActions>
                    <ListAction onClick={() => restoreBackup(row)}>Restore</ListAction>
                </ListActions>
            ),
        },
    ];

    return (
        <Fragment>
            <Toaster ref={toasterRef} position="top-end" />
            <div className="nm-app nm-ui">
                <div className="nm-top-actions">
                    <Button icon="info-circle" component="a" href={config.infoUrl || '#'}>Info</Button>
                    <Button icon="refresh" disabled={loading} onClick={refreshAll} state={busy ? 'loading' : undefined}>Refresh</Button>
                    {runtimeReady && canManage && (
                        <Button icon="plus" intent="primary" onClick={() => setShowCreate(true)}>New Process</Button>
                    )}
                </div>

                {loading ? (
                    <WorkspaceSkeleton />
                ) : (
                    <Fragment>
                {!accessEnabled && (
                    <div className="nm-panel nm-state-panel">
                        <Icon name="lock-closed" />
                        <div>
                            <h3>Node Manager (PM2) is not enabled</h3>
                            <p>{accessMessage || 'Ask the server administrator to enable Node Manager (PM2) for this subscription or service plan.'}</p>
                        </div>
                    </div>
                )}

                {accessEnabled && (
                    <div className="nm-panel nm-domain-card">
                        <div className="nm-domain-grid">
                            <label className="nm-field nm-field-domain">
                                <span>Domain</span>
                                {!domainLocked ? (
                                    <select
                                        className="nm-native-input"
                                        value={selectedDomainId === null || selectedDomainId === undefined ? '' : String(selectedDomainId)}
                                        onChange={event => selectDomain(event.target.value)}
                                    >
                                        {domains.map(domain => (
                                            <option key={domain.id} value={String(domain.id)}>{domain.name}</option>
                                        ))}
                                    </select>
                                ) : (
                                    <NativeInput readOnly value={selectedDomain ? selectedDomain.name : 'Selected domain'} />
                                )}
                            </label>
                            <div className="nm-field">
                                <span>Subscription user</span>
                                <strong>{selectedDomain ? selectedDomain.systemUser : '-'}</strong>
                            </div>
                            <div className="nm-field nm-field-root">
                                <span>Application root</span>
                                <strong className="nm-path">{selectedRootPath || '-'}</strong>
                            </div>
                            <div className="nm-field">
                                <span>Permissions</span>
                                <strong>{canManage ? 'Manage' : (canControl ? 'Control' : (canLogs ? 'Logs' : 'Access'))}</strong>
                            </div>
                        </div>
                    </div>
                )}

                {accessEnabled && showCreate && (
                    <div className="nm-panel nm-view-card">
                        <div className="nm-panel-header">
                            <div>
                                <h3>Create PM2 process</h3>
                                {selectedRootPath && <p className="nm-muted">Relative paths use {selectedRootPath}.</p>}
                            </div>
                            <div className="nm-card-actions">
                                <Button onClick={() => setShowCreate(false)}>Cancel</Button>
                                <Button intent="primary" onClick={createProcess} state={busy ? 'loading' : undefined}>Create and Start</Button>
                            </div>
                        </div>
                        <div className="nm-form-grid">
                            <label className="nm-field">
                                <span>Name</span>
                                <NativeInput value={createForm.name} placeholder="assets-ssr" onChange={event => updateCreateField('name', event.target.value)} />
                            </label>
                            <label className="nm-field">
                                <span>Script path</span>
                                <div className="nm-input-action">
                                    <NativeInput value={createForm.scriptPath} placeholder="server.js" onChange={event => updateCreateField('scriptPath', event.target.value)} />
                                    <Button icon="folder" onClick={() => openPicker('scriptPath', 'file')}>Browse</Button>
                                </div>
                            </label>
                            <label className="nm-field">
                                <span>Working directory</span>
                                <div className="nm-input-action">
                                    <NativeInput value={createForm.cwd} placeholder="." onChange={event => updateCreateField('cwd', event.target.value)} />
                                    <Button icon="folder-open" onClick={() => openPicker('cwd', 'directory')}>Browse</Button>
                                </div>
                            </label>
                            <label className="nm-field">
                                <span>Environment</span>
                                <NativeInput value={createForm.envName} placeholder="production" onChange={event => updateCreateField('envName', event.target.value)} />
                            </label>
                            <label className="nm-field">
                                <span>Instances</span>
                                <NativeInput type="number" min="1" value={createForm.instances} onChange={event => updateCreateField('instances', event.target.value)} />
                            </label>
                            <label className="nm-field">
                                <span>Max restarts</span>
                                <NativeInput type="number" min="0" value={createForm.maxRestarts} onChange={event => updateCreateField('maxRestarts', event.target.value)} />
                            </label>
                            <label className="nm-field">
                                <span>Restart delay, ms</span>
                                <NativeInput type="number" min="0" value={createForm.restartDelay} onChange={event => updateCreateField('restartDelay', event.target.value)} />
                            </label>
                            <label className="nm-field">
                                <span>Git repository</span>
                                <NativeInput value={createForm.gitRepo} placeholder="https://..." onChange={event => updateCreateField('gitRepo', event.target.value)} />
                            </label>
                            <label className="nm-field">
                                <span>Git branch</span>
                                <NativeInput value={createForm.gitBranch} placeholder="main" onChange={event => updateCreateField('gitBranch', event.target.value)} />
                            </label>
                            <div className="nm-field nm-field-wide">
                                <Switch checked={Boolean(createForm.autorestart)} onChange={checked => updateCreateField('autorestart', checked)}>
                                    Autorestart if the process exits
                                </Switch>
                            </div>
                        </div>
                    </div>
                )}

                {accessEnabled && !showCreate && setupNeeded && (
                    <div className="nm-panel nm-setup-panel">
                        <Icon name="warning-triangle" />
                        <div>
                            <h3>Runtime setup required</h3>
                            <p>
                                {!nodeReady
                                    ? 'Node.js and npm were not found for this subscription. Enable Plesk Node.js support or configure the paths below.'
                                    : (!pm2Ready
                                        ? 'PM2 is not installed for the detected Node.js runtime. Administrators can install it from this page.'
                                        : 'Some runtime checks failed. Review detected paths before creating processes.')}
                            </p>
                        </div>
                        <div className="nm-card-actions">
                            {permissions.admin && (
                                <Button
                                    disabled={Boolean(runtimeAction)}
                                    onClick={applyDetectedPaths}
                                    state={runtimeAction === 'paths' ? 'loading' : undefined}
                                >
                                    Use detected paths
                                </Button>
                            )}
                            {permissions.canInstallPm2 && nodeReady && !pm2Ready && (
                                <Button
                                    disabled={Boolean(runtimeAction)}
                                    intent="primary"
                                    onClick={installPm2}
                                    state={runtimeAction === 'pm2' ? 'loading' : undefined}
                                >
                                    Install PM2
                                </Button>
                            )}
                            <Button onClick={() => setView('runtime')}>Review runtime</Button>
                        </div>
                    </div>
                )}

                {accessEnabled && !showCreate && <AppTabs active={activeView} items={viewTabs} onSelect={setView} />}

                {accessEnabled && !showCreate && activeView === 'processes' && runtimeReady && (
                    <div className="nm-view-stack">
                        <div className="nm-stat-grid">
                            <div className="nm-stat-card"><span>Total</span><strong>{totals.total}</strong></div>
                            <div className="nm-stat-card"><span>Online</span><strong>{totals.online}</strong></div>
                            <div className="nm-stat-card"><span>CPU</span><strong>{totals.cpu}%</strong></div>
                            <div className="nm-stat-card"><span>Memory</span><strong>{formatBytes(totals.memory)}</strong></div>
                            <div className="nm-stat-card"><span>Restarts</span><strong>{totals.restarts}</strong></div>
                        </div>
                        <List
                            columns={processColumns}
                            data={processPagination.visibleRows}
                            emptyView={<EmptyView title="No PM2 processes found for this domain" description="Create a process when your app is ready to run under PM2." icon="server" />}
                            emptyViewMode="items"
                            pagination={processPagination.pagination}
                            rowKey="pm2Name"
                            rowProps={row => ({
                                onClick: () => openProcess(row),
                                className: selectedProcess && selectedProcess.pm2Name === row.pm2Name ? 'nm-selected-row' : '',
                            })}
                            totalRows={processes.length}
                        />
                    </div>
                )}

                {accessEnabled && !showCreate && activeView === 'runtime' && (
                    <div className="nm-panel nm-view-card">
                        <div className="nm-panel-header">
                            <h3>Runtime</h3>
                            <div className="nm-card-actions">
                                {permissions.admin && (
                                    <Button
                                        disabled={Boolean(runtimeAction)}
                                        onClick={applyDetectedPaths}
                                        state={runtimeAction === 'paths' ? 'loading' : undefined}
                                    >
                                        Use detected paths
                                    </Button>
                                )}
                                {permissions.canInstallPm2 && nodeReady && (
                                    <Button
                                        disabled={Boolean(runtimeAction)}
                                        intent="primary"
                                        onClick={installPm2}
                                        state={runtimeAction === 'pm2' ? 'loading' : undefined}
                                    >
                                        Install or update PM2
                                    </Button>
                                )}
                            </div>
                        </div>
                        <List
                            columns={runtimeColumns}
                            data={runtimeRows}
                            emptyView={<EmptyView title="No runtime information" icon="info-circle" />}
                            emptyViewMode="items"
                            rowKey="key"
                        />
                        <div className="nm-field nm-pm2-home">
                            <span>PM2 home</span>
                            <strong className="nm-path">{runtime && runtime.pm2Home ? runtime.pm2Home : '-'}</strong>
                        </div>
                    </div>
                )}

                {accessEnabled && !showCreate && activeView === 'settings' && runtimeReady && permissions.admin && (
                    <div className="nm-panel nm-view-card">
                        <div className="nm-panel-header">
                            <h3>Settings</h3>
                        </div>
                        <div className="nm-form-grid">
                            {Object.keys(settings || {}).map(key => (
                                <label className="nm-field" key={key}>
                                    <span>{settingLabel(key)}</span>
                                    <NativeInput
                                        value={settings[key] === null || settings[key] === undefined ? '' : settings[key]}
                                        onChange={event => setSettings(current => ({ ...current, [key]: event.target.value }))}
                                    />
                                </label>
                            ))}
                        </div>
                        <div className="nm-card-actions">
                            <Button intent="primary" onClick={saveSettings}>Save Settings</Button>
                        </div>
                    </div>
                )}

                {accessEnabled && !showCreate && activeView === 'backups' && runtimeReady && permissions.admin && (
                    <div className="nm-panel nm-view-card">
                        <div className="nm-panel-header">
                            <h3>Backups</h3>
                            <div className="nm-card-actions">
                                <Button icon="plus" intent="primary" onClick={createBackup}>Create Backup</Button>
                            </div>
                        </div>
                        <List
                            columns={backupColumns}
                            data={backups}
                            emptyView={<EmptyView title="No backups created yet" icon="backup" />}
                            emptyViewMode="items"
                            rowKey="name"
                        />
                    </div>
                )}
                    </Fragment>
                )}
            </div>

            <Dialog
                isOpen={picker.open}
                onClose={closePicker}
                size="lg"
                title={picker.title || 'Select path'}
                subtitle={picker.currentPath || selectedRootPath}
                cancelButton={{ children: 'Close' }}
            >
                <Feedback message={picker.error} intent="danger" />
                <Toolbar>
                    <ToolbarGroup title="Browse actions" groupable={false}>
                        <Button icon="home" onClick={() => browsePath('.')}>Document root</Button>
                        <Button icon="arrow-up" disabled={!picker.parentValue} onClick={() => browsePath(picker.parentValue)}>Up</Button>
                        {picker.mode === 'directory' && (
                            <Button intent="primary" icon="check-mark" onClick={chooseCurrentDirectory}>Use this directory</Button>
                        )}
                    </ToolbarGroup>
                </Toolbar>
                <List
                    columns={[
                        {
                            key: 'name',
                            title: 'Name',
                            type: 'title',
                            render: row => (
                                <div className="nm-picker-name">
                                    <button
                                        className="nm-picker-link"
                                        type="button"
                                        onClick={() => choosePickerEntry(row)}
                                    >
                                        <Icon name={row.type === 'directory' ? 'folder' : 'file'} /> {row.name}
                                    </button>
                                    <span className="nm-picker-actions">
                                        {row.type === 'directory' && (
                                            <Button icon="folder-open" onClick={() => browsePath(row.value)}>Open</Button>
                                        )}
                                        {row.type === 'file' && row.selectable && (
                                            <Button icon="check-mark" intent="primary" onClick={() => choosePickerEntry(row)}>Select</Button>
                                        )}
                                        {row.type === 'file' && row.editable && (
                                            <Button icon="pencil" onClick={() => openFileEditor(row)}>Edit</Button>
                                        )}
                                    </span>
                                </div>
                            ),
                        },
                        { key: 'type', title: 'Type', width: 120 },
                        { key: 'modifiedAt', title: 'Modified', width: 190 },
                    ]}
                    data={picker.entries}
                    emptyView={<EmptyView title={picker.loading ? 'Loading...' : 'No files found'} icon="folder" />}
                    emptyViewMode="items"
                    rowKey="path"
                />
            </Dialog>

            <Drawer
                title="Edit file"
                subtitle={fileEditor.value || ''}
                isOpen={fileEditor.open}
                placement="right"
                size="lg"
                className="nm-file-editor-drawer"
                closingConfirmation={fileEditor.content !== fileEditor.originalContent && !fileEditor.saving}
                onClose={closeFileEditor}
                form={{
                    values: {
                        content: fileEditor.content || '',
                    },
                    state: fileEditor.saving ? 'submit' : undefined,
                    submitButton: {
                        children: 'Save',
                        disabled: fileEditor.loading,
                    },
                    cancelButton: {
                        children: 'Close',
                    },
                    applyButton: false,
                    onFieldChange: (name, value) => {
                        if (name === 'content') {
                            setFileEditor(current => ({ ...current, content: value }));
                        }
                    },
                    onSubmit: values => saveFileEditor(values.content || ''),
                }}
                data-type="file-editor"
            >
                <div className="nm-file-editor-drawer-body">
                    {fileEditor.error && <Feedback message={fileEditor.error} intent="danger" />}
                    {fileEditor.loading ? (
                        <div className="nm-file-editor-loading">
                            <SkeletonBlock className="nm-skeleton-label" />
                            <SkeletonBlock className="nm-skeleton-path" />
                            <SkeletonBlock className="nm-skeleton-path" />
                        </div>
                    ) : (
                        <FormField name="content" label={null} vertical>
                            {({ setValue, getValue }) => (
                                <CodeEditor
                                    fileName={fileEditor.value || 'file'}
                                    onChange={content => setValue(content)}
                                    options={{
                                        lineNumbers: true,
                                        lineWrapping: true,
                                        viewportMargin: Infinity,
                                    }}
                                >
                                    {getValue('') || ''}
                                </CodeEditor>
                            )}
                        </FormField>
                    )}
                </div>
            </Drawer>

            <Drawer
                isOpen={Boolean(selectedProcess)}
                onClose={closeProcess}
                size="lg"
                placement="right"
                className="nm-process-drawer"
                title={selectedProcess ? (
                    <div className="nm-process-drawer-title">
                        <span>{selectedProcess.name}</span>
                        <Status intent={statusIntent(selectedProcess.status)} compact>
                            {selectedProcess.status || 'unknown'}
                        </Status>
                    </div>
                ) : ''}
                subtitle={selectedProcess ? selectedProcess.cwd || selectedProcess.scriptPath || '' : ''}
                form={{
                    submitButton: false,
                    applyButton: false,
                    cancelButton: { children: 'Close' },
                }}
            >
                {selectedProcess && (
                    <div className="nm-detail-stack nm-process-detail-stack">
                        <AppTabs
                            active={activeDetail}
                            onSelect={changeDetail}
                            items={[
                                { key: 'logs', title: 'Logs', hidden: !canLogs },
                                { key: 'env', title: 'Env', hidden: !canManage },
                                { key: 'deploy', title: 'Deploy', hidden: !canManage },
                                { key: 'ecosystem', title: 'Ecosystem', hidden: !canManage },
                                { key: 'metrics', title: 'Metrics' },
                            ]}
                        />

                        {activeDetail === 'logs' && (
                            <div className="nm-detail-stack nm-process-pane nm-process-logs">
                                <Toolbar>
                                    <ToolbarGroup title="Log actions" groupable={false}>
                                        <Select value={logStream} onChange={value => setLogStream(value || 'stdout')} size="sm">
                                            <SelectOption value="stdout">stdout</SelectOption>
                                            <SelectOption value="stderr">stderr</SelectOption>
                                        </Select>
                                        <Button icon="refresh" onClick={() => loadLogs()}>Refresh</Button>
                                        {canManage && <Button icon="remove" onClick={clearLogs}>Clear</Button>}
                                        <Button icon="download" component="a" href={downloadLogsUrl()}>Download</Button>
                                        {logMeta && <span className="nm-muted">{formatBytes(logMeta.size)}</span>}
                                    </ToolbarGroup>
                                </Toolbar>
                                <pre className="nm-terminal">{logContent}</pre>
                            </div>
                        )}

                        {activeDetail === 'env' && (
                            <div className="nm-detail-stack nm-process-pane">
                                <div className="nm-env-editor">
                                    <NativeInput placeholder="NAME" value={newEnv.name} onChange={event => setNewEnv(current => ({ ...current, name: event.target.value }))} />
                                    <NativeInput placeholder="value" type={newEnv.isSecret ? 'password' : 'text'} value={newEnv.value} onChange={event => setNewEnv(current => ({ ...current, value: event.target.value }))} />
                                    <Switch checked={Boolean(newEnv.isSecret)} onChange={checked => setNewEnv(current => ({ ...current, isSecret: checked }))}>Secret</Switch>
                                    <Button intent="primary" onClick={saveEnv}>Save</Button>
                                </div>
                                <List
                                    columns={envColumns}
                                    data={envRows}
                                    emptyView={<EmptyView title="No environment variables" icon="key" />}
                                    emptyViewMode="items"
                                    rowKey="name"
                                />
                            </div>
                        )}

                        {activeDetail === 'deploy' && (
                            <div className="nm-detail-stack nm-process-pane">
                                <div className="nm-switch-grid">
                                    <Switch checked={deployForm.npmInstall} onChange={checked => setDeployForm(current => ({ ...current, npmInstall: checked }))}>npm install</Switch>
                                    <Switch checked={deployForm.production} onChange={checked => setDeployForm(current => ({ ...current, production: checked }))}>production dependencies only</Switch>
                                    <Switch checked={deployForm.reload} onChange={checked => setDeployForm(current => ({ ...current, reload: checked }))}>zero-downtime reload</Switch>
                                </div>
                                <div className="nm-card-actions">
                                    <Button intent="primary" icon="upload" onClick={deploy} state={busy ? 'loading' : undefined}>Deploy Latest</Button>
                                    <Button onClick={() => toggleWebhook(true)}>Enable Webhook</Button>
                                    <Button onClick={() => toggleWebhook(false)}>Disable Webhook</Button>
                                </div>
                                {webhook.url && <NativeInput readOnly value={webhook.url} />}
                            </div>
                        )}

                        {activeDetail === 'ecosystem' && (
                            <div className="nm-detail-stack nm-process-pane nm-process-ecosystem">
                                <Toolbar>
                                    <ToolbarGroup title="Ecosystem actions" groupable={false}>
                                        <span className="nm-muted nm-path">{ecosystem.path || '-'}</span>
                                        <Button onClick={() => saveEcosystem(false)}>Save</Button>
                                        <Button intent="primary" onClick={() => saveEcosystem(true)}>Save and Start</Button>
                                    </ToolbarGroup>
                                </Toolbar>
                                <CodeEditor
                                    fileName="ecosystem.config.js"
                                    onChange={content => setEcosystem(current => ({ ...current, content }))}
                                    options={{
                                        lineNumbers: true,
                                        lineWrapping: true,
                                        viewportMargin: Infinity,
                                    }}
                                >
                                    {ecosystem.content || ''}
                                </CodeEditor>
                            </div>
                        )}

                        {activeDetail === 'metrics' && (
                            <div className="nm-process-pane nm-process-metrics">
                                <MetricsPanel metrics={metrics} />
                            </div>
                        )}
                    </div>
                )}
            </Drawer>
        </Fragment>
    );
};

NodeManagerApp.propTypes = {
    config: PropTypes.shape({
        csrf: PropTypes.string,
        domainContextId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
        domainLocked: PropTypes.bool,
        endpoints: PropTypes.object,
        info: PropTypes.object,
        infoUrl: PropTypes.string,
        initialDomainId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
    }).isRequired,
};

const Root = props => <NodeManagerApp config={props} />;

export const mount = id => {
    const element = document.getElementById(id);
    if (!element || element.dataset.nmUiMounted === '1') {
        return;
    }

    element.dataset.nmUiMounted = '1';
    const root = createRoot(element);
    root.render(<Root {...getProps(element)} />);
};

export default { mount };