From 14fe9a920a987cb5afa59c73ac4de6d2019ba127 Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Fri, 3 Jul 2026 00:54:11 +0300 Subject: [PATCH 1/5] feat(nodes): add remote cron management Add a remote cron management surface for saved non-virtual nodes. The node management card now links to a dedicated /panel/nodes/:id/cron page with cron user selection, service status controls, task/raw crontab modes, task add/edit/delete/run-now actions, diff confirmation before saving, conflict messaging, and backup path feedback. Implement the panel route layer and remote cron service behind the existing panel auth/onboarding middleware. The service reads and writes per-user crontabs over existing SSH access, requires baseHash conflict protection, creates restrictive backup/temp files, validates cron users/content, executes Run Now as the selected cron user, sanitizes JSON errors, and rate-limits write/execute/service endpoints. Cover the contract with three standalone Node tests under scripts/: - test-remote-cron-service.js asserts cron validation, hash conflicts, restrictive backup/temp handling, safe command construction, selected-user Run Now behavior, output truncation, and cron service fallbacks. - test-node-cron-panel.js asserts route mounting, node/SSH/user validation, save/run/service JSON behavior, sanitized failures, 409 conflicts, and no cron content or command leakage in logs. - test-node-cron-ui.js asserts the management link, node-cron UI contract, diff confirmation flow, selected-user Run Now payload, unsafe innerHTML avoidance, locale keys, and npm test wiring. Wire all three scripts into the npm test chain so the cron management contract is enforced on every run. --- package.json | 2 +- scripts/test-node-cron-panel.js | 367 +++++++++++++++++++ scripts/test-node-cron-ui.js | 166 +++++++++ scripts/test-remote-cron-service.js | 244 +++++++++++++ src/locales/en.json | 31 ++ src/locales/ru.json | 31 ++ src/locales/zh-CN.json | 31 ++ src/routes/panel/index.js | 2 + src/routes/panel/nodeCron.js | 229 ++++++++++++ src/services/remoteCronService.js | 313 ++++++++++++++++ views/node-cron.ejs | 466 ++++++++++++++++++++++++ views/partials/node-form/management.ejs | 6 +- 12 files changed, 1886 insertions(+), 2 deletions(-) create mode 100644 scripts/test-node-cron-panel.js create mode 100644 scripts/test-node-cron-ui.js create mode 100644 scripts/test-remote-cron-service.js create mode 100644 src/routes/panel/nodeCron.js create mode 100644 src/services/remoteCronService.js create mode 100644 views/node-cron.ejs diff --git a/package.json b/package.json index a1be017..b796484 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "scripts": { "start": "node index.js", "dev": "nodemon index.js", - "test": "node scripts/test-session-cookie-config.js && node scripts/test-i18n-fallback.js && node scripts/test-node-ui-meta.js && node scripts/test-node-active-api.js && node scripts/test-auth-subscription-boundaries.js && node scripts/test-setup-ejs-validation.js && node scripts/test-security-ejs-validation.js", + "test": "node scripts/test-session-cookie-config.js && node scripts/test-i18n-fallback.js && node scripts/test-node-ui-meta.js && node scripts/test-node-active-api.js && node scripts/test-auth-subscription-boundaries.js && node scripts/test-setup-ejs-validation.js && node scripts/test-security-ejs-validation.js && node scripts/test-remote-cron-service.js && node scripts/test-node-cron-panel.js && node scripts/test-node-cron-ui.js", "build:assets": "npx --yes esbuild public/css/style.css public/css/network.css --minify --loader:.css=css --outdir=public/css --out-extension:.css=.min.css --allow-overwrite && npx --yes esbuild public/js/network.js --minify --outfile=public/js/network.min.js" }, "dependencies": { diff --git a/scripts/test-node-cron-panel.js b/scripts/test-node-cron-panel.js new file mode 100644 index 0000000..e1abc9c --- /dev/null +++ b/scripts/test-node-cron-panel.js @@ -0,0 +1,367 @@ +const assert = require('assert'); +const express = require('express'); +const Module = require('module'); + +const originalLoad = Module._load; + +const db = new Map(); +const serviceCalls = []; +const rendered = []; +const warnings = []; +const errors = []; + +let cronResult = { content: '* * * * * echo ok\n', hash: 'hash-1', user: 'root' }; +let statusResult = { service: 'cron', active: true }; +let saveResult = { success: true, hash: 'hash-2', backupPath: '/tmp/backup' }; +let runResult = { code: 0, stdout: 'ok', stderr: '' }; +let reloadResult = { success: true, action: 'reload', service: 'cron' }; +let restartResult = { success: true, action: 'restart', service: 'cron' }; +let serviceError = null; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +function makeServiceError(message, statusCode) { + const error = new Error(message); + if (statusCode) error.statusCode = statusCode; + return error; +} + +const HyNode = { + findById: async (id) => clone(db.get(id) || null), +}; + +const remoteCronService = { + validateCronUser: (user) => { + serviceCalls.push({ method: 'validateCronUser', user }); + if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(String(user || ''))) { + throw new Error('Invalid cron user'); + } + return true; + }, + validateCronContent: (content) => { + serviceCalls.push({ method: 'validateCronContent', content }); + if (typeof content !== 'string') { + throw new Error('Cron content must be a string'); + } + const normalized = content.endsWith('\n') ? content : `${content}\n`; + const lines = normalized.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#') || /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(line)) { + continue; + } + const fields = line.split(/\s+/); + if (fields.length < 6) { + throw new Error(`Invalid cron line ${i + 1}`); + } + } + return true; + }, + getCron: async (node, user) => { + serviceCalls.push({ method: 'getCron', nodeId: String(node._id), user }); + if (serviceError) throw serviceError; + return clone(cronResult); + }, + saveCron: async (node, user, content, baseHash) => { + serviceCalls.push({ method: 'saveCron', nodeId: String(node._id), user, content, baseHash }); + if (serviceError) throw serviceError; + return clone(saveResult); + }, + runCommandNow: async (node, user, command) => { + serviceCalls.push({ method: 'runCommandNow', nodeId: String(node._id), user, command }); + if (serviceError) throw serviceError; + return clone(runResult); + }, + getCronServiceStatus: async (node) => { + serviceCalls.push({ method: 'getCronServiceStatus', nodeId: String(node._id) }); + if (serviceError) throw serviceError; + return clone(statusResult); + }, + reloadCronService: async (node) => { + serviceCalls.push({ method: 'reloadCronService', nodeId: String(node._id) }); + if (serviceError) throw serviceError; + return clone(reloadResult); + }, + restartCronService: async (node) => { + serviceCalls.push({ method: 'restartCronService', nodeId: String(node._id) }); + if (serviceError) throw serviceError; + return clone(restartResult); + }, +}; + +const stubs = { + '../../models/hyNodeModel': HyNode, + '../../services/remoteCronService': remoteCronService, + '../../utils/logger': { + warn: (...args) => warnings.push(args), + error: (...args) => errors.push(args), + }, + './helpers': { + render: (res, template, data) => { + rendered.push({ template, data: clone(data) }); + res.rendered = { template, data }; + return res; + }, + checkIpWhitelist: (_req, _res, next) => next(), + requireAuth: (_req, _res, next) => next(), + requireOnboarding: (_req, _res, next) => next(), + }, + './auth': express.Router(), + './wizard': express.Router(), + './nodes': express.Router(), + './users': express.Router(), + './settings': express.Router(), + './system': express.Router(), + './migration': express.Router(), +}; + +Module._load = function patchedLoad(request, parent, isMain) { + if (Object.prototype.hasOwnProperty.call(stubs, request)) { + return stubs[request]; + } + return originalLoad.call(this, request, parent, isMain); +}; + +const nodeCronRouter = require('../src/routes/panel/nodeCron'); +const panelRouter = require('../src/routes/panel'); +Module._load = originalLoad; + +function reset() { + db.clear(); + serviceCalls.length = 0; + rendered.length = 0; + warnings.length = 0; + errors.length = 0; + cronResult = { content: '* * * * * echo ok\n', hash: 'hash-1', user: 'root' }; + statusResult = { service: 'cron', active: true }; + saveResult = { success: true, hash: 'hash-2', backupPath: '/tmp/backup' }; + runResult = { code: 0, stdout: 'ok', stderr: '' }; + reloadResult = { success: true, action: 'reload', service: 'cron' }; + restartResult = { success: true, action: 'restart', service: 'cron' }; + serviceError = null; +} + +function findRoute(router, method, path) { + const layer = router.stack.find(item => item.route?.path === path && item.route?.methods?.[method]); + assert(layer, `${method.toUpperCase()} ${path} route exists`); + return layer.route.stack.map(item => item.handle); +} + +function makeRes() { + return { + statusCode: 200, + body: undefined, + redirectedTo: null, + rendered: null, + headers: {}, + status(code) { + this.statusCode = code; + return this; + }, + json(payload) { + this.body = payload; + return this; + }, + send(payload) { + this.body = payload; + return this; + }, + redirect(target) { + this.redirectedTo = target; + return this; + }, + setHeader(name, value) { + this.headers[name.toLowerCase()] = value; + }, + getHeader(name) { + return this.headers[name.toLowerCase()]; + }, + }; +} + +async function runRoute(method, path, { id = 'node-1', query = {}, body = {} } = {}) { + const handlers = findRoute(nodeCronRouter, method, path); + const handler = handlers[handlers.length - 1]; + const req = { + params: { id }, + query, + body, + ip: '127.0.0.1', + get: () => undefined, + }; + const res = makeRes(); + await handler(req, res, () => {}); + return res; +} + +(async () => { + assert(findRoute(nodeCronRouter, 'get', '/nodes/:id/cron')); + assert(findRoute(nodeCronRouter, 'get', '/nodes/:id/cron/data')); + assert(findRoute(nodeCronRouter, 'post', '/nodes/:id/cron/save').length > 1, 'save route has local rate limiter'); + assert(findRoute(nodeCronRouter, 'post', '/nodes/:id/cron/run').length > 1, 'run route has local rate limiter'); + assert(findRoute(nodeCronRouter, 'post', '/nodes/:id/cron/service').length > 1, 'service route has local rate limiter'); + assert(panelRouter.stack.some(layer => layer.name === 'router' && layer.handle === nodeCronRouter), 'panel index mounts nodeCron router'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { username: 'admin', password: 'secret' } }); + let res = await runRoute('get', '/nodes/:id/cron'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(rendered[0].template, 'node-cron'); + assert.strictEqual(rendered[0].data.title, 'Cron: Alpha'); + assert.strictEqual(rendered[0].data.page, 'nodes'); + assert.strictEqual(rendered[0].data.defaultCronUser, 'admin'); + assert.strictEqual(rendered[0].data.error, null); + + reset(); + res = await runRoute('get', '/nodes/:id/cron', { id: 'missing' }); + assert.strictEqual(res.redirectedTo, '/panel/nodes'); + + reset(); + db.set('virtual-1', { _id: 'virtual-1', name: 'Virtual', type: 'virtual', ssh: { password: 'secret' } }); + res = await runRoute('get', '/nodes/:id/cron', { id: 'virtual-1' }); + assert.strictEqual(res.statusCode, 400); + assert.match(res.body, /virtual/i); + + reset(); + db.set('no-ssh', { _id: 'no-ssh', name: 'No SSH', type: 'xray', ssh: { username: 'root' } }); + res = await runRoute('get', '/nodes/:id/cron', { id: 'no-ssh' }); + assert.strictEqual(res.statusCode, 400); + assert.match(res.body, /ssh/i); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'hysteria', ssh: { privateKey: 'key' } }); + res = await runRoute('get', '/nodes/:id/cron/data', { query: { user: 'www-data' } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.body, { success: true, cron: cronResult, service: statusResult }); + assert.deepStrictEqual(serviceCalls.map(call => call.method), ['validateCronUser', 'getCron', 'getCronServiceStatus']); + + reset(); + res = await runRoute('get', '/nodes/:id/cron/data', { id: 'missing', query: { user: 'root' } }); + assert.strictEqual(res.statusCode, 404); + assert.deepStrictEqual(res.body, { success: false, error: 'Node not found' }); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('get', '/nodes/:id/cron/data', { query: { user: 'bad user' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid cron user' }); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + serviceError = makeServiceError('Remote read failed', 502); + res = await runRoute('get', '/nodes/:id/cron/data', { query: { user: 'root' } }); + assert.strictEqual(res.statusCode, 502); + assert.deepStrictEqual(res.body, { success: false, error: 'Remote cron operation failed' }); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/save', { body: { user: 'root', content: 'MAILTO=\n', baseHash: 'hash-1' } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.body, saveResult); + assert.deepStrictEqual(serviceCalls.find(call => call.method === 'saveCron'), { + method: 'saveCron', + nodeId: 'node-1', + user: 'root', + content: 'MAILTO=\n', + baseHash: 'hash-1', + }); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/save', { body: { user: 'root', content: 'MAILTO=\n' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Base hash is required' }); + assert(!serviceCalls.some(call => call.method === 'saveCron'), 'missing base hash is rejected before remote save'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/save', { body: { user: 'root', content: '* * echo broken', baseHash: 'hash-1' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid cron line 1' }); + assert(!serviceCalls.some(call => call.method === 'saveCron'), 'invalid cron content is rejected before remote save'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + serviceError = makeServiceError('Cron changed since it was loaded', 409); + res = await runRoute('post', '/nodes/:id/cron/save', { body: { user: 'root', content: 'MAILTO=\n', baseHash: 'old' } }); + assert.strictEqual(res.statusCode, 409); + assert.deepStrictEqual(res.body, { success: false, error: 'Cron changed since it was loaded' }); + assert.strictEqual(warnings.length, 1); + assert(!JSON.stringify(warnings).includes('MAILTO'), 'cron content is not logged'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + serviceError = makeServiceError('ssh failed: stderr includes root password hunter2', 502); + res = await runRoute('post', '/nodes/:id/cron/save', { body: { user: 'root', content: 'MAILTO=\n', baseHash: 'hash-1' } }); + assert.strictEqual(res.statusCode, 502); + assert.deepStrictEqual(res.body, { success: false, error: 'Remote cron operation failed' }); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/run', { body: { user: 'app', command: 'uptime' } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.body, runResult); + assert.deepStrictEqual(serviceCalls.map(call => call.method), ['validateCronUser', 'runCommandNow']); + assert.deepStrictEqual(serviceCalls.find(call => call.method === 'runCommandNow'), { + method: 'runCommandNow', + nodeId: 'node-1', + user: 'app', + command: 'uptime', + }); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/run', { body: { user: 'bad user', command: 'uptime' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid cron user' }); + assert(!serviceCalls.some(call => call.method === 'runCommandNow'), 'invalid run user is rejected before remote run'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/run', { body: { command: ' ' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Command is required' }); + assert(!serviceCalls.some(call => call.method === 'runCommandNow'), 'empty run command is rejected before remote run'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + serviceError = makeServiceError('Command failed with stdout token=secret and stderr password=hunter2'); + res = await runRoute('post', '/nodes/:id/cron/run', { body: { command: 'secret command' } }); + assert.strictEqual(res.statusCode, 500); + assert.deepStrictEqual(res.body, { success: false, error: 'Remote cron operation failed' }); + assert.strictEqual(errors.length, 1); + assert(!JSON.stringify(errors).includes('secret command'), 'command is not logged'); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/service', { body: { action: 'status' } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.body, statusResult); + assert.deepStrictEqual(serviceCalls.map(call => call.method), ['getCronServiceStatus']); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/service', { body: { action: 'reload' } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.body, reloadResult); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/service', { body: { action: 'restart' } }); + assert.strictEqual(res.statusCode, 200); + assert.deepStrictEqual(res.body, restartResult); + + reset(); + db.set('node-1', { _id: 'node-1', name: 'Alpha', type: 'xray', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/service', { body: { action: 'stop' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid service action' }); + + console.log('node cron panel route tests passed'); +})().catch(error => { + Module._load = originalLoad; + console.error(error); + process.exit(1); +}); diff --git a/scripts/test-node-cron-ui.js b/scripts/test-node-cron-ui.js new file mode 100644 index 0000000..12a3688 --- /dev/null +++ b/scripts/test-node-cron-ui.js @@ -0,0 +1,166 @@ +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), 'utf8'); +} + +function readJson(relativePath) { + return JSON.parse(read(relativePath)); +} + +function assertIncludes(content, expected, label) { + assert( + content.includes(expected), + `${label} should include ${expected}`, + ); +} + +const managementPartial = read('views/partials/node-form/management.ejs'); +assertIncludes(managementPartial, '/panel/nodes/<%= node._id %>/cron', 'management partial'); +assertIncludes(managementPartial, "t('nodes.cronTasks')", 'management partial'); +assert( + /if\s*\(\s*node\?\._id\s*&&\s*node\.type\s*!==\s*'virtual'\s*\)/.test(managementPartial), + 'management partial should render management actions only for saved non-virtual nodes', +); + +const cronView = read('views/node-cron.ejs'); +[ + '/panel/nodes/<%= node._id %>/cron/data', + '/panel/nodes/<%= node._id %>/cron/save', + '/panel/nodes/<%= node._id %>/cron/run', + '/panel/nodes/<%= node._id %>/cron/service', + 'id="rawCrontab"', + 'name="minute"', + 'name="hour"', + 'name="dayOfMonth"', + 'name="month"', + 'name="dayOfWeek"', + 'name="command"', + 'id="diffPreview"', + 'id="confirmSaveBtn"', + 'data-action="status"', + 'data-action="reload"', + 'data-action="restart"', + 'data-cron-action="run"', +].forEach(expected => assertIncludes(cronView, expected, 'node cron view')); + +assert( + !/\.innerHTML\s*=|insertAdjacentHTML\s*\(/.test(cronView), + 'node cron view should avoid unsafe HTML injection APIs', +); +assert( + /saveCronBtn['"]\)\.addEventListener\('click',\s*showDiff\)/.test(cronView) + && /confirmSaveBtn['"]\)\.addEventListener\('click',\s*confirmSave\)/.test(cronView) + && !/saveCronBtn['"]\)\.addEventListener\('click',\s*confirmSave\)/.test(cronView), + 'node cron view should require confirmation before POST save', +); +assertIncludes(cronView, 'error.status === 409 ? labels.conflict : error.message', 'node cron view conflict handling'); +assert( + /body:\s*JSON\.stringify\(\{\s*user:\s*cronUser\.value\.trim\(\)\s*\|\|\s*'root',\s*command,?\s*\}\)/.test(cronView), + 'node cron view should include selected user when running a command now', +); + +const buildDiffMatch = cronView.match(/function buildDiff\(before, after\) \{([\s\S]*?)\n \}/); +assert(buildDiffMatch, 'node cron view should define buildDiff'); +const buildDiffSource = buildDiffMatch[0]; +assert( + !/\.includes\(/.test(buildDiffSource), + 'buildDiff should not use duplicate-unsafe includes checks', +); +assert( + /lcs|counts|commonLengths|dp/i.test(buildDiffSource), + 'buildDiff should include a duplicate-aware implementation marker', +); +const buildDiff = new Function(`${buildDiffSource}; return buildDiff;`)(); +assert.strictEqual( + buildDiff('same\nsame\nremove\n', 'same\nsame\nadd\n'), + '- remove\n+ add', + 'buildDiff should preserve duplicate unchanged lines while showing real changes', +); +assert.strictEqual( + buildDiff('dup\ndup\n', 'dup\n'), + '- dup', + 'buildDiff should show removal when only one duplicate line is removed', +); +assert.strictEqual( + buildDiff('dup\n', 'dup\ndup\n'), + '+ dup', + 'buildDiff should show addition when only one duplicate line is added', +); + +[ + 'react', + 'vue', + 'angular', + 'svelte', + 'alpine', + 'jquery', +].forEach(framework => { + assert( + !new RegExp(`<(script|link)[^>]+${framework}`, 'i').test(cronView), + `node cron view should not import ${framework}`, + ); +}); + +const requiredNodeKeys = [ + 'cronTasks', + 'cronBackToNode', + 'cronUser', + 'cronServiceStatus', + 'cronServiceStatusButton', + 'cronServiceReload', + 'cronServiceRestart', + 'cronModeTasks', + 'cronModeRaw', + 'cronRawCrontab', + 'cronAddTask', + 'cronMinute', + 'cronHour', + 'cronDayOfMonth', + 'cronMonth', + 'cronDayOfWeek', + 'cronCommand', + 'cronRunNow', + 'cronEdit', + 'cronDelete', + 'cronSave', + 'cronDiffPreview', + 'cronConfirmSave', + 'cronCancel', + 'cronBackupPath', + 'cronConflictReload', + 'cronOutput', + 'cronRawOnly', + 'cronLoad', + 'cronLoading', + 'cronSaved', +]; + +[ + 'src/locales/ru.json', + 'src/locales/en.json', + 'src/locales/zh-CN.json', +].forEach(localePath => { + const locale = readJson(localePath); + requiredNodeKeys.forEach(key => { + assert( + Object.prototype.hasOwnProperty.call(locale.nodes, key), + `${localePath} should contain nodes.${key}`, + ); + }); +}); + +const packageJson = readJson('package.json'); +[ + 'node scripts/test-remote-cron-service.js', + 'node scripts/test-node-cron-panel.js', + 'node scripts/test-node-cron-ui.js', +].forEach(command => { + assertIncludes(packageJson.scripts.test, command, 'package npm test script'); +}); + +console.log('node cron UI tests passed'); diff --git a/scripts/test-remote-cron-service.js b/scripts/test-remote-cron-service.js new file mode 100644 index 0000000..d56f501 --- /dev/null +++ b/scripts/test-remote-cron-service.js @@ -0,0 +1,244 @@ +const assert = require('assert'); +const crypto = require('crypto'); + +const { + createRemoteCronService, + validateCronUser, + validateCronContent, +} = require('../src/services/remoteCronService'); + +function hash(content) { + return crypto.createHash('sha256').update(content).digest('hex'); +} + +function createNode(username = 'root') { + return { + _id: 'node-1', + name: 'Node 1', + ip: '127.0.0.1', + ssh: { username }, + }; +} + +function makeSSHClass(handler) { + const instances = []; + + class FakeSSH { + constructor(node) { + this.node = node; + this.commands = []; + this.connected = false; + this.disconnected = false; + instances.push(this); + } + + async connect() { + this.connected = true; + } + + async exec(command) { + this.commands.push(command); + return handler(command, this); + } + + async writeFile(remotePath, content) { + this.commands.push(`writeFile:${remotePath}:${content}`); + return undefined; + } + + disconnect() { + this.disconnected = true; + } + } + + FakeSSH.instances = instances; + return FakeSSH; +} + +(async () => { + assert.doesNotThrow(() => validateCronUser('root')); + assert.doesNotThrow(() => validateCronUser('backup-user_1')); + assert.throws(() => validateCronUser(''), /Invalid cron user/); + assert.throws(() => validateCronUser('bad user'), /Invalid cron user/); + assert.throws(() => validateCronUser('bad;user'), /Invalid cron user/); + + assert.doesNotThrow(() => validateCronContent('SHELL=/bin/bash\n# comment\n*/5 * * * * /usr/bin/true\n@reboot /usr/bin/true\n')); + assert.throws(() => validateCronContent('* * * * /usr/bin/true'), /Invalid cron line/); + assert.throws(() => validateCronContent('NAME'), /Invalid cron line/); + assert.throws(() => validateCronContent('A=B\0'), /NUL/); + assert.throws(() => validateCronContent('x'.repeat(64 * 1024 + 1)), /64 KiB/); + + { + const SSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, 'crontab -l'); + return { code: 0, stdout: 'SHELL=/bin/bash\r\n* * * * * echo ok\r\n', stderr: '' }; + }); + const service = createRemoteCronService({ SSHClass }); + const result = await service.getCron(createNode('root'), 'root'); + assert.deepStrictEqual(result, { + content: 'SHELL=/bin/bash\n* * * * * echo ok\n', + hash: hash('SHELL=/bin/bash\n* * * * * echo ok\n'), + user: 'root', + }); + assert.strictEqual(SSHClass.instances[0].disconnected, true); + } + + { + const SSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, 'sudo -n crontab -u other -l'); + return { code: 1, stdout: '', stderr: 'no crontab for other' }; + }); + const service = createRemoteCronService({ SSHClass }); + const result = await service.getCron(createNode('deployer'), 'other'); + assert.strictEqual(result.content, ''); + assert.strictEqual(result.hash, hash('')); + } + + { + const SSHClass = makeSSHClass(async (command) => { + if (command === 'crontab -u app -l') { + return { code: 0, stdout: '* * * * * echo changed\n', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }); + const service = createRemoteCronService({ SSHClass }); + await assert.rejects( + () => service.saveCron(createNode('root'), 'app', '* * * * * echo new\n', hash('* * * * * echo old\n')), + (error) => error.statusCode === 409 && /Cron changed/.test(error.message) + ); + assert.strictEqual(SSHClass.instances[0].disconnected, true); + } + + { + const SSHClass = makeSSHClass(async (command) => { + if (command === 'crontab -u app -l') { + return { code: 0, stdout: '* * * * * echo old\n', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }); + const service = createRemoteCronService({ SSHClass }); + await assert.rejects( + () => service.saveCron(createNode('root'), 'app', '* * * * * echo new\n', ''), + /Base hash is required/ + ); + assert.strictEqual(SSHClass.instances.length, 0); + } + + { + const rawContent = '* * * * * echo safe; not shell input\n'; + const SSHClass = makeSSHClass(async (command) => { + if (command === 'crontab -u app -l') { + return { code: 0, stdout: '* * * * * echo old\n', stderr: '' }; + } + if (command.startsWith('umask 077; mkdir -p /tmp/celerity-cron-app-1710000000000-')) { + assert.match(command, /^umask 077; mkdir -p \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16} && chmod 700 \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}$/); + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('umask 077 && crontab -u app -l > /tmp/celerity-cron-backup-app-')) { + assert.match(command, /^umask 077 && crontab -u app -l > \/tmp\/celerity-cron-backup-app-1710000000000-[a-f0-9]{16}\.txt 2>\/dev\/null \|\| true$/); + return { code: 0, stdout: '', stderr: '' }; + } + if (command.includes('base64 -d') && command.includes('crontab -u app')) { + assert(!command.includes(rawContent), 'raw crontab content must not be concatenated into shell command'); + assert.match(command, /^base64 -d < \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}\/crontab\.b64 > \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}\/crontab\.txt && crontab -u app \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}\/crontab\.txt; code=\$\?; rm -rf \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}; exit \$code$/); + return { code: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }); + const service = createRemoteCronService({ SSHClass, now: () => 1710000000000 }); + const result = await service.saveCron(createNode('root'), 'app', rawContent, hash('* * * * * echo old\n')); + assert.strictEqual(result.success, true); + assert.strictEqual(result.hash, hash(rawContent)); + assert.match(result.backupPath, /^\/tmp\/celerity-cron-backup-app-1710000000000-[a-f0-9]{16}\.txt$/); + assert(SSHClass.instances[0].commands.some(command => /^writeFile:\/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}\/crontab\.b64:/.test(command))); + assert(!SSHClass.instances[0].commands.some(command => /writeFile:\/tmp\/celerity-cron-app-1710000000000\.b64:/.test(command))); + assert(SSHClass.instances[0].commands.some(command => /rm -rf \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}/.test(command))); + } + + { + const SSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, "bash -lc 'echo '\"'\"'hi'\"'\"''"); + return { code: 7, stdout: 'o'.repeat(20050), stderr: 'e'.repeat(20050) }; + }); + const service = createRemoteCronService({ SSHClass }); + const result = await service.runCommandNow(createNode(), 'root', "echo 'hi'"); + assert.strictEqual(result.code, 7); + assert.strictEqual(result.stdout.length, 20000); + assert.strictEqual(result.stderr.length, 20000); + await assert.rejects(() => service.runCommandNow(createNode(), 'root', ''), /Command is required/); + await assert.rejects(() => service.runCommandNow(createNode(), 'root', 'x'.repeat(4097)), /4096/); + } + + { + const rootSSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, "sudo -n -u app bash -lc 'echo app'"); + return { code: 0, stdout: 'app\n', stderr: '' }; + }); + const rootService = createRemoteCronService({ SSHClass: rootSSHClass }); + await rootService.runCommandNow(createNode('root'), 'app', 'echo app'); + + const sameUserSSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, "bash -lc 'echo admin'"); + return { code: 0, stdout: 'admin\n', stderr: '' }; + }); + const sameUserService = createRemoteCronService({ SSHClass: sameUserSSHClass }); + await sameUserService.runCommandNow(createNode('admin'), 'admin', 'echo admin'); + + const sudoSSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, "sudo -n -u app bash -lc 'echo app'"); + return { code: 0, stdout: 'app\n', stderr: '' }; + }); + const sudoService = createRemoteCronService({ SSHClass: sudoSSHClass }); + await sudoService.runCommandNow(createNode('deployer'), 'app', 'echo app'); + await assert.rejects(() => sudoService.runCommandNow(createNode('deployer'), 'bad user', 'echo app'), /Invalid cron user/); + + const nonRootToRootSSHClass = makeSSHClass(async (command) => { + assert.strictEqual(command, "sudo -n -u root bash -lc 'echo root'"); + return { code: 0, stdout: 'root\n', stderr: '' }; + }); + const nonRootToRootService = createRemoteCronService({ SSHClass: nonRootToRootSSHClass }); + await nonRootToRootService.runCommandNow(createNode('admin'), 'root', 'echo root'); + } + + { + const SSHClass = makeSSHClass(async (command) => { + if (command === 'systemctl is-active cron') return { code: 3, stdout: 'inactive\n', stderr: '' }; + if (command === 'systemctl is-active crond') return { code: 0, stdout: 'active\n', stderr: '' }; + if (command === 'sudo -n systemctl reload cron') return { code: 1, stdout: '', stderr: 'failed' }; + if (command === 'sudo -n systemctl reload crond') return { code: 0, stdout: '', stderr: '' }; + if (command === 'sudo -n systemctl restart cron') return { code: 1, stdout: '', stderr: 'failed' }; + if (command === 'sudo -n systemctl restart crond') return { code: 0, stdout: '', stderr: '' }; + throw new Error(`unexpected command: ${command}`); + }); + const service = createRemoteCronService({ SSHClass }); + assert.deepStrictEqual(await service.getCronServiceStatus(createNode()), { + service: 'crond', + active: true, + checked: [ + { service: 'cron', active: false, code: 3, stdout: 'inactive', stderr: '' }, + { service: 'crond', active: true, code: 0, stdout: 'active', stderr: '' }, + ], + }); + assert.deepStrictEqual(await service.reloadCronService(createNode()), { + success: true, + action: 'reload', + service: 'crond', + code: 0, + stdout: '', + stderr: '', + }); + assert.deepStrictEqual(await service.restartCronService(createNode()), { + success: true, + action: 'restart', + service: 'crond', + code: 0, + stdout: '', + stderr: '', + }); + } + + console.log('remote cron service tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/locales/en.json b/src/locales/en.json index fb2a86a..dc06b2f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -270,6 +270,37 @@ "viewLogs": "View Logs", "checkStatus": "Check Status", "resetStatus": "Reset Status", + "cronTasks": "Cron Tasks", + "cronBackToNode": "Back to node", + "cronUser": "Cron user", + "cronServiceStatus": "Cron service status", + "cronServiceStatusButton": "Status", + "cronServiceReload": "Reload", + "cronServiceRestart": "Restart", + "cronModeTasks": "Tasks", + "cronModeRaw": "Raw crontab", + "cronRawCrontab": "Raw crontab", + "cronAddTask": "Add task", + "cronMinute": "Minute", + "cronHour": "Hour", + "cronDayOfMonth": "Day of month", + "cronMonth": "Month", + "cronDayOfWeek": "Day of week", + "cronCommand": "Command", + "cronRunNow": "Run now", + "cronEdit": "Edit", + "cronDelete": "Delete", + "cronSave": "Save crontab", + "cronDiffPreview": "Diff preview", + "cronConfirmSave": "Confirm save", + "cronCancel": "Cancel", + "cronBackupPath": "Backup path", + "cronConflictReload": "Crontab changed on the node. Reload crontab before saving.", + "cronOutput": "Output", + "cronRawOnly": "Raw only", + "cronLoad": "Load crontab", + "cronLoading": "Loading...", + "cronSaved": "Crontab saved", "executionLogs": "Execution logs:", "loadingLogs": "Loading logs...", "checking": "Checking...", diff --git a/src/locales/ru.json b/src/locales/ru.json index d1d98fe..5526480 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -270,6 +270,37 @@ "viewLogs": "Просмотр логов", "checkStatus": "Проверить статус", "resetStatus": "Сбросить статус", + "cronTasks": "Cron задачи", + "cronBackToNode": "Назад к ноде", + "cronUser": "Пользователь cron", + "cronServiceStatus": "Статус сервиса cron", + "cronServiceStatusButton": "Статус", + "cronServiceReload": "Перезагрузить", + "cronServiceRestart": "Перезапустить", + "cronModeTasks": "Задачи", + "cronModeRaw": "Raw crontab", + "cronRawCrontab": "Raw crontab", + "cronAddTask": "Добавить задачу", + "cronMinute": "Минута", + "cronHour": "Час", + "cronDayOfMonth": "День месяца", + "cronMonth": "Месяц", + "cronDayOfWeek": "День недели", + "cronCommand": "Команда", + "cronRunNow": "Запустить сейчас", + "cronEdit": "Редактировать", + "cronDelete": "Удалить", + "cronSave": "Сохранить crontab", + "cronDiffPreview": "Предпросмотр изменений", + "cronConfirmSave": "Подтвердить сохранение", + "cronCancel": "Отмена", + "cronBackupPath": "Путь резервной копии", + "cronConflictReload": "Crontab изменился на ноде. Перезагрузите crontab перед сохранением.", + "cronOutput": "Вывод", + "cronRawOnly": "Только raw", + "cronLoad": "Загрузить crontab", + "cronLoading": "Загрузка...", + "cronSaved": "Crontab сохранен", "executionLogs": "Логи выполнения:", "loadingLogs": "Загрузка логов...", "checking": "Проверка...", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index a52f3a3..5b32ba0 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -270,6 +270,37 @@ "viewLogs": "查看日志", "checkStatus": "检查状态", "resetStatus": "重置状态", + "cronTasks": "Cron 任务", + "cronBackToNode": "返回节点", + "cronUser": "Cron 用户", + "cronServiceStatus": "Cron 服务状态", + "cronServiceStatusButton": "状态", + "cronServiceReload": "重新加载", + "cronServiceRestart": "重启", + "cronModeTasks": "任务", + "cronModeRaw": "原始 crontab", + "cronRawCrontab": "原始 crontab", + "cronAddTask": "添加任务", + "cronMinute": "分钟", + "cronHour": "小时", + "cronDayOfMonth": "月日", + "cronMonth": "月份", + "cronDayOfWeek": "星期", + "cronCommand": "命令", + "cronRunNow": "立即运行", + "cronEdit": "编辑", + "cronDelete": "删除", + "cronSave": "保存 crontab", + "cronDiffPreview": "差异预览", + "cronConfirmSave": "确认保存", + "cronCancel": "取消", + "cronBackupPath": "备份路径", + "cronConflictReload": "节点上的 crontab 已更改。保存前请重新加载 crontab。", + "cronOutput": "输出", + "cronRawOnly": "仅原始内容", + "cronLoad": "加载 crontab", + "cronLoading": "正在加载...", + "cronSaved": "Crontab 已保存", "executionLogs": "执行日志:", "loadingLogs": "正在加载日志...", "checking": "正在检查...", diff --git a/src/routes/panel/index.js b/src/routes/panel/index.js index f0e0e17..4b0ffe3 100644 --- a/src/routes/panel/index.js +++ b/src/routes/panel/index.js @@ -10,6 +10,7 @@ const { checkIpWhitelist, requireAuth, requireOnboarding } = require('./helpers' const authRoutes = require('./auth'); const wizardRoutes = require('./wizard'); +const nodeCronRoutes = require('./nodeCron'); const nodesRoutes = require('./nodes'); const usersRoutes = require('./users'); const settingsRoutes = require('./settings'); @@ -26,6 +27,7 @@ router.use('/', authRoutes); router.use('/', requireAuth, wizardRoutes); // All other routes require authentication and completed onboarding +router.use('/', requireAuth, requireOnboarding, nodeCronRoutes); router.use('/', requireAuth, requireOnboarding, nodesRoutes); router.use('/', requireAuth, requireOnboarding, usersRoutes); router.use('/', requireAuth, requireOnboarding, settingsRoutes); diff --git a/src/routes/panel/nodeCron.js b/src/routes/panel/nodeCron.js new file mode 100644 index 0000000..bbbd8df --- /dev/null +++ b/src/routes/panel/nodeCron.js @@ -0,0 +1,229 @@ +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const router = express.Router(); + +const HyNode = require('../../models/hyNodeModel'); +const remoteCronService = require('../../services/remoteCronService'); +const logger = require('../../utils/logger'); +const { render } = require('./helpers'); + +const writeLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 20, + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => { + res.status(429).json({ success: false, error: 'Too many cron requests. Try again in a minute.' }); + }, +}); + +function hasSshCredentials(node) { + return !!(node?.ssh?.password || node?.ssh?.privateKey); +} + +function defaultCronUser(node) { + return node?.ssh?.username || 'root'; +} + +function errorMessage(error) { + return error?.message || 'Internal Server Error'; +} + +function validationError(message) { + const error = new Error(message); + error.statusCode = 400; + error.expose = true; + return error; +} + +function conflictError(message) { + const error = new Error(message); + error.statusCode = 409; + error.expose = true; + return error; +} + +function safeJsonErrorMessage(error) { + if (error?.expose) { + return errorMessage(error); + } + return 'Remote cron operation failed'; +} + +function errorStatus(error, fallback = 500) { + const status = Number(error?.statusCode); + if (Number.isInteger(status) && status >= 400 && status <= 599) { + return status; + } + return fallback; +} + +function validateCronUser(user) { + try { + remoteCronService.validateCronUser(user); + } catch (error) { + throw validationError(errorMessage(error)); + } +} + +function validateCronContent(content) { + try { + remoteCronService.validateCronContent(content); + } catch (error) { + throw validationError(errorMessage(error)); + } +} + +function validateBaseHash(baseHash) { + if (typeof baseHash !== 'string' || baseHash.trim() === '') { + throw validationError('Base hash is required'); + } +} + +function validateRunCommand(command) { + if (typeof command !== 'string' || command.trim() === '') { + throw validationError('Command is required'); + } +} + +async function loadNodeForJson(req, res, { validateUser = false } = {}) { + const node = await HyNode.findById(req.params.id); + if (!node) { + res.status(404).json({ success: false, error: 'Node not found' }); + return null; + } + if (node.type === 'virtual') { + res.status(400).json({ success: false, error: 'Virtual nodes do not support remote cron management' }); + return null; + } + if (!hasSshCredentials(node)) { + res.status(400).json({ success: false, error: 'SSH credentials are required for remote cron management' }); + return null; + } + if (validateUser) { + validateCronUser(req.query.user || req.body.user || defaultCronUser(node)); + } + return node; +} + +function sendJsonError(res, error, fallbackStatus = 500) { + return res.status(errorStatus(error, fallbackStatus)).json({ + success: false, + error: safeJsonErrorMessage(error), + }); +} + +function logCronFailure(level, action, req, error) { + logger[level]('[PanelCron] Remote cron action failed', { + action, + nodeId: req.params.id, + statusCode: errorStatus(error), + error: errorMessage(error), + }); +} + +router.get('/nodes/:id/cron', async (req, res) => { + try { + const node = await HyNode.findById(req.params.id); + if (!node) { + return res.redirect('/panel/nodes'); + } + if (node.type === 'virtual') { + return res.status(400).send('Virtual nodes do not support remote cron management'); + } + if (!hasSshCredentials(node)) { + return res.status(400).send('SSH credentials are required for remote cron management'); + } + + return render(res, 'node-cron', { + title: `Cron: ${node.name || node.ip || node._id}`, + page: 'nodes', + node, + defaultCronUser: defaultCronUser(node), + error: null, + }); + } catch (error) { + return res.status(500).send('Error: ' + errorMessage(error)); + } +}); + +router.get('/nodes/:id/cron/data', async (req, res) => { + try { + const node = await loadNodeForJson(req, res, { validateUser: true }); + if (!node) return null; + + const user = req.query.user || defaultCronUser(node); + const [cron, service] = await Promise.all([ + remoteCronService.getCron(node, user), + remoteCronService.getCronServiceStatus(node), + ]); + return res.json({ success: true, cron, service }); + } catch (error) { + return sendJsonError(res, error, errorMessage(error) === 'Invalid cron user' ? 400 : 500); + } +}); + +router.post('/nodes/:id/cron/save', writeLimiter, async (req, res) => { + try { + const node = await loadNodeForJson(req, res, { validateUser: true }); + if (!node) return null; + validateCronContent(req.body.content); + validateBaseHash(req.body.baseHash); + + const result = await remoteCronService.saveCron( + node, + req.body.user || defaultCronUser(node), + req.body.content, + req.body.baseHash + ); + return res.json(result); + } catch (error) { + if (errorStatus(error) === 409) { + error = conflictError('Cron changed since it was loaded'); + } + logCronFailure(errorStatus(error) >= 500 ? 'error' : 'warn', 'save', req, error); + return sendJsonError(res, error); + } +}); + +router.post('/nodes/:id/cron/run', writeLimiter, async (req, res) => { + try { + const node = await loadNodeForJson(req, res, { validateUser: true }); + if (!node) return null; + validateRunCommand(req.body.command); + + const result = await remoteCronService.runCommandNow( + node, + req.body.user || defaultCronUser(node), + req.body.command + ); + return res.json(result); + } catch (error) { + logCronFailure(errorStatus(error) >= 500 ? 'error' : 'warn', 'run', req, error); + return sendJsonError(res, error); + } +}); + +router.post('/nodes/:id/cron/service', writeLimiter, async (req, res) => { + try { + const node = await loadNodeForJson(req, res); + if (!node) return null; + + const action = req.body.action; + if (!['status', 'reload', 'restart'].includes(action)) { + return res.status(400).json({ success: false, error: 'Invalid service action' }); + } + + const result = action === 'status' + ? await remoteCronService.getCronServiceStatus(node) + : action === 'reload' + ? await remoteCronService.reloadCronService(node) + : await remoteCronService.restartCronService(node); + return res.json(result); + } catch (error) { + logCronFailure(errorStatus(error) >= 500 ? 'error' : 'warn', 'service', req, error); + return sendJsonError(res, error); + } +}); + +module.exports = router; diff --git a/src/services/remoteCronService.js b/src/services/remoteCronService.js new file mode 100644 index 0000000..e70cc5b --- /dev/null +++ b/src/services/remoteCronService.js @@ -0,0 +1,313 @@ +const crypto = require('crypto'); + +const MAX_CRON_BYTES = 64 * 1024; +const MAX_COMMAND_CHARS = 4096; +const MAX_OUTPUT_CHARS = 20000; +const CRON_USER_RE = /^[a-z_][a-z0-9_-]{0,31}$/i; +const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*$/; +const CRON_MACROS = new Set([ + '@reboot', + '@yearly', + '@annually', + '@monthly', + '@weekly', + '@daily', + '@midnight', + '@hourly', +]); + +function normalizeContent(content) { + return String(content || '').replace(/\r\n?/g, '\n'); +} + +function hashContent(content) { + return crypto.createHash('sha256').update(normalizeContent(content)).digest('hex'); +} + +function validateCronUser(user) { + if (typeof user !== 'string' || !CRON_USER_RE.test(user)) { + throw new Error('Invalid cron user'); + } + return true; +} + +function validateCronContent(content) { + if (typeof content !== 'string') { + throw new Error('Cron content must be a string'); + } + if (Buffer.byteLength(content, 'utf8') > MAX_CRON_BYTES) { + throw new Error('Cron content must be at most 64 KiB'); + } + if (content.includes('\0')) { + throw new Error('Cron content must not contain NUL bytes'); + } + + const normalized = normalizeContent(content); + const lines = normalized.split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) { + continue; + } + if (ENV_ASSIGNMENT_RE.test(line)) { + continue; + } + + const fields = line.split(/\s+/); + if (fields[0].startsWith('@')) { + if (CRON_MACROS.has(fields[0]) && fields.length >= 2) { + continue; + } + throw new Error(`Invalid cron line ${i + 1}`); + } + if (fields.length < 6) { + throw new Error(`Invalid cron line ${i + 1}`); + } + } + return true; +} + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\"'\"'`)}'`; +} + +function truncateOutput(value) { + return String(value || '').slice(0, MAX_OUTPUT_CHARS); +} + +function getSshUsername(node) { + return node?.ssh?.username || 'root'; +} + +function crontabReadCommand(node, user) { + if (user === getSshUsername(node)) { + return 'crontab -l'; + } + if (getSshUsername(node) === 'root') { + return `crontab -u ${user} -l`; + } + return `sudo -n crontab -u ${user} -l`; +} + +function crontabInstallCommand(node, user, tempPath) { + if (user === getSshUsername(node)) { + return `crontab ${tempPath}`; + } + if (getSshUsername(node) === 'root') { + return `crontab -u ${user} ${tempPath}`; + } + return `sudo -n crontab -u ${user} ${tempPath}`; +} + +function isMissingCrontab(result) { + const text = `${result?.stdout || ''}\n${result?.stderr || ''}`.toLowerCase(); + return text.includes('no crontab') || text.includes('no crontab for'); +} + +function createConflictError() { + const error = new Error('Cron changed since it was loaded'); + error.statusCode = 409; + return error; +} + +function createValidationError(message) { + const error = new Error(message); + error.statusCode = 400; + return error; +} + +function createRemoteCronService({ SSHClass, now = () => Date.now() } = {}) { + const ResolvedSSHClass = SSHClass || require('./nodeSSH'); + + async function withSSH(node, callback) { + const ssh = new ResolvedSSHClass(node); + await ssh.connect(); + try { + return await callback(ssh); + } finally { + ssh.disconnect(); + } + } + + async function readCronWithSSH(ssh, node, user) { + const result = await ssh.exec(crontabReadCommand(node, user)); + if (result.code !== 0) { + if (isMissingCrontab(result)) { + return ''; + } + const error = new Error(result.stderr || result.stdout || 'Failed to read crontab'); + error.statusCode = 502; + throw error; + } + return normalizeContent(result.stdout || ''); + } + + async function getCron(node, user) { + validateCronUser(user); + return withSSH(node, async (ssh) => { + const content = await readCronWithSSH(ssh, node, user); + return { + content, + hash: hashContent(content), + user, + }; + }); + } + + async function saveCron(node, user, content, baseHash) { + validateCronUser(user); + validateCronContent(content); + if (typeof baseHash !== 'string' || baseHash.trim() === '') { + throw createValidationError('Base hash is required'); + } + const normalized = normalizeContent(content); + + return withSSH(node, async (ssh) => { + const currentContent = await readCronWithSSH(ssh, node, user); + if (hashContent(currentContent) !== baseHash) { + throw createConflictError(); + } + + const timestamp = now(); + const randomSuffix = crypto.randomBytes(8).toString('hex'); + const backupPath = `/tmp/celerity-cron-backup-${user}-${timestamp}-${crypto.randomBytes(8).toString('hex')}.txt`; + const tempDir = `/tmp/celerity-cron-${user}-${timestamp}-${randomSuffix}`; + const tempBase64Path = `${tempDir}/crontab.b64`; + const tempCronPath = `${tempDir}/crontab.txt`; + const tempDirResult = await ssh.exec(`umask 077; mkdir -p ${tempDir} && chmod 700 ${tempDir}`); + if (tempDirResult.code !== 0) { + const error = new Error(tempDirResult.stderr || tempDirResult.stdout || 'Failed to prepare crontab temp directory'); + error.statusCode = 502; + throw error; + } + await ssh.exec(`umask 077 && ${crontabReadCommand(node, user)} > ${backupPath} 2>/dev/null || true`); + await ssh.writeFile(tempBase64Path, Buffer.from(normalized, 'utf8').toString('base64')); + + const installCommand = crontabInstallCommand(node, user, tempCronPath); + const result = await ssh.exec(`base64 -d < ${tempBase64Path} > ${tempCronPath} && ${installCommand}; code=$?; rm -rf ${tempDir}; exit $code`); + if (result.code !== 0) { + const error = new Error(result.stderr || result.stdout || 'Failed to install crontab'); + error.statusCode = 502; + throw error; + } + + return { + success: true, + hash: hashContent(normalized), + backupPath, + }; + }); + } + + async function runCommandNow(node, user, command) { + validateCronUser(user); + if (typeof command !== 'string' || command.trim() === '') { + throw new Error('Command is required'); + } + if (command.length > MAX_COMMAND_CHARS) { + throw new Error('Command must be at most 4096 characters'); + } + + return withSSH(node, async (ssh) => { + const sshUsername = getSshUsername(node); + const runCommand = user === sshUsername + ? `bash -lc ${shellQuote(command)}` + : `sudo -n -u ${user} bash -lc ${shellQuote(command)}`; + const result = await ssh.exec(runCommand); + return { + code: result.code, + stdout: truncateOutput(result.stdout), + stderr: truncateOutput(result.stderr), + }; + }); + } + + async function getCronServiceStatus(node) { + return withSSH(node, async (ssh) => { + const checked = []; + for (const service of ['cron', 'crond']) { + const result = await ssh.exec(`systemctl is-active ${service}`); + const entry = { + service, + active: result.code === 0 && String(result.stdout || '').trim() === 'active', + code: result.code, + stdout: String(result.stdout || '').trim(), + stderr: String(result.stderr || '').trim(), + }; + checked.push(entry); + if (entry.active) { + return { + service, + active: true, + checked, + }; + } + } + return { + service: null, + active: false, + checked, + }; + }); + } + + async function runServiceAction(node, action) { + return withSSH(node, async (ssh) => { + let lastResult = null; + for (const service of ['cron', 'crond']) { + const result = await ssh.exec(`sudo -n systemctl ${action} ${service}`); + lastResult = { service, result }; + if (result.code === 0) { + return { + success: true, + action, + service, + code: result.code, + stdout: String(result.stdout || '').trim(), + stderr: String(result.stderr || '').trim(), + }; + } + } + return { + success: false, + action, + service: lastResult?.service || null, + code: lastResult?.result?.code ?? 1, + stdout: String(lastResult?.result?.stdout || '').trim(), + stderr: String(lastResult?.result?.stderr || '').trim(), + }; + }); + } + + return { + validateCronUser, + validateCronContent, + getCron, + saveCron, + runCommandNow, + getCronServiceStatus, + reloadCronService: (node) => runServiceAction(node, 'reload'), + restartCronService: (node) => runServiceAction(node, 'restart'), + }; +} + +let defaultService; + +function getDefaultService() { + if (!defaultService) { + defaultService = createRemoteCronService(); + } + return defaultService; +} + +module.exports = { + createRemoteCronService, + validateCronUser, + validateCronContent, + getCron: (...args) => getDefaultService().getCron(...args), + saveCron: (...args) => getDefaultService().saveCron(...args), + runCommandNow: (...args) => getDefaultService().runCommandNow(...args), + getCronServiceStatus: (...args) => getDefaultService().getCronServiceStatus(...args), + reloadCronService: (...args) => getDefaultService().reloadCronService(...args), + restartCronService: (...args) => getDefaultService().restartCronService(...args), +}; diff --git a/views/node-cron.ejs b/views/node-cron.ejs new file mode 100644 index 0000000..2d86972 --- /dev/null +++ b/views/node-cron.ejs @@ -0,0 +1,466 @@ + + +<% if (error) { %> +
<%= error %>
+<% } %> + + + +
+
+
+
+

<%= t('nodes.cronTasks') %>

+
<%= node.name %><% if (node.ip) { %> · <%= node.ip %><% } %>
+
+
+
+ + +
+ +
+
+
+ +
+
+
+
+ <%= t('nodes.cronServiceStatus') %> +
+
+
+ + + +
+
+
+
+ +
+
+
+ + +
+ +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+ +
+ + + + + + + + + + + + + +
<%= t('nodes.cronMinute') %><%= t('nodes.cronHour') %><%= t('nodes.cronDayOfMonth') %><%= t('nodes.cronMonth') %><%= t('nodes.cronDayOfWeek') %><%= t('nodes.cronCommand') %><%= t('common.actions') %>
+
+
+
+ + +
+
+
+ +
+
+

<%= t('nodes.cronDiffPreview') %>

+
+
+

+            
+ + +
+
+
+ +
+
+

<%= t('nodes.cronOutput') %>

+
+
+
+

+        
+
+
+ + diff --git a/views/partials/node-form/management.ejs b/views/partials/node-form/management.ejs index 0c00b9b..0f5e9f8 100644 --- a/views/partials/node-form/management.ejs +++ b/views/partials/node-form/management.ejs @@ -165,7 +165,7 @@ <% } %> <%# ========== Management (edit only — hidden for virtual: nothing to setup/restart/terminal-into) ========== %> -<% if (node && node.type !== 'virtual') { %> +<% if (node?._id && node.type !== 'virtual') { %>

<%= t('nodes.management') %>

@@ -214,6 +214,10 @@ <%= t('nodes.terminal') %> + + + <%= t('nodes.cronTasks') %> +
-
@@ -269,7 +268,7 @@ makeCell(row, task.command, 'cron-command-cell'); const actions = document.createElement('td'); [ - { label: labels.runNow, action: () => runCommand(task.command), className: 'btn btn-sm btn-success' }, + { label: labels.runNow, action: () => runCommand(task.command).catch(error => appendOutput(error.message)), className: 'btn btn-sm btn-success' }, { label: labels.edit, action: () => editTask(task), className: 'btn btn-sm' }, { label: labels.delete, action: () => deleteTask(index), className: 'btn btn-sm btn-danger' }, ].forEach(item => { From edb4aa0d45500127879a7b7ffe9cdb5c68d56710 Mon Sep 17 00:00:00 2001 From: smileoniks-ctrl Date: Fri, 3 Jul 2026 16:52:47 +0300 Subject: [PATCH 4/5] feat(ui): add beta badge to node cron action --- public/css/style.css | 15 +++++++++++++++ public/css/style.min.css | 2 +- scripts/test-node-cron-ui.js | 5 +++++ views/partials/node-form/management.ejs | 1 + 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/public/css/style.css b/public/css/style.css index 076d7cb..c8f8475 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -2494,6 +2494,21 @@ html { font-size: 16px; } +.beta-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 7px; + border-radius: 999px; + background: rgba(245, 158, 11, 0.13); + border: 1px solid rgba(245, 158, 11, 0.35); + color: #fbbf24; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.5px; + vertical-align: middle; +} + /* Mobile Header (hidden on desktop) */ .mobile-header { display: none; diff --git a/public/css/style.min.css b/public/css/style.min.css index 686a22c..fa7db85 100644 --- a/public/css/style.min.css +++ b/public/css/style.min.css @@ -1 +1 @@ -:root{--bg-primary: #0a0a0b;--bg-secondary: #111113;--bg-tertiary: #18181b;--bg-elevated: #1f1f23;--bg-hover: #27272a;--text-primary: #fafafa;--text-secondary: #a1a1aa;--text-muted: #71717a;--accent: #6366f1;--accent-hover: #818cf8;--accent-glow: rgba(99, 102, 241, .3);--accent-gradient-end: #7c3aed;--accent-hover-gradient-end: #8b5cf6;--success: #22c55e;--success-hover: #16a34a;--success-bg: rgba(34, 197, 94, .15);--warning: #eab308;--warning-hover: #ca8a04;--warning-bg: rgba(234, 179, 8, .15);--danger: #ef4444;--danger-hover: #dc2626;--danger-bg: rgba(239, 68, 68, .15);--info: #3b82f6;--info-bg: rgba(59, 130, 246, .15);--border: #27272a;--border-light: #3f3f46;--primary: var(--accent);--text: var(--text-primary);--muted: var(--text-muted);--card-bg: var(--bg-secondary);--input-bg: var(--bg-primary);--border-color: var(--border);--radius: 16px;--radius-sm: 10px;--radius-xs: 6px;--shadow: 0 4px 24px rgba(0, 0, 0, .4);--shadow-lg: 0 8px 40px rgba(0, 0, 0, .5);--transition: .15s ease}*{box-sizing:border-box;margin:0;padding:0}body{font-family:Inter,-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;background:var(--bg-primary);color:var(--text-primary);line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overflow-x:hidden}html{scroll-behavior:smooth}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--border-light);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}.app{display:flex;min-height:100vh}.sidebar{width:260px;background:var(--bg-secondary);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;height:100vh;z-index:100}.logo{padding:24px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--border)}.logo-icon{font-size:28px;filter:drop-shadow(0 0 12px var(--accent-glow));animation:pulse 3s infinite}.logo-content{display:flex;flex-direction:column;gap:2px}.logo-text{font-size:18px;font-weight:700;letter-spacing:1px;background:linear-gradient(135deg,var(--accent) 0%,#a855f7 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.logo-sub{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text-secondary);opacity:.8;font-weight:400}.sidebar-version{display:block;margin-top:10px;text-align:center;font-family:JetBrains Mono,monospace;font-size:10px;letter-spacing:.5px;color:var(--text-secondary);opacity:.45;text-decoration:none;transition:opacity .15s ease}.sidebar-version:hover{opacity:.85}.login-footer{position:fixed;left:0;right:0;bottom:16px;text-align:center;font-family:JetBrains Mono,monospace;font-size:10px;letter-spacing:.5px;color:var(--text-secondary);opacity:.4;text-decoration:none;transition:opacity .15s ease}.login-footer:hover{opacity:.8}.nav-menu{list-style:none;padding:16px 12px;flex:1}.nav-menu li{margin-bottom:4px}.nav-menu a{display:flex;align-items:center;gap:12px;padding:12px 16px;color:#c4c4cc;text-decoration:none;border-radius:var(--radius-sm);transition:all .2s cubic-bezier(.4,0,.2,1);font-weight:500;position:relative}.nav-menu a:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:0;background:var(--accent);border-radius:0 2px 2px 0;transition:height .3s ease}.nav-menu a:hover{background:var(--bg-hover);color:var(--text-primary)}.nav-menu a:hover:before{height:60%}.nav-menu a.active{background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);color:#fff;box-shadow:0 4px 12px var(--accent-glow)}.nav-menu a.active:before{height:100%;background:#ffffff4d}.nav-icon{font-size:18px;width:24px;text-align:center;color:#d4d4dc}.sidebar-footer{padding:16px;border-top:1px solid var(--border)}.logout-btn{display:block;width:100%;padding:12px;text-align:center;color:var(--text-muted);text-decoration:none;border:1px solid var(--border);border-radius:var(--radius-sm);transition:all var(--transition);font-weight:500}.logout-btn:hover{background:var(--danger);border-color:var(--danger);color:#fff}.github-link{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;text-align:center;color:var(--text-muted);text-decoration:none;border:1px solid var(--border);border-radius:var(--radius-sm);transition:all var(--transition);font-weight:500;font-size:13px;margin-bottom:8px}.github-link:hover{background:#ffffff0d;border-color:var(--accent);color:var(--text-primary)}.github-link svg{flex-shrink:0}.content{flex:1;margin-left:260px;min-height:100vh;min-width:0;background:var(--bg-primary)}.topbar{display:flex;justify-content:space-between;align-items:center;padding:20px 32px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:50;backdrop-filter:blur(12px)}.topbar h1{font-size:20px;font-weight:600}.topbar-right{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--text-secondary)}.status-dot{width:10px;height:10px;border-radius:50%;background:var(--text-muted)}.status-dot.online{background:var(--success);box-shadow:0 0 12px var(--success);animation:pulse 2s infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.6}}.main-content{padding:32px;min-width:0;position:relative}.main-content:before{content:"";position:absolute;inset:-120px -120px auto;height:380px;background:radial-gradient(circle at 18% 22%,rgba(99,102,241,.22),transparent 38%),radial-gradient(circle at 82% 14%,rgba(168,85,247,.18),transparent 40%),radial-gradient(circle at 50% 80%,rgba(56,189,248,.1),transparent 36%);filter:blur(70px);opacity:.55;pointer-events:none;z-index:0}.main-content>*:not(.toast):not(.sticky-header){position:relative;z-index:1}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;margin-bottom:32px}.stat-card{background:linear-gradient(135deg,var(--bg-secondary) 0%,var(--bg-tertiary) 100%);border:1px solid var(--border);border-radius:var(--radius);padding:24px;position:relative;overflow:hidden;transition:all .3s cubic-bezier(.4,0,.2,1)}.stat-card:before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent) 0%,#a855f7 100%);opacity:0;transition:opacity .3s ease}.stat-card:hover{border-color:var(--accent);transform:translateY(-4px);box-shadow:0 8px 24px #6366f126}.stat-card:hover:before{opacity:1}.stat-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.stat-icon{font-size:24px}.stat-trend{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);background:var(--bg-tertiary);padding:4px 8px;border-radius:20px}.stat-value{font-size:36px;font-weight:700;background:linear-gradient(135deg,var(--text-primary) 0%,var(--text-secondary) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;line-height:1.2}.stat-label{font-size:13px;color:var(--text-muted);margin-top:4px}.card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:0 2px 8px #0000001a;transition:all .3s ease}.card:hover{box-shadow:0 4px 16px #00000026}.card-header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--bg-tertiary) 0%,transparent 100%)}.card-header h2{font-size:16px;font-weight:600;display:flex;align-items:center;gap:8px}.card-header h2 i{font-size:18px;opacity:.9}.card-body{padding:24px}.card-body.no-padding{padding:0}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:16px 20px;text-align:left;border-bottom:1px solid var(--border)}.table th{background:var(--bg-tertiary);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted)}.table th.sortable{user-select:none;transition:all .2s ease}.table th.sortable:hover{background:var(--bg-hover);color:var(--text-primary)}.table tbody tr{transition:all .2s ease}.table tbody tr:hover{background:var(--bg-tertiary);transform:scale(1.005)}.table code{padding:4px 8px;border-radius:var(--radius-xs);font-size:12px;font-family:JetBrains Mono,monospace}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 20px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-tertiary);color:var(--text-primary);font-family:inherit;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;transition:all .2s cubic-bezier(.4,0,.2,1);position:relative;overflow:hidden}.btn:before{content:"";position:absolute;top:50%;left:50%;width:0;height:0;border-radius:50%;background:#ffffff1a;transform:translate(-50%,-50%);transition:width .6s,height .6s}.btn:active:before{width:300px;height:300px}.btn:hover{background:var(--bg-hover);border-color:var(--border-light);transform:translateY(-1px)}.btn-primary{background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);border:none;color:#fff;box-shadow:0 2px 8px #6366f133}.btn-primary:hover{background:linear-gradient(135deg,#818cf8 0%,var(--accent-hover-gradient-end) 100%);box-shadow:0 6px 20px var(--accent-glow);transform:translateY(-2px)}.btn-primary:active{transform:translateY(0)}.btn-success{background:var(--success);border-color:var(--success);color:#fff}.btn-success:hover{background:var(--success-hover);border-color:var(--success-hover);box-shadow:0 6px 20px #22c55e4d;transform:translateY(-2px)}.btn-warning{background:var(--warning);border-color:var(--warning);color:#000}.btn-warning:hover{background:var(--warning-hover);border-color:var(--warning-hover);box-shadow:0 6px 20px #eab3084d;transform:translateY(-2px)}.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}.btn-danger:hover{background:var(--danger-hover);border-color:var(--danger-hover);box-shadow:0 6px 20px #ef44444d;transform:translateY(-2px)}.btn-secondary{background:var(--bg-tertiary);border-color:var(--border);color:var(--text-primary)}.btn-secondary:hover{background:var(--bg-hover);border-color:var(--accent);box-shadow:0 4px 12px #6366f126;transform:translateY(-2px)}.btn-sm{padding:8px 14px;font-size:13px}.btn-block{width:100%}.btn-icon{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;min-width:36px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:16px;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);text-decoration:none;position:relative;overflow:hidden}.btn-icon:after{content:"";position:absolute;inset:0;background:radial-gradient(circle,rgba(99,102,241,.2) 0%,transparent 70%);opacity:0;transition:opacity .3s ease}.btn-icon:hover{background:var(--bg-hover);color:var(--text-primary);border-color:var(--accent);transform:scale(1.1)}.btn-icon:hover:after{opacity:1}.btn-icon:active{transform:scale(.95)}.toggle-switch{position:relative;display:inline-block;width:44px;height:24px;cursor:pointer}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;inset:0;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:24px;transition:all var(--transition)}.toggle-slider:before{content:"";position:absolute;width:18px;height:18px;left:2px;top:2px;background:var(--text-muted);border-radius:50%;transition:all var(--transition)}.toggle-switch input:checked+.toggle-slider{background:linear-gradient(135deg,var(--success) 0%,var(--success-hover) 100%);border-color:var(--success);box-shadow:0 2px 8px #22c55e4d}.toggle-switch input:checked+.toggle-slider:before{transform:translate(20px);background:#fff;box-shadow:0 2px 4px #0003}.toggle-switch:hover .toggle-slider{border-color:var(--border-light)}.toggle-switch:hover input:checked+.toggle-slider{box-shadow:0 4px 12px #22c55e66}.page-header{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:24px}.sticky-header{position:sticky;top:70px;z-index:40;background:transparent;padding:8px 0;margin:0 0 22px;border-bottom:none}.page-header-center{flex:1;display:flex;justify-content:center;min-width:0}.filters-bar{display:inline-flex;flex-direction:row;flex-wrap:nowrap;align-items:center;gap:4px;padding:6px;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);max-width:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.filters-bar::-webkit-scrollbar{display:none}.filters-bar>*{flex-shrink:0}.filters-bar .search-box{position:relative;display:inline-flex;align-items:center}.filters-bar .search-box input{width:260px;padding:9px 36px;background:transparent;border:none;border-radius:999px;color:var(--text-primary);font-size:13.5px;font-weight:500;box-shadow:none;transition:background .2s ease,box-shadow .2s ease}.filters-bar .search-box input:hover{background:#ffffff0d}.filters-bar .search-box input:focus{outline:none;background:#ffffff12;box-shadow:0 0 0 2px #6366f166}.filters-bar .search-icon{left:12px;color:var(--text-muted)}.filters-bar .search-hint{right:8px;background:#ffffff0f;border:1px solid rgba(255,255,255,.08);color:var(--text-muted)}.filters-bar .filter-select{padding:9px 32px 9px 16px;background-color:transparent;border:none;border-radius:999px;color:var(--text-secondary);font-family:inherit;font-size:13.5px;font-weight:500;white-space:nowrap;cursor:pointer;appearance:none;-webkit-appearance:none;-moz-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;transition:color .2s ease,background-color .2s ease}.filters-bar .filter-select:hover{color:var(--text-primary);background-color:#ffffff0d}.filters-bar .filter-select:focus{outline:none;color:var(--text-primary);background-color:#ffffff14}.filters-bar .filter-select option{background:#1f1f23;color:var(--text-primary)}.filters-bar .filter-count{color:var(--text-muted);font-size:12.5px;padding:0 12px 0 8px;white-space:nowrap}.filters-bar .btn{background:transparent;border:none;border-radius:999px;padding:9px 16px;font-size:13.5px;font-weight:500;box-shadow:none;color:var(--text-secondary);transition:color .2s ease,background-color .2s ease}.filters-bar .btn:hover{background:#ffffff0d;color:var(--text-primary);transform:none}.page-header-right .btn{border-radius:999px;padding:9px 18px}.search-box{position:relative;display:flex;align-items:center}.search-icon{position:absolute;left:14px;color:var(--text-muted);pointer-events:none}.search-box input{width:280px;padding:10px 14px 10px 40px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:14px;transition:all .2s ease}.search-box input:hover{border-color:var(--border-light)}.search-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-glow);background:var(--bg-tertiary)}.search-box input::placeholder{color:var(--text-muted)}.search-hint{position:absolute;right:10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-size:11px;color:var(--text-muted);font-family:inherit}.filter-select{padding:10px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:14px;cursor:pointer;transition:all var(--transition)}.filter-select:focus{outline:none;border-color:var(--accent)}.results-count{display:inline-flex;align-items:center;padding:9px 18px;color:var(--text-secondary);font-size:13.5px;font-weight:500;white-space:nowrap;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur)}.users-table-card{overflow:visible}.table-wrapper{overflow-x:auto}.user-row{transition:all var(--transition)}.user-row.is-selected{background:var(--accent-glow)}.user-select-cell{width:42px;min-width:42px;text-align:center}.user-select-checkbox{width:16px;height:16px;accent-color:var(--accent);cursor:pointer}.bulk-delete-btn{margin-left:8px;white-space:nowrap;border:none;border-radius:999px;padding:9px 18px;background:linear-gradient(135deg,var(--danger) 0%,var(--danger-hover) 100%);color:#fff;box-shadow:0 2px 8px #ef444433}.bulk-delete-btn:hover{background:linear-gradient(135deg,#f87171 0%,var(--danger) 100%);box-shadow:0 6px 20px #ef444459;transform:translateY(-2px)}.bulk-delete-btn:active{transform:translateY(0)}.bulk-delete-btn[hidden]{display:none!important}.user-info{display:flex;flex-direction:column;gap:4px}.user-id{font-weight:600;color:var(--text-primary)}.user-name{font-size:12px;color:var(--text-muted)}.plan-badge{display:inline-block;padding:4px 12px;border-radius:20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}.plan-standart{background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border)}.plan-premium{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}.traffic-info{display:flex;align-items:center;gap:8px;font-size:13px}.traffic-bar{width:60px;height:4px;background:var(--bg-tertiary);border-radius:2px;overflow:hidden}.traffic-fill{height:100%;background:var(--accent);border-radius:2px;transition:width .3s ease}.traffic-limit{color:var(--text-muted);font-size:12px}.traffic-unlimited{color:var(--success);font-weight:600}.action-buttons{display:flex;gap:8px}.dashboard-grid{display:grid;grid-template-columns:1fr 320px;gap:24px}.dashboard-main{display:flex;flex-direction:column;gap:24px}.dashboard-sidebar{display:flex;flex-direction:column;gap:16px}.node-cell{display:flex;flex-direction:column;gap:4px}.node-name-row{display:inline-flex;align-items:center;gap:4px}.node-name-row strong{display:inline}.node-ip{font-size:12px;color:var(--text-muted)}.status-indicator{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500}.status-dot{width:8px;height:8px;border-radius:50%}.status-online .status-dot{background:var(--success);box-shadow:0 0 12px var(--success),0 0 20px #22c55e4d}.status-online{color:var(--success);font-weight:500}.status-offline .status-dot{background:var(--text-muted)}.status-offline{color:var(--text-muted)}.status-error .status-dot{background:var(--danger);box-shadow:0 0 12px var(--danger)}.status-error{color:var(--danger);font-weight:500}.status-syncing .status-dot{background:var(--warning);animation:pulse 1s infinite;box-shadow:0 0 12px var(--warning)}.status-syncing{color:var(--warning);font-weight:500}.node-actions{display:flex;gap:8px}.quick-actions{display:flex;flex-direction:column;gap:8px}.quick-action{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);text-decoration:none;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);font-family:inherit;width:100%;text-align:left;position:relative;overflow:hidden}.quick-action:before{content:"";position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(99,102,241,.1),transparent);transition:left .5s ease}.quick-action:hover:before{left:100%}.quick-action:hover{background:var(--bg-hover);border-color:var(--accent);transform:translate(4px)}.quick-action:active{transform:translate(2px) scale(.98)}.qa-icon{font-size:18px;width:24px;text-align:center}.system-info{display:flex;flex-direction:column;gap:12px}.sys-item{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)}.sys-item:last-child{border-bottom:none}.sys-label{color:var(--text-muted);font-size:13px}.sys-value{font-weight:500;font-size:13px}.logs-container{max-height:300px;overflow-y:auto;overflow-x:hidden;font-family:JetBrains Mono,monospace;font-size:12px;background:var(--bg-primary)}.log-line{padding:8px 16px;border-bottom:1px solid var(--border);color:var(--text-secondary);white-space:pre-wrap;word-break:break-all;overflow-wrap:anywhere}.log-line:hover{background:var(--bg-tertiary)}.log-error{color:var(--danger)}.log-warn{color:var(--warning)}.log-success{color:var(--success)}.logs-empty,.logs-loading,.logs-error{padding:40px;text-align:center;color:var(--text-muted)}.logs-search-wrap{display:flex;align-items:center;gap:6px}.logs-search-input{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;padding:7px 12px;width:180px;outline:none;transition:all .2s ease}.logs-search-input:hover{border-color:var(--border-light)}.logs-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);background:var(--bg-tertiary);width:220px}.logs-search-input::placeholder{color:var(--text-muted)}.logs-search-count{font-size:11px;color:var(--text-muted);white-space:nowrap;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;padding:3px 8px;min-width:20px;text-align:center}.logs-search-count:empty{display:none}.logs-search-clear{display:none;align-items:center;justify-content:center;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px;padding:4px;border-radius:4px;transition:all .15s ease;margin-left:-4px}.logs-search-clear:hover{background:var(--bg-tertiary);color:var(--text-primary)}.pagination{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:24px}.page-btn{display:inline-flex;align-items:center;justify-content:center;min-width:40px;height:40px;padding:0 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);text-decoration:none;font-weight:500;transition:all .2s cubic-bezier(.4,0,.2,1)}.page-btn:hover{background:var(--bg-hover);border-color:var(--accent);transform:translateY(-2px)}.page-btn.active{background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);border-color:var(--accent);color:#fff;box-shadow:0 4px 12px var(--accent-glow);transform:scale(1.05)}.page-btn:active{transform:scale(.95)}.page-dots{color:var(--text-muted);padding:0 8px}.toast{position:fixed;bottom:24px;right:24px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);padding:16px 24px;font-weight:500;box-shadow:0 8px 32px #0006;transform:translateY(150px) scale(.9);opacity:0;transition:all .4s cubic-bezier(.4,0,.2,1);z-index:1000;backdrop-filter:blur(12px);min-width:280px}.toast.show{transform:translateY(0) scale(1);opacity:1}.toast.error{border-color:var(--danger);background:linear-gradient(135deg,var(--danger-bg) 0%,rgba(239,68,68,.05) 100%);color:var(--danger)}.toast.success{border-color:var(--success);background:linear-gradient(135deg,var(--success-bg) 0%,rgba(34,197,94,.05) 100%);color:var(--success)}.form-group{margin-bottom:20px;min-width:0}.form-group label:not(.checkbox-label){display:block;margin-bottom:8px;font-size:14px;font-weight:500;color:var(--text-secondary)}.form-group input:not([type=checkbox]):not([type=radio]),.form-group select,.form-group textarea{width:100%;padding:12px 16px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-family:inherit;font-size:14px;transition:all .2s ease}.form-group input:not([type=checkbox]):not([type=radio]):focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-glow);background:var(--bg-secondary);transform:translateY(-1px)}.form-group input:not([type=checkbox]):not([type=radio]):hover,.form-group select:hover,.form-group textarea:hover{border-color:var(--border-light)}.form-group small{display:block;margin-top:8px;font-size:12px;color:var(--text-muted)}.form-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}.form-row-3{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,2fr) minmax(0,1fr);gap:16px}.form-section{margin-top:32px;padding-top:32px;border-top:1px solid var(--border)}.form-section h3{font-size:15px;font-weight:600;margin-bottom:8px;color:var(--text-secondary)}.section-hint{display:block;color:var(--text-muted);font-size:12px;margin-bottom:20px;line-height:1.5}.docs-hint-link{display:inline-block;margin-right:6px;color:var(--accent);text-decoration:none;font-weight:700}.docs-hint-link:hover,.docs-hint-link:focus{text-decoration:underline}.optional-fields-wrap{margin-top:12px}.form-actions{display:flex;gap:12px;margin-top:32px;padding-top:24px;border-top:1px solid var(--border)}.checkbox-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:500;line-height:1.35;font-size:14px;color:var(--text-secondary);margin-bottom:0;vertical-align:middle}.form-group label.checkbox-label{display:flex;margin-bottom:0}.checkbox-label>input[type=checkbox],.checkbox-label>input[type=radio]{width:20px;height:20px;margin:0;padding:0;flex:0 0 auto;accent-color:var(--accent);background:transparent;border:0;border-radius:0;box-shadow:none;transform:none}.checkbox-label>input[type=checkbox]:hover,.checkbox-label>input[type=checkbox]:focus,.checkbox-label>input[type=radio]:hover,.checkbox-label>input[type=radio]:focus{background:transparent;box-shadow:none;transform:none}.checkbox-label>.checkbox-text,.checkbox-label>span{line-height:1.35}.onboarding-page{min-height:100vh;padding:32px 16px 48px;background:radial-gradient(circle at top,rgba(99,102,241,.16) 0%,transparent 32%),linear-gradient(135deg,#09090b,#0f1020 48%,#111113);position:relative;overflow-x:hidden;overflow-y:auto}.onboarding-page:before,.onboarding-page:after{content:"";position:fixed;inset:0;pointer-events:none;z-index:0}.onboarding-page:before{inset:-35vmax;background:conic-gradient(from 0deg,#6366f12e,#a855f724,#0ea5e91f,#6366f12e);filter:blur(96px);opacity:.9;transform-origin:center;animation:onboardingRotate 42s linear infinite}.onboarding-page:after{background:radial-gradient(circle at 18% 18%,rgba(99,102,241,.18),transparent 26%),radial-gradient(circle at 82% 16%,rgba(168,85,247,.16),transparent 28%),radial-gradient(circle at 50% 82%,rgba(56,189,248,.12),transparent 24%);animation:onboardingFloat 18s ease-in-out infinite alternate}@keyframes onboardingRotate{to{transform:rotate(360deg)}}@keyframes onboardingFloat{to{transform:translate3d(0,18px,0) scale(1.04)}}.onboarding-shell{position:relative;z-index:1;width:min(100%,760px);margin:0 auto}.onboarding-card{position:relative;background:#111113db;border:1px solid rgba(63,63,70,.78);border-radius:24px;padding:clamp(24px,4vw,46px);box-shadow:0 28px 80px #00000080;backdrop-filter:blur(20px);overflow:hidden}.onboarding-card:before{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.04) 0%,transparent 28%);pointer-events:none}.onboarding-header{text-align:center;margin-bottom:32px}.onboarding-header .logo-icon{font-size:48px;display:inline-block;filter:drop-shadow(0 0 18px var(--accent-glow))}.onboarding-header h1{margin-top:16px;font-size:clamp(28px,4vw,36px);font-weight:700;letter-spacing:-.03em;background:linear-gradient(135deg,var(--text-primary) 0%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.onboarding-brand{display:inline-block;margin-top:8px;font-size:13px;font-weight:600;text-decoration:none;background:linear-gradient(135deg,var(--accent) 0%,#a855f7 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.onboarding-subtitle{max-width:620px;margin:12px auto 0;color:var(--text-secondary);font-size:15px;line-height:1.65}.onboarding-lang-switch{display:inline-flex;align-items:center;gap:8px;padding:6px;margin:0 auto 28px;background:#ffffff0a;border:1px solid var(--border);border-radius:999px}.onboarding-lang-switch a{min-width:48px;padding:7px 14px;border-radius:999px;color:var(--text-secondary);font-size:12px;font-weight:600;text-decoration:none;transition:all .2s ease}.onboarding-lang-switch a:hover{color:var(--text-primary);background:#ffffff0f}.onboarding-lang-switch a.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 10px 24px #6366f140}.onboarding-stack>*+*{margin-top:18px}.onboarding-section{position:relative;border:1px solid rgba(63,63,70,.9);border-radius:18px;padding:20px;background:#18181bbd}.onboarding-section-title{display:flex;align-items:center;gap:10px;margin-bottom:8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-secondary)}.onboarding-section-title i{font-size:16px;color:var(--accent)}.onboarding-section-copy{color:var(--text-secondary);font-size:13px;line-height:1.65}.onboarding-feature-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:24px}.onboarding-feature-card{padding:16px;border-radius:16px;border:1px solid rgba(63,63,70,.9);background:#18181bb8}.onboarding-feature-card strong{display:block;margin-bottom:6px;font-size:13px;color:var(--text-primary)}.onboarding-feature-card span{color:var(--text-secondary);font-size:12px;line-height:1.6}.onboarding-actions{display:flex;gap:12px;margin-top:24px}.onboarding-actions .btn{flex:1 1 0;padding:14px 20px;font-size:15px;min-height:48px}.onboarding-progress-meta{text-align:center;margin-bottom:22px}.onboarding-progress-meta p{color:var(--text-secondary);font-size:14px}@media(max-width:800px){.onboarding-feature-grid{grid-template-columns:1fr}}@media(max-width:640px){.onboarding-page{padding:18px 12px 28px}.onboarding-card{border-radius:20px;padding:20px 18px}.onboarding-header{margin-bottom:24px}.onboarding-actions{flex-direction:column}}.login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:32px 16px 48px;background:radial-gradient(circle at top,rgba(99,102,241,.16) 0%,transparent 32%),linear-gradient(135deg,#09090b,#0f1020 48%,#111113);position:relative;overflow-x:hidden;overflow-y:auto}.login-page:before,.login-page:after{content:"";position:fixed;inset:0;pointer-events:none;z-index:0}.login-page:before{inset:-35vmax;background:conic-gradient(from 0deg,#6366f12e,#a855f724,#0ea5e91f,#6366f12e);filter:blur(96px);opacity:.9;transform-origin:center;animation:onboardingRotate 42s linear infinite}.login-page:after{background:radial-gradient(circle at 18% 18%,rgba(99,102,241,.18),transparent 26%),radial-gradient(circle at 82% 16%,rgba(168,85,247,.16),transparent 28%),radial-gradient(circle at 50% 82%,rgba(56,189,248,.12),transparent 24%);animation:onboardingFloat 18s ease-in-out infinite alternate}.login-container{width:100%;max-width:440px;padding:20px;position:relative;z-index:1}.login-box{position:relative;background:#111113db;border:1px solid rgba(63,63,70,.78);border-radius:24px;padding:clamp(28px,4vw,48px);box-shadow:0 28px 80px #00000080;backdrop-filter:blur(20px);overflow:hidden;animation:slideUp .5s ease-out}.login-box:before{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.04) 0%,transparent 28%);pointer-events:none}.login-box>*{position:relative;z-index:1}@keyframes slideUp{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}.login-header{text-align:center;margin-bottom:40px}.login-header .logo-icon{font-size:48px;filter:drop-shadow(0 0 20px var(--accent-glow));display:inline-block;animation:pulse 2s infinite}.login-header h1{margin-top:16px;font-size:28px;font-weight:700;background:linear-gradient(135deg,var(--text-primary) 0%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.login-header p{color:var(--text-secondary);margin-top:8px}.login-form .form-group{margin-bottom:24px}.login-form .btn-block{padding:14px 20px;font-size:15px;min-height:48px}.login-container.totp-enrollment{max-width:480px}.totp-qr-section{display:flex;justify-content:center;padding:20px 0}.totp-qr{width:200px;height:200px;border-radius:var(--radius-sm);background:#fafafa;padding:8px;box-shadow:0 4px 16px #0003}.totp-secret{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px 16px;margin-bottom:20px}.totp-secret-label{font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.totp-secret-value{display:block;font-family:JetBrains Mono,monospace;font-size:13px;color:var(--text-primary);word-break:break-all;line-height:1.5}.totp-secret-hint{display:block;margin-top:8px;font-size:12px;color:var(--text-muted)}@media(max-width:480px){.totp-qr{width:160px;height:160px}.login-container.totp-enrollment .login-box{padding:28px 20px}}.login-lang-switcher{display:flex;align-items:center;gap:8px;padding:6px;margin:0 auto 24px;width:fit-content;background:#ffffff0a;border:1px solid var(--border);border-radius:999px}.login-lang-switcher .lang-btn{min-width:48px;padding:7px 14px;font-size:12px;font-weight:600;color:var(--text-secondary);text-decoration:none;border-radius:999px;background:transparent;text-align:center;transition:all .2s ease}.login-lang-switcher .lang-btn:hover{color:var(--text-primary);background:#ffffff0f}.login-lang-switcher .lang-btn.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 10px 24px #6366f140}.lockout-alert{margin-bottom:16px}.lockout-meta{margin-top:6px;font-size:12px;opacity:.9}.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;border:1px solid transparent;transition:all .2s ease}.badge-success{background:var(--success-bg);color:var(--success);border-color:var(--success)}.badge-warning{background:var(--warning-bg);color:var(--warning);border-color:var(--warning)}.badge-danger{background:var(--danger-bg);color:var(--danger);border-color:var(--danger)}.badge-secondary{background:var(--bg-tertiary);color:var(--text-secondary);border-color:var(--border)}.badge-lg{padding:7px 16px;font-size:12px}.badge:hover{transform:scale(1.05)}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.detail-item{padding:16px;background:var(--bg-tertiary);border-radius:var(--radius-sm)}.detail-item label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:6px}.detail-item .value{font-size:14px;font-weight:500}.section-title{font-size:14px;font-weight:600;margin:28px 0 16px;color:var(--text-secondary);padding-bottom:12px;border-bottom:1px solid var(--border)}.nodes-list{display:flex;flex-wrap:wrap;gap:8px}.node-badge{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm)}.node-name{font-weight:600}.actions-list{display:flex;flex-direction:column;gap:10px}.input-readonly{width:100%;padding:12px 16px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-secondary);font-family:JetBrains Mono,monospace;font-size:12px}.row{display:flex;gap:24px;margin-bottom:24px}.col{flex:1}.col-4{flex:0 0 33.333%}.col-6{flex:0 0 calc(50% - 12px)}.col-8{flex:0 0 calc(66.666% - 12px)}.empty-state{padding:60px 20px;text-align:center}.empty-icon{font-size:48px;display:block;margin-bottom:16px;opacity:.5}.empty-state p{color:var(--text-muted);margin-bottom:20px}.empty-cell{padding:0!important}.text-center{text-align:center}.text-muted{color:var(--text-muted)}.mt-1{margin-top:8px}.mt-2{margin-top:16px}.mt-3{margin-top:24px}.mb-1{margin-bottom:8px}.hint{font-size:13px;color:var(--text-muted)}.settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.settings-grid .form-group{margin-bottom:0}.settings-grid input[type=number]{width:100%}.alert{padding:16px 20px;border-radius:var(--radius-sm);margin-bottom:20px;font-weight:500}.alert-error{background:var(--danger-bg);border:1px solid var(--danger);color:var(--danger)}.alert-success{background:var(--success-bg);border:1px solid var(--success);color:var(--success)}.alert-warning{background:var(--warning-bg);border:1px solid var(--warning);color:var(--warning)}.alert-info{background:var(--info-bg);border:1px solid var(--info);color:var(--text-secondary)}.alert-info strong{color:var(--info)}.alert-info code{color:var(--text-primary)}.groups-select{display:flex;flex-wrap:wrap;gap:8px;padding:8px 0}.checkbox-tag{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:2px solid var(--border);border-radius:20px;cursor:pointer;transition:all .2s ease;font-size:13px;background:var(--bg-tertiary)}.checkbox-tag:hover{background:var(--bg-hover);border-color:var(--border-light);transform:translateY(-1px)}.checkbox-tag input{display:none}.checkbox-tag:has(input:checked){background:var(--accent);border-color:var(--accent);box-shadow:0 4px 12px var(--accent-glow);transform:scale(1.02)}.checkbox-tag:has(input:checked) span{color:#fff;font-weight:600}.checkbox-tag:has(input:checked) .tag-dot{background:#fff!important;box-shadow:0 0 8px #ffffff80}.tag-dot{width:8px;height:8px;border-radius:50%}.group-tag{display:inline-flex;align-items:center;padding:5px 12px;border:1px solid;border-radius:12px;font-size:12px;font-weight:500;margin-right:6px;transition:all .2s ease;cursor:default}.group-tag:hover{transform:scale(1.05);box-shadow:0 2px 8px #0003}.group-tag-sm{display:inline-flex;align-items:center;padding:3px 10px;border:1px solid;border-radius:10px;font-size:11px;font-weight:500;margin-right:4px;transition:all .2s ease;cursor:default}.group-tag-sm:hover{transform:scale(1.08)}.group-more{font-size:11px;color:var(--text-muted)}.groups-list{margin-top:8px}.actions-grid{display:flex;flex-wrap:wrap;gap:10px}.action-btn{display:inline-flex;align-items:center;gap:8px;padding:10px 16px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;cursor:pointer;transition:var(--transition)}.action-btn:hover{background:var(--bg-hover);border-color:var(--border-light)}.action-btn:disabled{opacity:.5;cursor:not-allowed}.action-btn.is-disabled{opacity:.5;cursor:not-allowed}.action-btn .action-icon{font-size:16px}.mobile-header,.mobile-overlay,.mobile-nodes-list,.mobile-users-list{display:none}.mobile-menu-btn{display:none;flex-direction:column;justify-content:space-around;width:40px;height:40px;background:transparent;border:none;cursor:pointer;padding:8px}.mobile-menu-btn span{width:24px;height:2px;background:var(--text-primary);border-radius:2px;transition:all .3s ease}@media(max-width:1200px){.stats-grid{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.settings-grid{grid-template-columns:1fr}}@media(max-width:768px){.table-wrapper{display:none!important}.mobile-nodes-list,.mobile-users-list{display:flex!important}.mobile-header{display:flex;align-items:center;padding:12px 16px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:fixed;top:0;left:0;right:0;height:60px;z-index:200}.mobile-logo{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;align-items:center;gap:8px;pointer-events:none}.mobile-logo .logo-icon,.mobile-status{display:none}.mobile-logo .logo-text{font-size:16px;font-weight:700;letter-spacing:.5px;background:linear-gradient(135deg,var(--accent) 0%,#a855f7 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.mobile-menu-btn{display:flex}.mobile-overlay{display:block;position:fixed;inset:0;background:#0009;backdrop-filter:blur(4px);opacity:0;visibility:hidden;transition:all .3s ease;z-index:150}.mobile-overlay.active{opacity:1;visibility:visible}.sidebar{position:fixed;left:-280px;top:0;width:280px;height:100vh;z-index:200;transition:left .3s ease;box-shadow:4px 0 24px #0000004d}.sidebar.active{left:0}.content{margin-left:0;padding-top:60px;width:100%}.topbar{position:relative;padding:16px}.topbar h1{font-size:18px}.topbar-right .status-text{display:none}.main-content{padding:16px}.sticky-header{position:relative;top:auto;padding:4px 0;margin:0 0 14px}.toast{left:16px;right:16px;bottom:16px;min-width:0}.stats-grid{grid-template-columns:repeat(2,1fr);gap:12px}.stat-card{padding:16px}.stat-value{font-size:24px}.form-row,.form-row-3,.detail-grid{grid-template-columns:1fr}.row{flex-direction:column;gap:16px}.col-4,.col-6,.col-8{flex:1}.page-header{flex-direction:column;gap:10px;align-items:stretch}.page-header-left,.page-header-right{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.page-header-center{width:100%;justify-content:flex-start}.filters-bar{max-width:100%;gap:2px}.filters-bar .search-box input{width:180px;font-size:13px}.filters-bar .filter-select{font-size:13px;padding:8px 26px 8px 12px}.filters-bar .search-hint{display:none}.page-header-right .btn{flex:0 1 auto;white-space:nowrap}.page-fab-mobile{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:100;width:56px;height:56px;padding:0;gap:0;border-radius:50%;box-shadow:0 12px 28px #6366f173,0 4px 12px #00000073}.page-fab-mobile i{font-size:24px}.page-fab-mobile .fab-label{display:none}.page-fab-mobile:active{transform:scale(.94)}.page-header-right:has(.page-fab-mobile){display:contents}.logs-search-wrap{flex:1;min-width:0}.logs-search-input{width:100%!important}.logs-search-input:focus{width:100%!important}.table-wrapper{display:none}.mobile-nodes-list,.mobile-users-list{display:flex;flex-direction:column;gap:12px;padding:16px}.mobile-node-card,.mobile-user-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px;transition:all .2s ease}.mobile-node-card:active,.mobile-user-card:active{transform:scale(.98);background:var(--bg-hover)}.mobile-node-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;gap:12px}.mobile-user-title-wrap{display:flex;align-items:flex-start;gap:10px;min-width:0}.mobile-user-card.is-selected{border-color:var(--accent);background:#6366f11f}.mobile-user-select{margin-top:2px;flex:0 0 auto}.mobile-node-title{flex:1;min-width:0}.mobile-node-title strong{font-size:15px;display:block;margin-bottom:4px}.mobile-node-title code{font-size:12px;color:var(--text-muted)}.mobile-node-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:12px}.mobile-stat-item{display:flex;flex-direction:column;gap:4px}.mobile-stat-label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px}.mobile-stat-value{font-size:14px;font-weight:500}.mobile-node-actions{display:flex;gap:8px;padding-top:12px;border-top:1px solid var(--border)}.mobile-node-actions>.btn{flex:1 1 0;min-width:0;padding:10px 0;gap:0}.mobile-node-actions>.btn i{font-size:18px}.logs-container{max-height:200px;font-size:11px}.log-line{padding:6px 12px;word-break:break-word;white-space:pre-wrap;font-size:10px}.form-actions .btn,.actions-list .btn,.login-form .btn{width:100%;justify-content:center}.btn-sm{padding:10px 16px}.action-buttons{justify-content:center}.card{border-radius:var(--radius)}.card-header{padding:16px;flex-wrap:wrap}.card-body{padding:16px}.dashboard-grid{grid-template-columns:1fr;gap:16px}.dashboard-main,.dashboard-sidebar{width:100%;margin-top:0}.nodes-table-wrapper{overflow-x:auto;-webkit-overflow-scrolling:touch}.quick-action{padding:16px;font-size:15px}.pagination{flex-wrap:wrap}.page-btn{min-width:36px;height:36px;padding:0 10px;font-size:13px}.form-group input,.form-group select,.form-group textarea{font-size:16px}.login-container{padding:16px}.login-box{padding:32px 24px}}@media(max-width:480px){.stats-grid{grid-template-columns:1fr}.stat-card{padding:16px}.stat-value{font-size:24px}.stat-label{font-size:12px}.table th,.table td{padding:10px 8px;font-size:12px}.btn-icon{width:32px;height:32px;font-size:14px}.action-buttons{gap:6px}}.lang-switcher{display:flex;align-items:center;gap:4px;margin-bottom:12px;padding:4px;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.lang-btn{flex:1;padding:7px 12px;text-align:center;font-size:12px;font-weight:600;letter-spacing:.04em;color:var(--text-secondary);text-decoration:none;background:transparent;border-radius:999px;transition:color .2s ease,background .2s ease,transform .15s ease}.lang-btn:hover{color:var(--text-primary);background:#ffffff0d}.lang-btn.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 6px 20px #6366f14d,0 1px #ffffff2e inset,0 -1px #00000040 inset}.lang-btn:active{transform:scale(.97)}.radio-group{display:flex;gap:16px;flex-wrap:wrap}.form-group .datetime-input{color-scheme:dark;padding-right:44px}.form-group .datetime-input::-webkit-calendar-picker-indicator{opacity:1;cursor:pointer;border-radius:var(--radius-xs);padding:4px;background-color:var(--bg-elevated);border:1px solid var(--border-light);filter:invert(94%) sepia(7%) saturate(154%) hue-rotate(192deg) brightness(91%) contrast(96%);transition:all var(--transition)}.form-group .datetime-input::-webkit-calendar-picker-indicator:hover{background-color:var(--bg-hover);border-color:var(--accent)}.form-group .datetime-input::-webkit-calendar-picker-indicator:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.ti{font-size:1em;line-height:1;vertical-align:-.125em}@keyframes ti-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.ti-loader-2{display:inline-block;animation:ti-spin 1s linear infinite}@keyframes fadeUp{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.main-content{animation:fadeIn .2s ease-out}.stat-card{animation:fadeUp .3s ease-out both}.stat-card:nth-child(1){animation-delay:.03s}.stat-card:nth-child(2){animation-delay:.07s}.stat-card:nth-child(3){animation-delay:.11s}.stat-card:nth-child(4){animation-delay:.15s}.card{animation:fadeUp .25s ease-out both}.dashboard-main>.card:nth-child(1){animation-delay:.05s}.dashboard-main>.card:nth-child(2){animation-delay:.1s}.dashboard-sidebar>.card:nth-child(1){animation-delay:.08s}.dashboard-sidebar>.card:nth-child(2){animation-delay:.13s}.table tbody tr{animation:fadeIn .2s ease-out both}.table tbody tr:nth-child(1){animation-delay:.04s}.table tbody tr:nth-child(2){animation-delay:.07s}.table tbody tr:nth-child(3){animation-delay:.1s}.table tbody tr:nth-child(4){animation-delay:.13s}.table tbody tr:nth-child(5){animation-delay:.16s}.table tbody tr:nth-child(n+6){animation-delay:.18s}.topbar{animation:fadeIn .15s ease-out}.page-header{animation:fadeIn .2s ease-out}.quick-action{animation:fadeUp .25s ease-out both}.quick-actions .quick-action:nth-child(1){animation-delay:.05s}.quick-actions .quick-action:nth-child(2){animation-delay:.08s}.quick-actions .quick-action:nth-child(3){animation-delay:.11s}.quick-actions .quick-action:nth-child(4){animation-delay:.14s}.quick-actions .quick-action:nth-child(5){animation-delay:.17s}@media(prefers-reduced-motion:reduce){.main-content,.stat-card,.card,.table tbody tr,.nav-menu li,.topbar,.page-header,.quick-action{animation:none}}.cascade-role-picker{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-top:10px}.role-card{display:flex;align-items:center;gap:11px;padding:12px 14px;border:1.5px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;transition:all .18s;background:var(--bg-tertiary, rgba(255, 255, 255, .02));user-select:none}.role-card:hover{border-color:#ffffff2e;background:#ffffff0a}.role-card.active{border-color:var(--accent, #6366f1);background:#6366f114}.role-card-icon{width:34px;height:34px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}.role-card-icon.role-standalone{background:#4755694d;color:#94a3b8}.role-card-icon.role-portal{background:#6366f133;color:#a5b4fc}.role-card-icon.role-bridge{background:#f59e0b33;color:#fcd34d}.role-card-icon.role-relay{background:#8b5cf633;color:#c4b5fd}.role-card.active .role-card-icon.role-standalone{background:#47556980}.role-card.active .role-card-icon.role-portal{background:#6366f14d}.role-card.active .role-card-icon.role-bridge{background:#f59e0b4d}.role-card.active .role-card-icon.role-relay{background:#8b5cf64d}.role-card-body{display:flex;flex-direction:column;gap:2px;min-width:0}.role-card-name{font-size:12px;font-weight:600;color:var(--text-primary, #e2e8f0);white-space:nowrap}.role-card-name small{font-weight:400;color:var(--text-muted, #64748b);font-size:10px}.role-card-desc{font-size:10px;color:var(--text-muted, #64748b);line-height:1.3;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.cascade-links-table td{vertical-align:middle}.cascade-node-cell{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.badge-this{display:inline-block;padding:1px 7px;border-radius:6px;font-size:9px;font-weight:700;letter-spacing:.4px;background:#6366f12e;color:#a5b4fc;border:1px solid rgba(99,102,241,.3);text-transform:uppercase}.cascade-link-actions{display:flex;align-items:center;gap:5px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.btn:focus-visible,.btn-icon:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}h1,h2,h3,h4,h5,h6{line-height:1.2;letter-spacing:-.02em}p{line-height:1.6}.ti{vertical-align:middle;position:relative;top:-1px}.table th{color:var(--text-secondary);border-bottom:2px solid var(--border)}.table tbody tr:last-child td{border-bottom:none}.table tbody tr:hover{background:var(--bg-hover);transform:none}.badge{font-weight:600;letter-spacing:.02em}.badge:hover{transform:none;filter:brightness(1.1)}.form-group input:not([type=checkbox]):not([type=radio]):focus,.form-group select:focus,.form-group textarea:focus{transform:none}.form-group label:not(.checkbox-label){color:var(--text-primary)}.card{box-shadow:0 4px 12px #00000026}.card-header{background:var(--bg-secondary)}.stat-card{box-shadow:0 4px 12px #00000026}.btn{letter-spacing:.01em}.btn:active{transform:scale(.98)}.btn-icon:active{transform:scale(.92)}.nav-menu a{border-radius:var(--radius-sm)}.nav-menu a:active{transform:scale(.98)}.btn,.btn-icon,.nav-menu a,.card,.stat-card,.action-btn{transition:all .2s cubic-bezier(.4,0,.2,1)}.port-chip-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center}.port-chip{display:inline-block;padding:2px 8px;border-radius:10px;background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);font-family:JetBrains Mono,Consolas,Monaco,monospace;font-size:12px;line-height:1.4;cursor:help}.port-chip-extra{background:var(--accent-glow, rgba(99, 102, 241, .12));border-color:var(--accent, #6366f1);color:var(--accent, #6366f1)}.node-meta-strip{display:flex;flex-wrap:wrap;align-items:center;column-gap:4px;row-gap:3px;margin-top:5px;max-width:320px}.node-meta-strip-mobile{max-width:100%;margin:5px 0 2px}.node-meta-badge{display:inline-flex;align-items:center;gap:3px;min-height:18px;max-width:100%;padding:2px 6px;border-radius:4px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-muted);font-size:11px;font-weight:600;line-height:1.25;letter-spacing:0;white-space:nowrap}.node-meta-badge i{flex:0 0 auto;font-size:11px;opacity:.9}.node-meta-transport i{color:#7dd3fc}.node-meta-reality i,.node-meta-tls i{color:#86efac}.node-meta-none i,.node-meta-virtual i{color:var(--text-muted);opacity:.75}.node-meta-panel i,.node-meta-acme i,.node-meta-manual i,.node-meta-self-signed i,.node-meta-files i{color:#fcd34d}.node-domain-line{display:inline-flex;align-items:center;gap:4px;max-width:280px;margin-top:4px;color:var(--text-muted);font-size:12px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.node-domain-line i{flex:0 0 auto;font-size:12px;opacity:.8}:root{--glass-bg: rgba(24, 24, 27, .62);--glass-bg-strong: rgba(24, 24, 27, .78);--glass-bg-soft: rgba(255, 255, 255, .025);--glass-border: rgba(255, 255, 255, .08);--glass-border-strong: rgba(255, 255, 255, .14);--glass-highlight: rgba(255, 255, 255, .06);--glass-shadow: 0 1px 0 rgba(255, 255, 255, .04) inset, 0 -1px 0 rgba(0, 0, 0, .35) inset, 0 8px 32px rgba(0, 0, 0, .32);--glass-shadow-lg: 0 1px 0 rgba(255, 255, 255, .06) inset, 0 -1px 0 rgba(0, 0, 0, .4) inset, 0 24px 60px rgba(0, 0, 0, .45);--glass-blur: saturate(180%) blur(22px);--glass-blur-sm: saturate(160%) blur(14px)}.glass{position:relative;background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow);border-radius:var(--radius);overflow:hidden}.glass:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.06) 0%,rgba(255,255,255,0) 24%,transparent 100%);border-radius:inherit}.card-glass{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow)}.glass-modal{position:fixed;inset:0;z-index:1000;background:#08080a8c;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);align-items:center;justify-content:center}.glass-modal-content{background:var(--glass-bg-strong);border:1px solid var(--glass-border-strong);border-radius:var(--radius);padding:1.5rem;width:100%;max-width:520px;max-height:90vh;overflow-y:auto;backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow-lg)}.topbar{background:#111113b8;border-bottom:1px solid var(--glass-border);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur)}.mobile-header{backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.sidebar{background:linear-gradient(180deg,#111113f5,#0f101bf5);border-right:1px solid var(--glass-border)}.toast{background:#1f1f23c7;backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--glass-border-strong);box-shadow:var(--glass-shadow-lg)}.settings-tabbar{position:sticky;top:73px;z-index:30;margin:0 0 22px;padding:0;background:transparent}.settings-tabs{display:inline-flex;align-items:center;gap:4px;padding:6px;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);overflow-x:auto;overflow-y:hidden;max-width:100%;scrollbar-width:none}.settings-tabs::-webkit-scrollbar{display:none}.settings-tab{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;font-family:inherit;font-size:13.5px;font-weight:500;white-space:nowrap;border-radius:999px;transition:color .2s ease,background .2s ease,transform .15s ease;position:relative}.settings-tab i{font-size:16px;opacity:.85}.settings-tab:hover{color:var(--text-primary);background:#ffffff0d}.settings-tab.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 6px 20px #6366f159,0 1px #ffffff2e inset,0 -1px #00000040 inset}.settings-tab.active i{opacity:1}.settings-tab:active{transform:scale(.97)}.settings-tab-caption{display:flex;align-items:center;gap:12px;margin-bottom:18px;padding:14px 18px;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:var(--radius);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.settings-tab-caption .stc-icon{flex-shrink:0;width:38px;height:38px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);font-size:19px;background:linear-gradient(135deg,#6366f138,#a855f729);color:#c7d2fe;border:1px solid rgba(99,102,241,.25)}.settings-tab-caption .stc-text{min-width:0}.settings-tab-caption .stc-title{font-size:15px;font-weight:600;color:var(--text-primary);letter-spacing:-.01em;margin:0}.settings-tab-caption .stc-desc{font-size:12.5px;color:var(--text-muted);margin:2px 0 0;line-height:1.5}.settings-tab-caption .stc-version{flex-shrink:0;margin-left:auto;align-self:center;display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:600;letter-spacing:-.01em;color:#c7d2fe;background:linear-gradient(135deg,#6366f12e,#a855f71f);border:1px solid rgba(99,102,241,.28);white-space:nowrap}.settings-tab-caption .stc-version i{font-size:13px;opacity:.9}.settings-page .card,.settings-page .card-glass{background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:var(--radius);box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm);position:relative;overflow:hidden}.settings-page .card:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.05) 0%,rgba(255,255,255,0) 30%,transparent 100%);border-radius:inherit}.settings-page .card>*{position:relative;z-index:1}.settings-page .card-header{background:transparent;border-bottom:1px solid var(--glass-border);padding:16px 20px;gap:12px}.settings-page .card-header h2{font-size:14.5px;letter-spacing:-.005em;display:flex;align-items:center;gap:10px}.settings-page .card-header h2>i:first-child{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);font-size:15px;background:linear-gradient(135deg,#6366f12e,#a855f71f);color:#c7d2fe;border:1px solid rgba(99,102,241,.2);flex-shrink:0}.settings-page .card-body{padding:18px 20px}.settings-page .card:hover{border-color:var(--glass-border-strong);box-shadow:0 1px #ffffff0f inset,0 -1px #0006 inset,0 14px 44px #0000006b}.settings-list .setting-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 0;border-bottom:1px dashed var(--glass-border)}.settings-list .setting-item:last-child{border-bottom:none}.settings-list .setting-item label{font-size:12.5px;color:var(--text-muted);margin:0;font-weight:500;text-transform:uppercase;letter-spacing:.04em}.settings-list .setting-item span,.settings-list .setting-item code{font-size:13px;font-weight:500;color:var(--text-primary);text-align:right}.settings-list .setting-item code{background:#ffffff0a;border:1px solid var(--glass-border);border-radius:var(--radius-xs);padding:3px 7px;font-family:JetBrains Mono,monospace;font-size:11.5px}.settings-page .form-group small.hint,.settings-page small.hint{color:var(--text-muted);line-height:1.55;font-size:12px}.settings-page .form-group input:not([type=checkbox]):not([type=radio]),.settings-page .form-group select,.settings-page .form-group textarea{background:#0a0a0b8c;border:1px solid var(--glass-border);border-radius:var(--radius-sm);padding:11px 14px;transition:all .18s ease}.settings-page .form-group input:not([type=checkbox]):not([type=radio]):focus,.settings-page .form-group select:focus,.settings-page .form-group textarea:focus{background:#0a0a0bcc;border-color:#6366f18c;box-shadow:0 0 0 4px #6366f12e}.settings-save-bar{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:18px}.settings-stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:18px}.settings-stat{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:var(--radius);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm);transition:border-color .2s ease,transform .15s ease}.settings-stat:hover{border-color:var(--glass-border-strong);transform:translateY(-1px)}.settings-stat-icon{flex-shrink:0;width:38px;height:38px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);font-size:18px;background:linear-gradient(135deg,#6366f133,#a855f724);color:#c7d2fe;border:1px solid rgba(99,102,241,.22)}.settings-stat-icon.success{background:linear-gradient(135deg,#22c55e38,#22c55e1a);color:#86efac;border-color:#22c55e47}.settings-stat-icon.warning{background:linear-gradient(135deg,#eab30838,#eab3081a);color:#fde047;border-color:#eab30847}.settings-stat-icon.info{background:linear-gradient(135deg,#38bdf838,#38bdf81a);color:#7dd3fc;border-color:#38bdf847}.settings-stat-content{min-width:0;flex:1}.settings-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:2px;font-weight:600}.settings-stat-value{font-size:14px;font-weight:600;color:var(--text-primary);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.settings-stat-value code{font-family:JetBrains Mono,monospace;font-size:12px;background:#ffffff0a;border:1px solid var(--glass-border);border-radius:var(--radius-xs);padding:2px 6px}.settings-page .card.danger-zone{border-color:#ef444452;background:linear-gradient(180deg,#ef44440f,#18181b99)}.settings-page .card.danger-zone .card-header{border-bottom-color:#ef444433}.settings-page .card.danger-zone .card-header h2{color:#fca5a5}.settings-page .card.danger-zone .card-header h2>i:first-child{background:linear-gradient(135deg,#ef44443d,#ef44441f);color:#fca5a5;border-color:#ef444452}.settings-page [id$=Modal]>div,.glass-modal{background:#141417db!important;border:1px solid var(--glass-border-strong)!important;border-radius:var(--radius)!important;backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow-lg)!important}[id$=Modal][style*=flex]{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}.settings-page pre,.settings-page code:not(.setting-item code):not(.table code){background:#00000052;border:1px solid var(--glass-border);border-radius:var(--radius-sm)}.settings-divider{height:1px;margin:18px 0;background:linear-gradient(90deg,transparent,var(--glass-border-strong),transparent);border:none}.settings-subsection-title{display:flex;align-items:center;gap:8px;margin:18px 0 12px;font-size:13px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.05em}.settings-subsection-title i{color:var(--accent);font-size:15px}.settings-subsection-title:first-child{margin-top:0}@media(max-width:768px){.settings-tabbar{top:60px;margin:0 0 18px;padding:0}.settings-tabs{width:100%;border-radius:var(--radius);flex-wrap:nowrap}.settings-tab{padding:8px 12px;font-size:12.5px}.settings-tab i{font-size:15px}.settings-tab-caption{padding:12px 14px;gap:10px}.settings-tab-caption .stc-icon{width:34px;height:34px;font-size:17px}.settings-stat-row{grid-template-columns:1fr}.settings-page .card-body{padding:14px 16px}.settings-save-bar{flex-wrap:wrap}}.settings-tab:focus-visible{outline:2px solid var(--accent);outline-offset:3px}@media(prefers-reduced-motion:reduce){.settings-tab,.settings-stat,.settings-page .card{transition:none}}body{background:radial-gradient(ellipse at 0% 0%,rgba(99,102,241,.08),transparent 45%),radial-gradient(ellipse at 100% 0%,rgba(168,85,247,.06),transparent 50%),var(--bg-primary);background-attachment:fixed}.card{background:var(--glass-bg);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm);position:relative}.card:after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.04) 0%,rgba(255,255,255,0) 28%,transparent 100%);border-radius:inherit}.card>*{position:relative;z-index:1}.card-header{background:transparent;border-bottom:1px solid var(--glass-border)}.card:hover{border-color:var(--glass-border-strong);box-shadow:0 1px #ffffff0f inset,0 -1px #0006 inset,0 14px 44px #0000006b}.stat-card{background:var(--glass-bg);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.stat-card:hover{border-color:#6366f180;box-shadow:0 1px #ffffff0f inset,0 -1px #0006 inset,0 18px 50px #6366f12e}.quick-action{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.quick-action:hover{background:#6366f114;border-color:#6366f166}.table th{background:#ffffff08;border-bottom:1px solid var(--glass-border)}.table td{border-bottom:1px solid var(--glass-border)}.table tbody tr:hover{background:#ffffff06}.search-box input,.filter-select,.logs-search-input{background:#0a0a0b8c;border-color:var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.search-box input:focus,.filter-select:focus,.logs-search-input:focus{background:#0a0a0bcc;border-color:#6366f18c;box-shadow:0 0 0 4px #6366f12e}.form-group input:not([type=checkbox]):not([type=radio]),.form-group select,.form-group textarea{background:#0a0a0b8c;border-color:var(--glass-border)}.form-group input:not([type=checkbox]):not([type=radio]):focus,.form-group select:focus,.form-group textarea:focus{background:#0a0a0bcc;border-color:#6366f18c;box-shadow:0 0 0 4px #6366f12e}.page-btn{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.badge-secondary{background:#ffffff0d;border-color:var(--glass-border)}.action-btn{background:var(--glass-bg);border-color:var(--glass-border)}.action-btn:hover{background:#ffffff0d;border-color:var(--glass-border-strong)}.mobile-node-card,.mobile-user-card{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.section-title{border-bottom:1px solid var(--glass-border)}.detail-item{background:var(--glass-bg);border:1px solid var(--glass-border)}.table code{background:transparent;border:none;padding:0}.settings-page .row{align-items:stretch}.settings-page .row>[class^=col-]{display:flex;flex-direction:column}.settings-page .row>[class^=col-]>.card{flex:1 1 auto;display:flex;flex-direction:column}.settings-page .row>[class^=col-]>.card>.card-body{flex:1 1 auto}.xray-tls-config{border:1px solid var(--border-light);border-radius:var(--radius-sm);padding:16px;background:var(--bg-elevated, transparent)}.xray-tls-config>.block-label{display:flex;align-items:center;gap:8px;font-weight:600;margin:0 0 6px;color:var(--text);font-size:14px}.xray-tls-config>.hint{display:block;color:var(--text-muted);font-size:12px;margin:0 0 14px;line-height:1.5}.xray-tls-config .radio-group{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin:0 0 14px;flex-wrap:nowrap}.xray-tls-config .radio-row{position:relative;display:flex;align-items:flex-start;gap:10px;padding:12px 16px 12px 14px;border:1px solid var(--border-light);border-radius:var(--radius-xs);cursor:pointer;transition:background .12s ease,border-color .12s ease,box-shadow .12s ease;background:transparent;margin:0}.xray-tls-config .radio-row .badge{position:absolute;top:10px;right:10px;padding:2px 8px;font-size:9px;line-height:1.4;border-radius:999px;text-transform:uppercase;letter-spacing:.5px}.xray-tls-config .radio-row:hover{background:var(--bg-hover, rgba(127, 127, 127, .05));border-color:var(--border)}.xray-tls-config .radio-row input[type=radio]{margin:3px 0 0;flex-shrink:0;accent-color:var(--accent)}.xray-tls-config .radio-row:has(input[type=radio]:checked){border-color:var(--accent);background:rgba(var(--accent-rgb, 79, 156, 249),.08);box-shadow:0 0 0 1px var(--accent) inset}.xray-tls-config .radio-label{display:flex;flex-direction:column;gap:4px;flex:1 1 auto;min-width:0;line-height:1.4}.xray-tls-config .radio-label strong{display:block;color:var(--text);font-size:13px;font-weight:600;padding-right:90px}.xray-tls-config .radio-hint{display:block;color:var(--text-muted);font-size:11.5px;line-height:1.5}.fp-mode-group .radio-group{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin:0 0 6px;flex-wrap:nowrap}.fp-mode-group .radio-row{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border:1px solid var(--border-light);border-radius:var(--radius-xs);cursor:pointer;transition:background .12s ease,border-color .12s ease;margin:0}.fp-mode-group .radio-row:hover{border-color:var(--text-muted)}.fp-mode-group .radio-label{display:flex;flex-direction:column;gap:2px}.fp-mode-group .radio-hint{display:block;color:var(--text-muted);font-size:11.5px;line-height:1.5}.fp-checkbox-grid{display:flex;flex-wrap:wrap;gap:6px}.fp-checkbox{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border:1px solid var(--border-light);border-radius:999px;cursor:pointer;font-size:12.5px;line-height:1;user-select:none;transition:background .12s ease,border-color .12s ease,color .12s ease}.fp-checkbox:hover{border-color:var(--accent)}.fp-checkbox:has(input:checked){border-color:var(--accent);background:#6366f124;color:var(--accent-hover)}.fp-checkbox input{margin:0;cursor:pointer;flex-shrink:0;accent-color:var(--accent)}.fp-checkbox input:disabled{cursor:default}.xray-tls-config .info-card{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;margin:0 0 10px;background:#7f7f7f0d;border:1px solid var(--border-light);border-radius:var(--radius-xs);font-size:13px;color:var(--text-muted);line-height:1.5}.xray-tls-config .info-card>i{color:var(--accent);margin-top:2px;flex-shrink:0;font-size:16px}.xray-tls-config .info-card>div{flex:1;min-width:0}.xray-tls-config .info-card>div>div{color:var(--text);margin-bottom:4px}.xray-tls-config .info-card>div>small{display:block;color:var(--text-muted);font-size:12px}.xray-tls-config .info-card-warning{background:#d99a2712;border-color:#d99a2766}.xray-tls-config .info-card-warning>i{color:#d99a27}.xray-tls-config .info-card-warning>div>strong{display:block;color:#d99a27;margin-bottom:4px}.xray-tls-config .info-card code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#7f7f7f1f;padding:1px 6px;border-radius:3px;font-size:12px;color:var(--text)}.xray-tls-config .tls-source-manual .form-group{margin:0 0 12px}.xray-tls-config .tls-source-manual .form-group:last-child{margin-bottom:0}.xray-tls-config .tls-source-manual label{display:block;margin-bottom:6px;font-size:12.5px;font-weight:600;color:var(--text)}.xray-tls-config .tls-source-manual small{display:block;color:var(--text-muted);font-size:11.5px;margin-top:4px;line-height:1.5}.pem-textarea{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;white-space:pre;overflow-wrap:normal;overflow-x:auto;width:100%}.pem-status{display:block;min-height:16px;margin-top:4px;font-size:11.5px;transition:color .1s ease}.pem-status.is-valid{color:#2ea043}.pem-status.is-invalid{color:#d9534f} +:root{--bg-primary: #0a0a0b;--bg-secondary: #111113;--bg-tertiary: #18181b;--bg-elevated: #1f1f23;--bg-hover: #27272a;--text-primary: #fafafa;--text-secondary: #a1a1aa;--text-muted: #71717a;--accent: #6366f1;--accent-hover: #818cf8;--accent-glow: rgba(99, 102, 241, .3);--accent-gradient-end: #7c3aed;--accent-hover-gradient-end: #8b5cf6;--success: #22c55e;--success-hover: #16a34a;--success-bg: rgba(34, 197, 94, .15);--warning: #eab308;--warning-hover: #ca8a04;--warning-bg: rgba(234, 179, 8, .15);--danger: #ef4444;--danger-hover: #dc2626;--danger-bg: rgba(239, 68, 68, .15);--info: #3b82f6;--info-bg: rgba(59, 130, 246, .15);--border: #27272a;--border-light: #3f3f46;--primary: var(--accent);--text: var(--text-primary);--muted: var(--text-muted);--card-bg: var(--bg-secondary);--input-bg: var(--bg-primary);--border-color: var(--border);--radius: 16px;--radius-sm: 10px;--radius-xs: 6px;--shadow: 0 4px 24px rgba(0, 0, 0, .4);--shadow-lg: 0 8px 40px rgba(0, 0, 0, .5);--transition: .15s ease}*{box-sizing:border-box;margin:0;padding:0}body{font-family:Inter,-apple-system,BlinkMacSystemFont,sans-serif;font-size:14px;background:var(--bg-primary);color:var(--text-primary);line-height:1.5;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;overflow-x:hidden}html{scroll-behavior:smooth}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:var(--bg-secondary)}::-webkit-scrollbar-thumb{background:var(--border-light);border-radius:4px}::-webkit-scrollbar-thumb:hover{background:var(--accent)}.app{display:flex;min-height:100vh}.sidebar{width:260px;background:var(--bg-secondary);border-right:1px solid var(--border);display:flex;flex-direction:column;position:fixed;height:100vh;z-index:100}.logo{padding:24px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--border)}.logo-icon{font-size:28px;filter:drop-shadow(0 0 12px var(--accent-glow));animation:pulse 3s infinite}.logo-content{display:flex;flex-direction:column;gap:2px}.logo-text{font-size:18px;font-weight:700;letter-spacing:1px;background:linear-gradient(135deg,var(--accent) 0%,#a855f7 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.logo-sub{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text-secondary);opacity:.8;font-weight:400}.sidebar-version{display:block;margin-top:10px;text-align:center;font-family:JetBrains Mono,monospace;font-size:10px;letter-spacing:.5px;color:var(--text-secondary);opacity:.45;text-decoration:none;transition:opacity .15s ease}.sidebar-version:hover{opacity:.85}.login-footer{position:fixed;left:0;right:0;bottom:16px;text-align:center;font-family:JetBrains Mono,monospace;font-size:10px;letter-spacing:.5px;color:var(--text-secondary);opacity:.4;text-decoration:none;transition:opacity .15s ease}.login-footer:hover{opacity:.8}.nav-menu{list-style:none;padding:16px 12px;flex:1}.nav-menu li{margin-bottom:4px}.nav-menu a{display:flex;align-items:center;gap:12px;padding:12px 16px;color:#c4c4cc;text-decoration:none;border-radius:var(--radius-sm);transition:all .2s cubic-bezier(.4,0,.2,1);font-weight:500;position:relative}.nav-menu a:before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:0;background:var(--accent);border-radius:0 2px 2px 0;transition:height .3s ease}.nav-menu a:hover{background:var(--bg-hover);color:var(--text-primary)}.nav-menu a:hover:before{height:60%}.nav-menu a.active{background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);color:#fff;box-shadow:0 4px 12px var(--accent-glow)}.nav-menu a.active:before{height:100%;background:#ffffff4d}.nav-icon{font-size:18px;width:24px;text-align:center;color:#d4d4dc}.sidebar-footer{padding:16px;border-top:1px solid var(--border)}.logout-btn{display:block;width:100%;padding:12px;text-align:center;color:var(--text-muted);text-decoration:none;border:1px solid var(--border);border-radius:var(--radius-sm);transition:all var(--transition);font-weight:500}.logout-btn:hover{background:var(--danger);border-color:var(--danger);color:#fff}.github-link{display:flex;align-items:center;justify-content:center;gap:8px;width:100%;padding:10px;text-align:center;color:var(--text-muted);text-decoration:none;border:1px solid var(--border);border-radius:var(--radius-sm);transition:all var(--transition);font-weight:500;font-size:13px;margin-bottom:8px}.github-link:hover{background:#ffffff0d;border-color:var(--accent);color:var(--text-primary)}.github-link svg{flex-shrink:0}.content{flex:1;margin-left:260px;min-height:100vh;min-width:0;background:var(--bg-primary)}.topbar{display:flex;justify-content:space-between;align-items:center;padding:20px 32px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:50;backdrop-filter:blur(12px)}.topbar h1{font-size:20px;font-weight:600}.topbar-right{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--text-secondary)}.status-dot{width:10px;height:10px;border-radius:50%;background:var(--text-muted)}.status-dot.online{background:var(--success);box-shadow:0 0 12px var(--success);animation:pulse 2s infinite}@keyframes pulse{0%,to{opacity:1}50%{opacity:.6}}.main-content{padding:32px;min-width:0;position:relative}.main-content:before{content:"";position:absolute;inset:-120px -120px auto;height:380px;background:radial-gradient(circle at 18% 22%,rgba(99,102,241,.22),transparent 38%),radial-gradient(circle at 82% 14%,rgba(168,85,247,.18),transparent 40%),radial-gradient(circle at 50% 80%,rgba(56,189,248,.1),transparent 36%);filter:blur(70px);opacity:.55;pointer-events:none;z-index:0}.main-content>*:not(.toast):not(.sticky-header){position:relative;z-index:1}.stats-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:20px;margin-bottom:32px}.stat-card{background:linear-gradient(135deg,var(--bg-secondary) 0%,var(--bg-tertiary) 100%);border:1px solid var(--border);border-radius:var(--radius);padding:24px;position:relative;overflow:hidden;transition:all .3s cubic-bezier(.4,0,.2,1)}.stat-card:before{content:"";position:absolute;top:0;left:0;right:0;height:3px;background:linear-gradient(90deg,var(--accent) 0%,#a855f7 100%);opacity:0;transition:opacity .3s ease}.stat-card:hover{border-color:var(--accent);transform:translateY(-4px);box-shadow:0 8px 24px #6366f126}.stat-card:hover:before{opacity:1}.stat-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.stat-icon{font-size:24px}.stat-trend{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);background:var(--bg-tertiary);padding:4px 8px;border-radius:20px}.stat-value{font-size:36px;font-weight:700;background:linear-gradient(135deg,var(--text-primary) 0%,var(--text-secondary) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;line-height:1.2}.stat-label{font-size:13px;color:var(--text-muted);margin-top:4px}.card{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden;box-shadow:0 2px 8px #0000001a;transition:all .3s ease}.card:hover{box-shadow:0 4px 16px #00000026}.card-header{display:flex;justify-content:space-between;align-items:center;padding:20px 24px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,var(--bg-tertiary) 0%,transparent 100%)}.card-header h2{font-size:16px;font-weight:600;display:flex;align-items:center;gap:8px}.card-header h2 i{font-size:18px;opacity:.9}.card-body{padding:24px}.card-body.no-padding{padding:0}.table{width:100%;border-collapse:collapse}.table th,.table td{padding:16px 20px;text-align:left;border-bottom:1px solid var(--border)}.table th{background:var(--bg-tertiary);font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted)}.table th.sortable{user-select:none;transition:all .2s ease}.table th.sortable:hover{background:var(--bg-hover);color:var(--text-primary)}.table tbody tr{transition:all .2s ease}.table tbody tr:hover{background:var(--bg-tertiary);transform:scale(1.005)}.table code{padding:4px 8px;border-radius:var(--radius-xs);font-size:12px;font-family:JetBrains Mono,monospace}.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:11px 20px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-tertiary);color:var(--text-primary);font-family:inherit;font-size:14px;font-weight:500;cursor:pointer;text-decoration:none;transition:all .2s cubic-bezier(.4,0,.2,1);position:relative;overflow:hidden}.btn:before{content:"";position:absolute;top:50%;left:50%;width:0;height:0;border-radius:50%;background:#ffffff1a;transform:translate(-50%,-50%);transition:width .6s,height .6s}.btn:active:before{width:300px;height:300px}.btn:hover{background:var(--bg-hover);border-color:var(--border-light);transform:translateY(-1px)}.btn-primary{background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);border:none;color:#fff;box-shadow:0 2px 8px #6366f133}.btn-primary:hover{background:linear-gradient(135deg,#818cf8 0%,var(--accent-hover-gradient-end) 100%);box-shadow:0 6px 20px var(--accent-glow);transform:translateY(-2px)}.btn-primary:active{transform:translateY(0)}.btn-success{background:var(--success);border-color:var(--success);color:#fff}.btn-success:hover{background:var(--success-hover);border-color:var(--success-hover);box-shadow:0 6px 20px #22c55e4d;transform:translateY(-2px)}.btn-warning{background:var(--warning);border-color:var(--warning);color:#000}.btn-warning:hover{background:var(--warning-hover);border-color:var(--warning-hover);box-shadow:0 6px 20px #eab3084d;transform:translateY(-2px)}.btn-danger{background:var(--danger);border-color:var(--danger);color:#fff}.btn-danger:hover{background:var(--danger-hover);border-color:var(--danger-hover);box-shadow:0 6px 20px #ef44444d;transform:translateY(-2px)}.btn-secondary{background:var(--bg-tertiary);border-color:var(--border);color:var(--text-primary)}.btn-secondary:hover{background:var(--bg-hover);border-color:var(--accent);box-shadow:0 4px 12px #6366f126;transform:translateY(-2px)}.btn-sm{padding:8px 14px;font-size:13px}.btn-block{width:100%}.btn-icon{display:inline-flex;align-items:center;justify-content:center;width:36px;height:36px;min-width:36px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-secondary);font-size:16px;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);text-decoration:none;position:relative;overflow:hidden}.btn-icon:after{content:"";position:absolute;inset:0;background:radial-gradient(circle,rgba(99,102,241,.2) 0%,transparent 70%);opacity:0;transition:opacity .3s ease}.btn-icon:hover{background:var(--bg-hover);color:var(--text-primary);border-color:var(--accent);transform:scale(1.1)}.btn-icon:hover:after{opacity:1}.btn-icon:active{transform:scale(.95)}.toggle-switch{position:relative;display:inline-block;width:44px;height:24px;cursor:pointer}.toggle-switch input{opacity:0;width:0;height:0}.toggle-slider{position:absolute;inset:0;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:24px;transition:all var(--transition)}.toggle-slider:before{content:"";position:absolute;width:18px;height:18px;left:2px;top:2px;background:var(--text-muted);border-radius:50%;transition:all var(--transition)}.toggle-switch input:checked+.toggle-slider{background:linear-gradient(135deg,var(--success) 0%,var(--success-hover) 100%);border-color:var(--success);box-shadow:0 2px 8px #22c55e4d}.toggle-switch input:checked+.toggle-slider:before{transform:translate(20px);background:#fff;box-shadow:0 2px 4px #0003}.toggle-switch:hover .toggle-slider{border-color:var(--border-light)}.toggle-switch:hover input:checked+.toggle-slider{box-shadow:0 4px 12px #22c55e66}.page-header{display:flex;justify-content:space-between;align-items:center;gap:24px;margin-bottom:24px}.sticky-header{position:sticky;top:70px;z-index:40;background:transparent;padding:8px 0;margin:0 0 22px;border-bottom:none}.page-header-center{flex:1;display:flex;justify-content:center;min-width:0}.filters-bar{display:inline-flex;flex-direction:row;flex-wrap:nowrap;align-items:center;gap:4px;padding:6px;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);max-width:100%;overflow-x:auto;overflow-y:hidden;scrollbar-width:none}.filters-bar::-webkit-scrollbar{display:none}.filters-bar>*{flex-shrink:0}.filters-bar .search-box{position:relative;display:inline-flex;align-items:center}.filters-bar .search-box input{width:260px;padding:9px 36px;background:transparent;border:none;border-radius:999px;color:var(--text-primary);font-size:13.5px;font-weight:500;box-shadow:none;transition:background .2s ease,box-shadow .2s ease}.filters-bar .search-box input:hover{background:#ffffff0d}.filters-bar .search-box input:focus{outline:none;background:#ffffff12;box-shadow:0 0 0 2px #6366f166}.filters-bar .search-icon{left:12px;color:var(--text-muted)}.filters-bar .search-hint{right:8px;background:#ffffff0f;border:1px solid rgba(255,255,255,.08);color:var(--text-muted)}.filters-bar .filter-select{padding:9px 32px 9px 16px;background-color:transparent;border:none;border-radius:999px;color:var(--text-secondary);font-family:inherit;font-size:13.5px;font-weight:500;white-space:nowrap;cursor:pointer;appearance:none;-webkit-appearance:none;-moz-appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2394a3b8' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 12px center;transition:color .2s ease,background-color .2s ease}.filters-bar .filter-select:hover{color:var(--text-primary);background-color:#ffffff0d}.filters-bar .filter-select:focus{outline:none;color:var(--text-primary);background-color:#ffffff14}.filters-bar .filter-select option{background:#1f1f23;color:var(--text-primary)}.filters-bar .filter-count{color:var(--text-muted);font-size:12.5px;padding:0 12px 0 8px;white-space:nowrap}.filters-bar .btn{background:transparent;border:none;border-radius:999px;padding:9px 16px;font-size:13.5px;font-weight:500;box-shadow:none;color:var(--text-secondary);transition:color .2s ease,background-color .2s ease}.filters-bar .btn:hover{background:#ffffff0d;color:var(--text-primary);transform:none}.page-header-right .btn{border-radius:999px;padding:9px 18px}.search-box{position:relative;display:flex;align-items:center}.search-icon{position:absolute;left:14px;color:var(--text-muted);pointer-events:none}.search-box input{width:280px;padding:10px 14px 10px 40px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:14px;transition:all .2s ease}.search-box input:hover{border-color:var(--border-light)}.search-box input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-glow);background:var(--bg-tertiary)}.search-box input::placeholder{color:var(--text-muted)}.search-hint{position:absolute;right:10px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:4px;padding:2px 6px;font-size:11px;color:var(--text-muted);font-family:inherit}.filter-select{padding:10px 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:14px;cursor:pointer;transition:all var(--transition)}.filter-select:focus{outline:none;border-color:var(--accent)}.results-count{display:inline-flex;align-items:center;padding:9px 18px;color:var(--text-secondary);font-size:13.5px;font-weight:500;white-space:nowrap;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur)}.users-table-card{overflow:visible}.table-wrapper{overflow-x:auto}.user-row{transition:all var(--transition)}.user-row.is-selected{background:var(--accent-glow)}.user-select-cell{width:42px;min-width:42px;text-align:center}.user-select-checkbox{width:16px;height:16px;accent-color:var(--accent);cursor:pointer}.bulk-delete-btn{margin-left:8px;white-space:nowrap;border:none;border-radius:999px;padding:9px 18px;background:linear-gradient(135deg,var(--danger) 0%,var(--danger-hover) 100%);color:#fff;box-shadow:0 2px 8px #ef444433}.bulk-delete-btn:hover{background:linear-gradient(135deg,#f87171 0%,var(--danger) 100%);box-shadow:0 6px 20px #ef444459;transform:translateY(-2px)}.bulk-delete-btn:active{transform:translateY(0)}.bulk-delete-btn[hidden]{display:none!important}.user-info{display:flex;flex-direction:column;gap:4px}.user-id{font-weight:600;color:var(--text-primary)}.user-name{font-size:12px;color:var(--text-muted)}.plan-badge{display:inline-block;padding:4px 12px;border-radius:20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px}.plan-standart{background:var(--bg-tertiary);color:var(--text-secondary);border:1px solid var(--border)}.plan-premium{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}.traffic-info{display:flex;align-items:center;gap:8px;font-size:13px}.traffic-bar{width:60px;height:4px;background:var(--bg-tertiary);border-radius:2px;overflow:hidden}.traffic-fill{height:100%;background:var(--accent);border-radius:2px;transition:width .3s ease}.traffic-limit{color:var(--text-muted);font-size:12px}.traffic-unlimited{color:var(--success);font-weight:600}.action-buttons{display:flex;gap:8px}.dashboard-grid{display:grid;grid-template-columns:1fr 320px;gap:24px}.dashboard-main{display:flex;flex-direction:column;gap:24px}.dashboard-sidebar{display:flex;flex-direction:column;gap:16px}.node-cell{display:flex;flex-direction:column;gap:4px}.node-name-row{display:inline-flex;align-items:center;gap:4px}.node-name-row strong{display:inline}.node-ip{font-size:12px;color:var(--text-muted)}.status-indicator{display:inline-flex;align-items:center;gap:8px;font-size:13px;font-weight:500}.status-dot{width:8px;height:8px;border-radius:50%}.status-online .status-dot{background:var(--success);box-shadow:0 0 12px var(--success),0 0 20px #22c55e4d}.status-online{color:var(--success);font-weight:500}.status-offline .status-dot{background:var(--text-muted)}.status-offline{color:var(--text-muted)}.status-error .status-dot{background:var(--danger);box-shadow:0 0 12px var(--danger)}.status-error{color:var(--danger);font-weight:500}.status-syncing .status-dot{background:var(--warning);animation:pulse 1s infinite;box-shadow:0 0 12px var(--warning)}.status-syncing{color:var(--warning);font-weight:500}.node-actions{display:flex;gap:8px}.quick-actions{display:flex;flex-direction:column;gap:8px}.quick-action{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);text-decoration:none;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s cubic-bezier(.4,0,.2,1);font-family:inherit;width:100%;text-align:left;position:relative;overflow:hidden}.quick-action:before{content:"";position:absolute;top:0;left:-100%;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(99,102,241,.1),transparent);transition:left .5s ease}.quick-action:hover:before{left:100%}.quick-action:hover{background:var(--bg-hover);border-color:var(--accent);transform:translate(4px)}.quick-action:active{transform:translate(2px) scale(.98)}.qa-icon{font-size:18px;width:24px;text-align:center}.system-info{display:flex;flex-direction:column;gap:12px}.sys-item{display:flex;justify-content:space-between;align-items:center;padding:10px 0;border-bottom:1px solid var(--border)}.sys-item:last-child{border-bottom:none}.sys-label{color:var(--text-muted);font-size:13px}.sys-value{font-weight:500;font-size:13px}.logs-container{max-height:300px;overflow-y:auto;overflow-x:hidden;font-family:JetBrains Mono,monospace;font-size:12px;background:var(--bg-primary)}.log-line{padding:8px 16px;border-bottom:1px solid var(--border);color:var(--text-secondary);white-space:pre-wrap;word-break:break-all;overflow-wrap:anywhere}.log-line:hover{background:var(--bg-tertiary)}.log-error{color:var(--danger)}.log-warn{color:var(--warning)}.log-success{color:var(--success)}.logs-empty,.logs-loading,.logs-error{padding:40px;text-align:center;color:var(--text-muted)}.logs-search-wrap{display:flex;align-items:center;gap:6px}.logs-search-input{background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;padding:7px 12px;width:180px;outline:none;transition:all .2s ease}.logs-search-input:hover{border-color:var(--border-light)}.logs-search-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow);background:var(--bg-tertiary);width:220px}.logs-search-input::placeholder{color:var(--text-muted)}.logs-search-count{font-size:11px;color:var(--text-muted);white-space:nowrap;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:10px;padding:3px 8px;min-width:20px;text-align:center}.logs-search-count:empty{display:none}.logs-search-clear{display:none;align-items:center;justify-content:center;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px;padding:4px;border-radius:4px;transition:all .15s ease;margin-left:-4px}.logs-search-clear:hover{background:var(--bg-tertiary);color:var(--text-primary)}.pagination{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:24px}.page-btn{display:inline-flex;align-items:center;justify-content:center;min-width:40px;height:40px;padding:0 14px;background:var(--bg-secondary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);text-decoration:none;font-weight:500;transition:all .2s cubic-bezier(.4,0,.2,1)}.page-btn:hover{background:var(--bg-hover);border-color:var(--accent);transform:translateY(-2px)}.page-btn.active{background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);border-color:var(--accent);color:#fff;box-shadow:0 4px 12px var(--accent-glow);transform:scale(1.05)}.page-btn:active{transform:scale(.95)}.page-dots{color:var(--text-muted);padding:0 8px}.toast{position:fixed;bottom:24px;right:24px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius);padding:16px 24px;font-weight:500;box-shadow:0 8px 32px #0006;transform:translateY(150px) scale(.9);opacity:0;transition:all .4s cubic-bezier(.4,0,.2,1);z-index:1000;backdrop-filter:blur(12px);min-width:280px}.toast.show{transform:translateY(0) scale(1);opacity:1}.toast.error{border-color:var(--danger);background:linear-gradient(135deg,var(--danger-bg) 0%,rgba(239,68,68,.05) 100%);color:var(--danger)}.toast.success{border-color:var(--success);background:linear-gradient(135deg,var(--success-bg) 0%,rgba(34,197,94,.05) 100%);color:var(--success)}.form-group{margin-bottom:20px;min-width:0}.form-group label:not(.checkbox-label){display:block;margin-bottom:8px;font-size:14px;font-weight:500;color:var(--text-secondary)}.form-group input:not([type=checkbox]):not([type=radio]),.form-group select,.form-group textarea{width:100%;padding:12px 16px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-family:inherit;font-size:14px;transition:all .2s ease}.form-group input:not([type=checkbox]):not([type=radio]):focus,.form-group select:focus,.form-group textarea:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 4px var(--accent-glow);background:var(--bg-secondary);transform:translateY(-1px)}.form-group input:not([type=checkbox]):not([type=radio]):hover,.form-group select:hover,.form-group textarea:hover{border-color:var(--border-light)}.form-group small{display:block;margin-top:8px;font-size:12px;color:var(--text-muted)}.form-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px}.form-row-3{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,2fr) minmax(0,1fr);gap:16px}.form-section{margin-top:32px;padding-top:32px;border-top:1px solid var(--border)}.form-section h3{font-size:15px;font-weight:600;margin-bottom:8px;color:var(--text-secondary)}.section-hint{display:block;color:var(--text-muted);font-size:12px;margin-bottom:20px;line-height:1.5}.docs-hint-link{display:inline-block;margin-right:6px;color:var(--accent);text-decoration:none;font-weight:700}.docs-hint-link:hover,.docs-hint-link:focus{text-decoration:underline}.optional-fields-wrap{margin-top:12px}.form-actions{display:flex;gap:12px;margin-top:32px;padding-top:24px;border-top:1px solid var(--border)}.checkbox-label{display:flex;align-items:center;gap:8px;cursor:pointer;font-weight:500;line-height:1.35;font-size:14px;color:var(--text-secondary);margin-bottom:0;vertical-align:middle}.form-group label.checkbox-label{display:flex;margin-bottom:0}.checkbox-label>input[type=checkbox],.checkbox-label>input[type=radio]{width:20px;height:20px;margin:0;padding:0;flex:0 0 auto;accent-color:var(--accent);background:transparent;border:0;border-radius:0;box-shadow:none;transform:none}.checkbox-label>input[type=checkbox]:hover,.checkbox-label>input[type=checkbox]:focus,.checkbox-label>input[type=radio]:hover,.checkbox-label>input[type=radio]:focus{background:transparent;box-shadow:none;transform:none}.checkbox-label>.checkbox-text,.checkbox-label>span{line-height:1.35}.onboarding-page{min-height:100vh;padding:32px 16px 48px;background:radial-gradient(circle at top,rgba(99,102,241,.16) 0%,transparent 32%),linear-gradient(135deg,#09090b,#0f1020 48%,#111113);position:relative;overflow-x:hidden;overflow-y:auto}.onboarding-page:before,.onboarding-page:after{content:"";position:fixed;inset:0;pointer-events:none;z-index:0}.onboarding-page:before{inset:-35vmax;background:conic-gradient(from 0deg,#6366f12e,#a855f724,#0ea5e91f,#6366f12e);filter:blur(96px);opacity:.9;transform-origin:center;animation:onboardingRotate 42s linear infinite}.onboarding-page:after{background:radial-gradient(circle at 18% 18%,rgba(99,102,241,.18),transparent 26%),radial-gradient(circle at 82% 16%,rgba(168,85,247,.16),transparent 28%),radial-gradient(circle at 50% 82%,rgba(56,189,248,.12),transparent 24%);animation:onboardingFloat 18s ease-in-out infinite alternate}@keyframes onboardingRotate{to{transform:rotate(360deg)}}@keyframes onboardingFloat{to{transform:translate3d(0,18px,0) scale(1.04)}}.onboarding-shell{position:relative;z-index:1;width:min(100%,760px);margin:0 auto}.onboarding-card{position:relative;background:#111113db;border:1px solid rgba(63,63,70,.78);border-radius:24px;padding:clamp(24px,4vw,46px);box-shadow:0 28px 80px #00000080;backdrop-filter:blur(20px);overflow:hidden}.onboarding-card:before{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.04) 0%,transparent 28%);pointer-events:none}.onboarding-header{text-align:center;margin-bottom:32px}.onboarding-header .logo-icon{font-size:48px;display:inline-block;filter:drop-shadow(0 0 18px var(--accent-glow))}.onboarding-header h1{margin-top:16px;font-size:clamp(28px,4vw,36px);font-weight:700;letter-spacing:-.03em;background:linear-gradient(135deg,var(--text-primary) 0%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.onboarding-brand{display:inline-block;margin-top:8px;font-size:13px;font-weight:600;text-decoration:none;background:linear-gradient(135deg,var(--accent) 0%,#a855f7 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.onboarding-subtitle{max-width:620px;margin:12px auto 0;color:var(--text-secondary);font-size:15px;line-height:1.65}.onboarding-lang-switch{display:inline-flex;align-items:center;gap:8px;padding:6px;margin:0 auto 28px;background:#ffffff0a;border:1px solid var(--border);border-radius:999px}.onboarding-lang-switch a{min-width:48px;padding:7px 14px;border-radius:999px;color:var(--text-secondary);font-size:12px;font-weight:600;text-decoration:none;transition:all .2s ease}.onboarding-lang-switch a:hover{color:var(--text-primary);background:#ffffff0f}.onboarding-lang-switch a.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 10px 24px #6366f140}.onboarding-stack>*+*{margin-top:18px}.onboarding-section{position:relative;border:1px solid rgba(63,63,70,.9);border-radius:18px;padding:20px;background:#18181bbd}.onboarding-section-title{display:flex;align-items:center;gap:10px;margin-bottom:8px;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--text-secondary)}.onboarding-section-title i{font-size:16px;color:var(--accent)}.onboarding-section-copy{color:var(--text-secondary);font-size:13px;line-height:1.65}.onboarding-feature-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;margin-bottom:24px}.onboarding-feature-card{padding:16px;border-radius:16px;border:1px solid rgba(63,63,70,.9);background:#18181bb8}.onboarding-feature-card strong{display:block;margin-bottom:6px;font-size:13px;color:var(--text-primary)}.onboarding-feature-card span{color:var(--text-secondary);font-size:12px;line-height:1.6}.onboarding-actions{display:flex;gap:12px;margin-top:24px}.onboarding-actions .btn{flex:1 1 0;padding:14px 20px;font-size:15px;min-height:48px}.onboarding-progress-meta{text-align:center;margin-bottom:22px}.onboarding-progress-meta p{color:var(--text-secondary);font-size:14px}@media(max-width:800px){.onboarding-feature-grid{grid-template-columns:1fr}}@media(max-width:640px){.onboarding-page{padding:18px 12px 28px}.onboarding-card{border-radius:20px;padding:20px 18px}.onboarding-header{margin-bottom:24px}.onboarding-actions{flex-direction:column}}.login-page{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:32px 16px 48px;background:radial-gradient(circle at top,rgba(99,102,241,.16) 0%,transparent 32%),linear-gradient(135deg,#09090b,#0f1020 48%,#111113);position:relative;overflow-x:hidden;overflow-y:auto}.login-page:before,.login-page:after{content:"";position:fixed;inset:0;pointer-events:none;z-index:0}.login-page:before{inset:-35vmax;background:conic-gradient(from 0deg,#6366f12e,#a855f724,#0ea5e91f,#6366f12e);filter:blur(96px);opacity:.9;transform-origin:center;animation:onboardingRotate 42s linear infinite}.login-page:after{background:radial-gradient(circle at 18% 18%,rgba(99,102,241,.18),transparent 26%),radial-gradient(circle at 82% 16%,rgba(168,85,247,.16),transparent 28%),radial-gradient(circle at 50% 82%,rgba(56,189,248,.12),transparent 24%);animation:onboardingFloat 18s ease-in-out infinite alternate}.login-container{width:100%;max-width:440px;padding:20px;position:relative;z-index:1}.login-box{position:relative;background:#111113db;border:1px solid rgba(63,63,70,.78);border-radius:24px;padding:clamp(28px,4vw,48px);box-shadow:0 28px 80px #00000080;backdrop-filter:blur(20px);overflow:hidden;animation:slideUp .5s ease-out}.login-box:before{content:"";position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.04) 0%,transparent 28%);pointer-events:none}.login-box>*{position:relative;z-index:1}@keyframes slideUp{0%{opacity:0;transform:translateY(30px)}to{opacity:1;transform:translateY(0)}}.login-header{text-align:center;margin-bottom:40px}.login-header .logo-icon{font-size:48px;filter:drop-shadow(0 0 20px var(--accent-glow));display:inline-block;animation:pulse 2s infinite}.login-header h1{margin-top:16px;font-size:28px;font-weight:700;background:linear-gradient(135deg,var(--text-primary) 0%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.login-header p{color:var(--text-secondary);margin-top:8px}.login-form .form-group{margin-bottom:24px}.login-form .btn-block{padding:14px 20px;font-size:15px;min-height:48px}.login-container.totp-enrollment{max-width:480px}.totp-qr-section{display:flex;justify-content:center;padding:20px 0}.totp-qr{width:200px;height:200px;border-radius:var(--radius-sm);background:#fafafa;padding:8px;box-shadow:0 4px 16px #0003}.totp-secret{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:14px 16px;margin-bottom:20px}.totp-secret-label{font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px}.totp-secret-value{display:block;font-family:JetBrains Mono,monospace;font-size:13px;color:var(--text-primary);word-break:break-all;line-height:1.5}.totp-secret-hint{display:block;margin-top:8px;font-size:12px;color:var(--text-muted)}@media(max-width:480px){.totp-qr{width:160px;height:160px}.login-container.totp-enrollment .login-box{padding:28px 20px}}.login-lang-switcher{display:flex;align-items:center;gap:8px;padding:6px;margin:0 auto 24px;width:fit-content;background:#ffffff0a;border:1px solid var(--border);border-radius:999px}.login-lang-switcher .lang-btn{min-width:48px;padding:7px 14px;font-size:12px;font-weight:600;color:var(--text-secondary);text-decoration:none;border-radius:999px;background:transparent;text-align:center;transition:all .2s ease}.login-lang-switcher .lang-btn:hover{color:var(--text-primary);background:#ffffff0f}.login-lang-switcher .lang-btn.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 10px 24px #6366f140}.lockout-alert{margin-bottom:16px}.lockout-meta{margin-top:6px;font-size:12px;opacity:.9}.badge{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:20px;font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;border:1px solid transparent;transition:all .2s ease}.badge-success{background:var(--success-bg);color:var(--success);border-color:var(--success)}.badge-warning{background:var(--warning-bg);color:var(--warning);border-color:var(--warning)}.badge-danger{background:var(--danger-bg);color:var(--danger);border-color:var(--danger)}.badge-secondary{background:var(--bg-tertiary);color:var(--text-secondary);border-color:var(--border)}.badge-lg{padding:7px 16px;font-size:12px}.badge:hover{transform:scale(1.05)}.detail-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.detail-item{padding:16px;background:var(--bg-tertiary);border-radius:var(--radius-sm)}.detail-item label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:6px}.detail-item .value{font-size:14px;font-weight:500}.section-title{font-size:14px;font-weight:600;margin:28px 0 16px;color:var(--text-secondary);padding-bottom:12px;border-bottom:1px solid var(--border)}.nodes-list{display:flex;flex-wrap:wrap;gap:8px}.node-badge{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm)}.node-name{font-weight:600}.actions-list{display:flex;flex-direction:column;gap:10px}.input-readonly{width:100%;padding:12px 16px;background:var(--bg-primary);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-secondary);font-family:JetBrains Mono,monospace;font-size:12px}.row{display:flex;gap:24px;margin-bottom:24px}.col{flex:1}.col-4{flex:0 0 33.333%}.col-6{flex:0 0 calc(50% - 12px)}.col-8{flex:0 0 calc(66.666% - 12px)}.empty-state{padding:60px 20px;text-align:center}.empty-icon{font-size:48px;display:block;margin-bottom:16px;opacity:.5}.empty-state p{color:var(--text-muted);margin-bottom:20px}.empty-cell{padding:0!important}.text-center{text-align:center}.text-muted{color:var(--text-muted)}.mt-1{margin-top:8px}.mt-2{margin-top:16px}.mt-3{margin-top:24px}.mb-1{margin-bottom:8px}.hint{font-size:13px;color:var(--text-muted)}.settings-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px}.settings-grid .form-group{margin-bottom:0}.settings-grid input[type=number]{width:100%}.alert{padding:16px 20px;border-radius:var(--radius-sm);margin-bottom:20px;font-weight:500}.alert-error{background:var(--danger-bg);border:1px solid var(--danger);color:var(--danger)}.alert-success{background:var(--success-bg);border:1px solid var(--success);color:var(--success)}.alert-warning{background:var(--warning-bg);border:1px solid var(--warning);color:var(--warning)}.alert-info{background:var(--info-bg);border:1px solid var(--info);color:var(--text-secondary)}.alert-info strong{color:var(--info)}.alert-info code{color:var(--text-primary)}.groups-select{display:flex;flex-wrap:wrap;gap:8px;padding:8px 0}.checkbox-tag{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border:2px solid var(--border);border-radius:20px;cursor:pointer;transition:all .2s ease;font-size:13px;background:var(--bg-tertiary)}.checkbox-tag:hover{background:var(--bg-hover);border-color:var(--border-light);transform:translateY(-1px)}.checkbox-tag input{display:none}.checkbox-tag:has(input:checked){background:var(--accent);border-color:var(--accent);box-shadow:0 4px 12px var(--accent-glow);transform:scale(1.02)}.checkbox-tag:has(input:checked) span{color:#fff;font-weight:600}.checkbox-tag:has(input:checked) .tag-dot{background:#fff!important;box-shadow:0 0 8px #ffffff80}.tag-dot{width:8px;height:8px;border-radius:50%}.group-tag{display:inline-flex;align-items:center;padding:5px 12px;border:1px solid;border-radius:12px;font-size:12px;font-weight:500;margin-right:6px;transition:all .2s ease;cursor:default}.group-tag:hover{transform:scale(1.05);box-shadow:0 2px 8px #0003}.group-tag-sm{display:inline-flex;align-items:center;padding:3px 10px;border:1px solid;border-radius:10px;font-size:11px;font-weight:500;margin-right:4px;transition:all .2s ease;cursor:default}.group-tag-sm:hover{transform:scale(1.08)}.group-more{font-size:11px;color:var(--text-muted)}.groups-list{margin-top:8px}.actions-grid{display:flex;flex-wrap:wrap;gap:10px}.action-btn{display:inline-flex;align-items:center;gap:8px;padding:10px 16px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text-primary);font-size:13px;cursor:pointer;transition:var(--transition)}.action-btn:hover{background:var(--bg-hover);border-color:var(--border-light)}.action-btn:disabled{opacity:.5;cursor:not-allowed}.action-btn.is-disabled{opacity:.5;cursor:not-allowed}.action-btn .action-icon{font-size:16px}.beta-badge{display:inline-flex;align-items:center;justify-content:center;padding:1px 7px;border-radius:999px;background:#f59e0b21;border:1px solid rgba(245,158,11,.35);color:#fbbf24;font-size:10px;font-weight:700;letter-spacing:.5px;vertical-align:middle}.mobile-header,.mobile-overlay,.mobile-nodes-list,.mobile-users-list{display:none}.mobile-menu-btn{display:none;flex-direction:column;justify-content:space-around;width:40px;height:40px;background:transparent;border:none;cursor:pointer;padding:8px}.mobile-menu-btn span{width:24px;height:2px;background:var(--text-primary);border-radius:2px;transition:all .3s ease}@media(max-width:1200px){.stats-grid{grid-template-columns:repeat(2,1fr)}.dashboard-grid,.settings-grid{grid-template-columns:1fr}}@media(max-width:768px){.table-wrapper{display:none!important}.mobile-nodes-list,.mobile-users-list{display:flex!important}.mobile-header{display:flex;align-items:center;padding:12px 16px;background:var(--bg-secondary);border-bottom:1px solid var(--border);position:fixed;top:0;left:0;right:0;height:60px;z-index:200}.mobile-logo{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);display:flex;align-items:center;gap:8px;pointer-events:none}.mobile-logo .logo-icon,.mobile-status{display:none}.mobile-logo .logo-text{font-size:16px;font-weight:700;letter-spacing:.5px;background:linear-gradient(135deg,var(--accent) 0%,#a855f7 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}.mobile-menu-btn{display:flex}.mobile-overlay{display:block;position:fixed;inset:0;background:#0009;backdrop-filter:blur(4px);opacity:0;visibility:hidden;transition:all .3s ease;z-index:150}.mobile-overlay.active{opacity:1;visibility:visible}.sidebar{position:fixed;left:-280px;top:0;width:280px;height:100vh;z-index:200;transition:left .3s ease;box-shadow:4px 0 24px #0000004d}.sidebar.active{left:0}.content{margin-left:0;padding-top:60px;width:100%}.topbar{position:relative;padding:16px}.topbar h1{font-size:18px}.topbar-right .status-text{display:none}.main-content{padding:16px}.sticky-header{position:relative;top:auto;padding:4px 0;margin:0 0 14px}.toast{left:16px;right:16px;bottom:16px;min-width:0}.stats-grid{grid-template-columns:repeat(2,1fr);gap:12px}.stat-card{padding:16px}.stat-value{font-size:24px}.form-row,.form-row-3,.detail-grid{grid-template-columns:1fr}.row{flex-direction:column;gap:16px}.col-4,.col-6,.col-8{flex:1}.page-header{flex-direction:column;gap:10px;align-items:stretch}.page-header-left,.page-header-right{width:100%;display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.page-header-center{width:100%;justify-content:flex-start}.filters-bar{max-width:100%;gap:2px}.filters-bar .search-box input{width:180px;font-size:13px}.filters-bar .filter-select{font-size:13px;padding:8px 26px 8px 12px}.filters-bar .search-hint{display:none}.page-header-right .btn{flex:0 1 auto;white-space:nowrap}.page-fab-mobile{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:100;width:56px;height:56px;padding:0;gap:0;border-radius:50%;box-shadow:0 12px 28px #6366f173,0 4px 12px #00000073}.page-fab-mobile i{font-size:24px}.page-fab-mobile .fab-label{display:none}.page-fab-mobile:active{transform:scale(.94)}.page-header-right:has(.page-fab-mobile){display:contents}.logs-search-wrap{flex:1;min-width:0}.logs-search-input{width:100%!important}.logs-search-input:focus{width:100%!important}.table-wrapper{display:none}.mobile-nodes-list,.mobile-users-list{display:flex;flex-direction:column;gap:12px;padding:16px}.mobile-node-card,.mobile-user-card{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius-sm);padding:16px;transition:all .2s ease}.mobile-node-card:active,.mobile-user-card:active{transform:scale(.98);background:var(--bg-hover)}.mobile-node-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px;gap:12px}.mobile-user-title-wrap{display:flex;align-items:flex-start;gap:10px;min-width:0}.mobile-user-card.is-selected{border-color:var(--accent);background:#6366f11f}.mobile-user-select{margin-top:2px;flex:0 0 auto}.mobile-node-title{flex:1;min-width:0}.mobile-node-title strong{font-size:15px;display:block;margin-bottom:4px}.mobile-node-title code{font-size:12px;color:var(--text-muted)}.mobile-node-stats{display:grid;grid-template-columns:repeat(2,1fr);gap:10px;margin-bottom:12px}.mobile-stat-item{display:flex;flex-direction:column;gap:4px}.mobile-stat-label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px}.mobile-stat-value{font-size:14px;font-weight:500}.mobile-node-actions{display:flex;gap:8px;padding-top:12px;border-top:1px solid var(--border)}.mobile-node-actions>.btn{flex:1 1 0;min-width:0;padding:10px 0;gap:0}.mobile-node-actions>.btn i{font-size:18px}.logs-container{max-height:200px;font-size:11px}.log-line{padding:6px 12px;word-break:break-word;white-space:pre-wrap;font-size:10px}.form-actions .btn,.actions-list .btn,.login-form .btn{width:100%;justify-content:center}.btn-sm{padding:10px 16px}.action-buttons{justify-content:center}.card{border-radius:var(--radius)}.card-header{padding:16px;flex-wrap:wrap}.card-body{padding:16px}.dashboard-grid{grid-template-columns:1fr;gap:16px}.dashboard-main,.dashboard-sidebar{width:100%;margin-top:0}.nodes-table-wrapper{overflow-x:auto;-webkit-overflow-scrolling:touch}.quick-action{padding:16px;font-size:15px}.pagination{flex-wrap:wrap}.page-btn{min-width:36px;height:36px;padding:0 10px;font-size:13px}.form-group input,.form-group select,.form-group textarea{font-size:16px}.login-container{padding:16px}.login-box{padding:32px 24px}}@media(max-width:480px){.stats-grid{grid-template-columns:1fr}.stat-card{padding:16px}.stat-value{font-size:24px}.stat-label{font-size:12px}.table th,.table td{padding:10px 8px;font-size:12px}.btn-icon{width:32px;height:32px;font-size:14px}.action-buttons{gap:6px}}.lang-switcher{display:flex;align-items:center;gap:4px;margin-bottom:12px;padding:4px;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.lang-btn{flex:1;padding:7px 12px;text-align:center;font-size:12px;font-weight:600;letter-spacing:.04em;color:var(--text-secondary);text-decoration:none;background:transparent;border-radius:999px;transition:color .2s ease,background .2s ease,transform .15s ease}.lang-btn:hover{color:var(--text-primary);background:#ffffff0d}.lang-btn.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 6px 20px #6366f14d,0 1px #ffffff2e inset,0 -1px #00000040 inset}.lang-btn:active{transform:scale(.97)}.radio-group{display:flex;gap:16px;flex-wrap:wrap}.form-group .datetime-input{color-scheme:dark;padding-right:44px}.form-group .datetime-input::-webkit-calendar-picker-indicator{opacity:1;cursor:pointer;border-radius:var(--radius-xs);padding:4px;background-color:var(--bg-elevated);border:1px solid var(--border-light);filter:invert(94%) sepia(7%) saturate(154%) hue-rotate(192deg) brightness(91%) contrast(96%);transition:all var(--transition)}.form-group .datetime-input::-webkit-calendar-picker-indicator:hover{background-color:var(--bg-hover);border-color:var(--accent)}.form-group .datetime-input::-webkit-calendar-picker-indicator:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.ti{font-size:1em;line-height:1;vertical-align:-.125em}@keyframes ti-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.ti-loader-2{display:inline-block;animation:ti-spin 1s linear infinite}@keyframes fadeUp{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.main-content{animation:fadeIn .2s ease-out}.stat-card{animation:fadeUp .3s ease-out both}.stat-card:nth-child(1){animation-delay:.03s}.stat-card:nth-child(2){animation-delay:.07s}.stat-card:nth-child(3){animation-delay:.11s}.stat-card:nth-child(4){animation-delay:.15s}.card{animation:fadeUp .25s ease-out both}.dashboard-main>.card:nth-child(1){animation-delay:.05s}.dashboard-main>.card:nth-child(2){animation-delay:.1s}.dashboard-sidebar>.card:nth-child(1){animation-delay:.08s}.dashboard-sidebar>.card:nth-child(2){animation-delay:.13s}.table tbody tr{animation:fadeIn .2s ease-out both}.table tbody tr:nth-child(1){animation-delay:.04s}.table tbody tr:nth-child(2){animation-delay:.07s}.table tbody tr:nth-child(3){animation-delay:.1s}.table tbody tr:nth-child(4){animation-delay:.13s}.table tbody tr:nth-child(5){animation-delay:.16s}.table tbody tr:nth-child(n+6){animation-delay:.18s}.topbar{animation:fadeIn .15s ease-out}.page-header{animation:fadeIn .2s ease-out}.quick-action{animation:fadeUp .25s ease-out both}.quick-actions .quick-action:nth-child(1){animation-delay:.05s}.quick-actions .quick-action:nth-child(2){animation-delay:.08s}.quick-actions .quick-action:nth-child(3){animation-delay:.11s}.quick-actions .quick-action:nth-child(4){animation-delay:.14s}.quick-actions .quick-action:nth-child(5){animation-delay:.17s}@media(prefers-reduced-motion:reduce){.main-content,.stat-card,.card,.table tbody tr,.nav-menu li,.topbar,.page-header,.quick-action{animation:none}}.cascade-role-picker{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:10px;margin-top:10px}.role-card{display:flex;align-items:center;gap:11px;padding:12px 14px;border:1.5px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;transition:all .18s;background:var(--bg-tertiary, rgba(255, 255, 255, .02));user-select:none}.role-card:hover{border-color:#ffffff2e;background:#ffffff0a}.role-card.active{border-color:var(--accent, #6366f1);background:#6366f114}.role-card-icon{width:34px;height:34px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:17px;flex-shrink:0}.role-card-icon.role-standalone{background:#4755694d;color:#94a3b8}.role-card-icon.role-portal{background:#6366f133;color:#a5b4fc}.role-card-icon.role-bridge{background:#f59e0b33;color:#fcd34d}.role-card-icon.role-relay{background:#8b5cf633;color:#c4b5fd}.role-card.active .role-card-icon.role-standalone{background:#47556980}.role-card.active .role-card-icon.role-portal{background:#6366f14d}.role-card.active .role-card-icon.role-bridge{background:#f59e0b4d}.role-card.active .role-card-icon.role-relay{background:#8b5cf64d}.role-card-body{display:flex;flex-direction:column;gap:2px;min-width:0}.role-card-name{font-size:12px;font-weight:600;color:var(--text-primary, #e2e8f0);white-space:nowrap}.role-card-name small{font-weight:400;color:var(--text-muted, #64748b);font-size:10px}.role-card-desc{font-size:10px;color:var(--text-muted, #64748b);line-height:1.3;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}.cascade-links-table td{vertical-align:middle}.cascade-node-cell{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.badge-this{display:inline-block;padding:1px 7px;border-radius:6px;font-size:9px;font-weight:700;letter-spacing:.4px;background:#6366f12e;color:#a5b4fc;border:1px solid rgba(99,102,241,.3);text-transform:uppercase}.cascade-link-actions{display:flex;align-items:center;gap:5px}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.btn:focus-visible,.btn-icon:focus-visible,a:focus-visible{outline:2px solid var(--accent);outline-offset:2px}h1,h2,h3,h4,h5,h6{line-height:1.2;letter-spacing:-.02em}p{line-height:1.6}.ti{vertical-align:middle;position:relative;top:-1px}.table th{color:var(--text-secondary);border-bottom:2px solid var(--border)}.table tbody tr:last-child td{border-bottom:none}.table tbody tr:hover{background:var(--bg-hover);transform:none}.badge{font-weight:600;letter-spacing:.02em}.badge:hover{transform:none;filter:brightness(1.1)}.form-group input:not([type=checkbox]):not([type=radio]):focus,.form-group select:focus,.form-group textarea:focus{transform:none}.form-group label:not(.checkbox-label){color:var(--text-primary)}.card{box-shadow:0 4px 12px #00000026}.card-header{background:var(--bg-secondary)}.stat-card{box-shadow:0 4px 12px #00000026}.btn{letter-spacing:.01em}.btn:active{transform:scale(.98)}.btn-icon:active{transform:scale(.92)}.nav-menu a{border-radius:var(--radius-sm)}.nav-menu a:active{transform:scale(.98)}.btn,.btn-icon,.nav-menu a,.card,.stat-card,.action-btn{transition:all .2s cubic-bezier(.4,0,.2,1)}.port-chip-list{display:flex;flex-wrap:wrap;gap:4px;align-items:center}.port-chip{display:inline-block;padding:2px 8px;border-radius:10px;background:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);font-family:JetBrains Mono,Consolas,Monaco,monospace;font-size:12px;line-height:1.4;cursor:help}.port-chip-extra{background:var(--accent-glow, rgba(99, 102, 241, .12));border-color:var(--accent, #6366f1);color:var(--accent, #6366f1)}.node-meta-strip{display:flex;flex-wrap:wrap;align-items:center;column-gap:4px;row-gap:3px;margin-top:5px;max-width:320px}.node-meta-strip-mobile{max-width:100%;margin:5px 0 2px}.node-meta-badge{display:inline-flex;align-items:center;gap:3px;min-height:18px;max-width:100%;padding:2px 6px;border-radius:4px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-muted);font-size:11px;font-weight:600;line-height:1.25;letter-spacing:0;white-space:nowrap}.node-meta-badge i{flex:0 0 auto;font-size:11px;opacity:.9}.node-meta-transport i{color:#7dd3fc}.node-meta-reality i,.node-meta-tls i{color:#86efac}.node-meta-none i,.node-meta-virtual i{color:var(--text-muted);opacity:.75}.node-meta-panel i,.node-meta-acme i,.node-meta-manual i,.node-meta-self-signed i,.node-meta-files i{color:#fcd34d}.node-domain-line{display:inline-flex;align-items:center;gap:4px;max-width:280px;margin-top:4px;color:var(--text-muted);font-size:12px;line-height:1.35;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}.node-domain-line i{flex:0 0 auto;font-size:12px;opacity:.8}:root{--glass-bg: rgba(24, 24, 27, .62);--glass-bg-strong: rgba(24, 24, 27, .78);--glass-bg-soft: rgba(255, 255, 255, .025);--glass-border: rgba(255, 255, 255, .08);--glass-border-strong: rgba(255, 255, 255, .14);--glass-highlight: rgba(255, 255, 255, .06);--glass-shadow: 0 1px 0 rgba(255, 255, 255, .04) inset, 0 -1px 0 rgba(0, 0, 0, .35) inset, 0 8px 32px rgba(0, 0, 0, .32);--glass-shadow-lg: 0 1px 0 rgba(255, 255, 255, .06) inset, 0 -1px 0 rgba(0, 0, 0, .4) inset, 0 24px 60px rgba(0, 0, 0, .45);--glass-blur: saturate(180%) blur(22px);--glass-blur-sm: saturate(160%) blur(14px)}.glass{position:relative;background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow);border-radius:var(--radius);overflow:hidden}.glass:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.06) 0%,rgba(255,255,255,0) 24%,transparent 100%);border-radius:inherit}.card-glass{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow)}.glass-modal{position:fixed;inset:0;z-index:1000;background:#08080a8c;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);align-items:center;justify-content:center}.glass-modal-content{background:var(--glass-bg-strong);border:1px solid var(--glass-border-strong);border-radius:var(--radius);padding:1.5rem;width:100%;max-width:520px;max-height:90vh;overflow-y:auto;backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow-lg)}.topbar{background:#111113b8;border-bottom:1px solid var(--glass-border);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur)}.mobile-header{backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.sidebar{background:linear-gradient(180deg,#111113f5,#0f101bf5);border-right:1px solid var(--glass-border)}.toast{background:#1f1f23c7;backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);border:1px solid var(--glass-border-strong);box-shadow:var(--glass-shadow-lg)}.settings-tabbar{position:sticky;top:73px;z-index:30;margin:0 0 22px;padding:0;background:transparent}.settings-tabs{display:inline-flex;align-items:center;gap:4px;padding:6px;background:var(--glass-bg-strong);border:1px solid var(--glass-border);border-radius:999px;box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);overflow-x:auto;overflow-y:hidden;max-width:100%;scrollbar-width:none}.settings-tabs::-webkit-scrollbar{display:none}.settings-tab{display:inline-flex;align-items:center;gap:8px;padding:9px 18px;background:transparent;border:none;color:var(--text-secondary);cursor:pointer;font-family:inherit;font-size:13.5px;font-weight:500;white-space:nowrap;border-radius:999px;transition:color .2s ease,background .2s ease,transform .15s ease;position:relative}.settings-tab i{font-size:16px;opacity:.85}.settings-tab:hover{color:var(--text-primary);background:#ffffff0d}.settings-tab.active{color:#fff;background:linear-gradient(135deg,var(--accent) 0%,var(--accent-gradient-end) 100%);box-shadow:0 6px 20px #6366f159,0 1px #ffffff2e inset,0 -1px #00000040 inset}.settings-tab.active i{opacity:1}.settings-tab:active{transform:scale(.97)}.settings-tab-caption{display:flex;align-items:center;gap:12px;margin-bottom:18px;padding:14px 18px;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:var(--radius);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.settings-tab-caption .stc-icon{flex-shrink:0;width:38px;height:38px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);font-size:19px;background:linear-gradient(135deg,#6366f138,#a855f729);color:#c7d2fe;border:1px solid rgba(99,102,241,.25)}.settings-tab-caption .stc-text{min-width:0}.settings-tab-caption .stc-title{font-size:15px;font-weight:600;color:var(--text-primary);letter-spacing:-.01em;margin:0}.settings-tab-caption .stc-desc{font-size:12.5px;color:var(--text-muted);margin:2px 0 0;line-height:1.5}.settings-tab-caption .stc-version{flex-shrink:0;margin-left:auto;align-self:center;display:inline-flex;align-items:center;gap:5px;padding:5px 11px;border-radius:999px;font-size:12px;font-weight:600;letter-spacing:-.01em;color:#c7d2fe;background:linear-gradient(135deg,#6366f12e,#a855f71f);border:1px solid rgba(99,102,241,.28);white-space:nowrap}.settings-tab-caption .stc-version i{font-size:13px;opacity:.9}.settings-page .card,.settings-page .card-glass{background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:var(--radius);box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm);position:relative;overflow:hidden}.settings-page .card:before{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.05) 0%,rgba(255,255,255,0) 30%,transparent 100%);border-radius:inherit}.settings-page .card>*{position:relative;z-index:1}.settings-page .card-header{background:transparent;border-bottom:1px solid var(--glass-border);padding:16px 20px;gap:12px}.settings-page .card-header h2{font-size:14.5px;letter-spacing:-.005em;display:flex;align-items:center;gap:10px}.settings-page .card-header h2>i:first-child{width:28px;height:28px;display:inline-flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);font-size:15px;background:linear-gradient(135deg,#6366f12e,#a855f71f);color:#c7d2fe;border:1px solid rgba(99,102,241,.2);flex-shrink:0}.settings-page .card-body{padding:18px 20px}.settings-page .card:hover{border-color:var(--glass-border-strong);box-shadow:0 1px #ffffff0f inset,0 -1px #0006 inset,0 14px 44px #0000006b}.settings-list .setting-item{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 0;border-bottom:1px dashed var(--glass-border)}.settings-list .setting-item:last-child{border-bottom:none}.settings-list .setting-item label{font-size:12.5px;color:var(--text-muted);margin:0;font-weight:500;text-transform:uppercase;letter-spacing:.04em}.settings-list .setting-item span,.settings-list .setting-item code{font-size:13px;font-weight:500;color:var(--text-primary);text-align:right}.settings-list .setting-item code{background:#ffffff0a;border:1px solid var(--glass-border);border-radius:var(--radius-xs);padding:3px 7px;font-family:JetBrains Mono,monospace;font-size:11.5px}.settings-page .form-group small.hint,.settings-page small.hint{color:var(--text-muted);line-height:1.55;font-size:12px}.settings-page .form-group input:not([type=checkbox]):not([type=radio]),.settings-page .form-group select,.settings-page .form-group textarea{background:#0a0a0b8c;border:1px solid var(--glass-border);border-radius:var(--radius-sm);padding:11px 14px;transition:all .18s ease}.settings-page .form-group input:not([type=checkbox]):not([type=radio]):focus,.settings-page .form-group select:focus,.settings-page .form-group textarea:focus{background:#0a0a0bcc;border-color:#6366f18c;box-shadow:0 0 0 4px #6366f12e}.settings-save-bar{display:flex;align-items:center;justify-content:flex-end;gap:10px;margin-top:18px}.settings-stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px;margin-bottom:18px}.settings-stat{display:flex;align-items:center;gap:12px;padding:14px 16px;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:var(--radius);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm);transition:border-color .2s ease,transform .15s ease}.settings-stat:hover{border-color:var(--glass-border-strong);transform:translateY(-1px)}.settings-stat-icon{flex-shrink:0;width:38px;height:38px;display:flex;align-items:center;justify-content:center;border-radius:var(--radius-sm);font-size:18px;background:linear-gradient(135deg,#6366f133,#a855f724);color:#c7d2fe;border:1px solid rgba(99,102,241,.22)}.settings-stat-icon.success{background:linear-gradient(135deg,#22c55e38,#22c55e1a);color:#86efac;border-color:#22c55e47}.settings-stat-icon.warning{background:linear-gradient(135deg,#eab30838,#eab3081a);color:#fde047;border-color:#eab30847}.settings-stat-icon.info{background:linear-gradient(135deg,#38bdf838,#38bdf81a);color:#7dd3fc;border-color:#38bdf847}.settings-stat-content{min-width:0;flex:1}.settings-stat-label{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:2px;font-weight:600}.settings-stat-value{font-size:14px;font-weight:600;color:var(--text-primary);line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.settings-stat-value code{font-family:JetBrains Mono,monospace;font-size:12px;background:#ffffff0a;border:1px solid var(--glass-border);border-radius:var(--radius-xs);padding:2px 6px}.settings-page .card.danger-zone{border-color:#ef444452;background:linear-gradient(180deg,#ef44440f,#18181b99)}.settings-page .card.danger-zone .card-header{border-bottom-color:#ef444433}.settings-page .card.danger-zone .card-header h2{color:#fca5a5}.settings-page .card.danger-zone .card-header h2>i:first-child{background:linear-gradient(135deg,#ef44443d,#ef44441f);color:#fca5a5;border-color:#ef444452}.settings-page [id$=Modal]>div,.glass-modal{background:#141417db!important;border:1px solid var(--glass-border-strong)!important;border-radius:var(--radius)!important;backdrop-filter:var(--glass-blur);-webkit-backdrop-filter:var(--glass-blur);box-shadow:var(--glass-shadow-lg)!important}[id$=Modal][style*=flex]{backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px)}.settings-page pre,.settings-page code:not(.setting-item code):not(.table code){background:#00000052;border:1px solid var(--glass-border);border-radius:var(--radius-sm)}.settings-divider{height:1px;margin:18px 0;background:linear-gradient(90deg,transparent,var(--glass-border-strong),transparent);border:none}.settings-subsection-title{display:flex;align-items:center;gap:8px;margin:18px 0 12px;font-size:13px;font-weight:600;color:var(--text-secondary);text-transform:uppercase;letter-spacing:.05em}.settings-subsection-title i{color:var(--accent);font-size:15px}.settings-subsection-title:first-child{margin-top:0}@media(max-width:768px){.settings-tabbar{top:60px;margin:0 0 18px;padding:0}.settings-tabs{width:100%;border-radius:var(--radius);flex-wrap:nowrap}.settings-tab{padding:8px 12px;font-size:12.5px}.settings-tab i{font-size:15px}.settings-tab-caption{padding:12px 14px;gap:10px}.settings-tab-caption .stc-icon{width:34px;height:34px;font-size:17px}.settings-stat-row{grid-template-columns:1fr}.settings-page .card-body{padding:14px 16px}.settings-save-bar{flex-wrap:wrap}}.settings-tab:focus-visible{outline:2px solid var(--accent);outline-offset:3px}@media(prefers-reduced-motion:reduce){.settings-tab,.settings-stat,.settings-page .card{transition:none}}body{background:radial-gradient(ellipse at 0% 0%,rgba(99,102,241,.08),transparent 45%),radial-gradient(ellipse at 100% 0%,rgba(168,85,247,.06),transparent 50%),var(--bg-primary);background-attachment:fixed}.card{background:var(--glass-bg);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm);position:relative}.card:after{content:"";position:absolute;inset:0;pointer-events:none;background:linear-gradient(180deg,rgba(255,255,255,.04) 0%,rgba(255,255,255,0) 28%,transparent 100%);border-radius:inherit}.card>*{position:relative;z-index:1}.card-header{background:transparent;border-bottom:1px solid var(--glass-border)}.card:hover{border-color:var(--glass-border-strong);box-shadow:0 1px #ffffff0f inset,0 -1px #0006 inset,0 14px 44px #0000006b}.stat-card{background:var(--glass-bg);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.stat-card:hover{border-color:#6366f180;box-shadow:0 1px #ffffff0f inset,0 -1px #0006 inset,0 18px 50px #6366f12e}.quick-action{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.quick-action:hover{background:#6366f114;border-color:#6366f166}.table th{background:#ffffff08;border-bottom:1px solid var(--glass-border)}.table td{border-bottom:1px solid var(--glass-border)}.table tbody tr:hover{background:#ffffff06}.search-box input,.filter-select,.logs-search-input{background:#0a0a0b8c;border-color:var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.search-box input:focus,.filter-select:focus,.logs-search-input:focus{background:#0a0a0bcc;border-color:#6366f18c;box-shadow:0 0 0 4px #6366f12e}.form-group input:not([type=checkbox]):not([type=radio]),.form-group select,.form-group textarea{background:#0a0a0b8c;border-color:var(--glass-border)}.form-group input:not([type=checkbox]):not([type=radio]):focus,.form-group select:focus,.form-group textarea:focus{background:#0a0a0bcc;border-color:#6366f18c;box-shadow:0 0 0 4px #6366f12e}.page-btn{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.badge-secondary{background:#ffffff0d;border-color:var(--glass-border)}.action-btn{background:var(--glass-bg);border-color:var(--glass-border)}.action-btn:hover{background:#ffffff0d;border-color:var(--glass-border-strong)}.mobile-node-card,.mobile-user-card{background:var(--glass-bg);border:1px solid var(--glass-border);backdrop-filter:var(--glass-blur-sm);-webkit-backdrop-filter:var(--glass-blur-sm)}.section-title{border-bottom:1px solid var(--glass-border)}.detail-item{background:var(--glass-bg);border:1px solid var(--glass-border)}.table code{background:transparent;border:none;padding:0}.settings-page .row{align-items:stretch}.settings-page .row>[class^=col-]{display:flex;flex-direction:column}.settings-page .row>[class^=col-]>.card{flex:1 1 auto;display:flex;flex-direction:column}.settings-page .row>[class^=col-]>.card>.card-body{flex:1 1 auto}.xray-tls-config{border:1px solid var(--border-light);border-radius:var(--radius-sm);padding:16px;background:var(--bg-elevated, transparent)}.xray-tls-config>.block-label{display:flex;align-items:center;gap:8px;font-weight:600;margin:0 0 6px;color:var(--text);font-size:14px}.xray-tls-config>.hint{display:block;color:var(--text-muted);font-size:12px;margin:0 0 14px;line-height:1.5}.xray-tls-config .radio-group{display:grid!important;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin:0 0 14px;flex-wrap:nowrap}.xray-tls-config .radio-row{position:relative;display:flex;align-items:flex-start;gap:10px;padding:12px 16px 12px 14px;border:1px solid var(--border-light);border-radius:var(--radius-xs);cursor:pointer;transition:background .12s ease,border-color .12s ease,box-shadow .12s ease;background:transparent;margin:0}.xray-tls-config .radio-row .badge{position:absolute;top:10px;right:10px;padding:2px 8px;font-size:9px;line-height:1.4;border-radius:999px;text-transform:uppercase;letter-spacing:.5px}.xray-tls-config .radio-row:hover{background:var(--bg-hover, rgba(127, 127, 127, .05));border-color:var(--border)}.xray-tls-config .radio-row input[type=radio]{margin:3px 0 0;flex-shrink:0;accent-color:var(--accent)}.xray-tls-config .radio-row:has(input[type=radio]:checked){border-color:var(--accent);background:rgba(var(--accent-rgb, 79, 156, 249),.08);box-shadow:0 0 0 1px var(--accent) inset}.xray-tls-config .radio-label{display:flex;flex-direction:column;gap:4px;flex:1 1 auto;min-width:0;line-height:1.4}.xray-tls-config .radio-label strong{display:block;color:var(--text);font-size:13px;font-weight:600;padding-right:90px}.xray-tls-config .radio-hint{display:block;color:var(--text-muted);font-size:11.5px;line-height:1.5}.fp-mode-group .radio-group{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;margin:0 0 6px;flex-wrap:nowrap}.fp-mode-group .radio-row{display:flex;align-items:flex-start;gap:10px;padding:12px 14px;border:1px solid var(--border-light);border-radius:var(--radius-xs);cursor:pointer;transition:background .12s ease,border-color .12s ease;margin:0}.fp-mode-group .radio-row:hover{border-color:var(--text-muted)}.fp-mode-group .radio-label{display:flex;flex-direction:column;gap:2px}.fp-mode-group .radio-hint{display:block;color:var(--text-muted);font-size:11.5px;line-height:1.5}.fp-checkbox-grid{display:flex;flex-wrap:wrap;gap:6px}.fp-checkbox{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border:1px solid var(--border-light);border-radius:999px;cursor:pointer;font-size:12.5px;line-height:1;user-select:none;transition:background .12s ease,border-color .12s ease,color .12s ease}.fp-checkbox:hover{border-color:var(--accent)}.fp-checkbox:has(input:checked){border-color:var(--accent);background:#6366f124;color:var(--accent-hover)}.fp-checkbox input{margin:0;cursor:pointer;flex-shrink:0;accent-color:var(--accent)}.fp-checkbox input:disabled{cursor:default}.xray-tls-config .info-card{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;margin:0 0 10px;background:#7f7f7f0d;border:1px solid var(--border-light);border-radius:var(--radius-xs);font-size:13px;color:var(--text-muted);line-height:1.5}.xray-tls-config .info-card>i{color:var(--accent);margin-top:2px;flex-shrink:0;font-size:16px}.xray-tls-config .info-card>div{flex:1;min-width:0}.xray-tls-config .info-card>div>div{color:var(--text);margin-bottom:4px}.xray-tls-config .info-card>div>small{display:block;color:var(--text-muted);font-size:12px}.xray-tls-config .info-card-warning{background:#d99a2712;border-color:#d99a2766}.xray-tls-config .info-card-warning>i{color:#d99a27}.xray-tls-config .info-card-warning>div>strong{display:block;color:#d99a27;margin-bottom:4px}.xray-tls-config .info-card code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;background:#7f7f7f1f;padding:1px 6px;border-radius:3px;font-size:12px;color:var(--text)}.xray-tls-config .tls-source-manual .form-group{margin:0 0 12px}.xray-tls-config .tls-source-manual .form-group:last-child{margin-bottom:0}.xray-tls-config .tls-source-manual label{display:block;margin-bottom:6px;font-size:12.5px;font-weight:600;color:var(--text)}.xray-tls-config .tls-source-manual small{display:block;color:var(--text-muted);font-size:11.5px;margin-top:4px;line-height:1.5}.pem-textarea{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;line-height:1.45;white-space:pre;overflow-wrap:normal;overflow-x:auto;width:100%}.pem-status{display:block;min-height:16px;margin-top:4px;font-size:11.5px;transition:color .1s ease}.pem-status.is-valid{color:#2ea043}.pem-status.is-invalid{color:#d9534f} diff --git a/scripts/test-node-cron-ui.js b/scripts/test-node-cron-ui.js index 42d174b..20f2d71 100644 --- a/scripts/test-node-cron-ui.js +++ b/scripts/test-node-cron-ui.js @@ -22,11 +22,16 @@ function assertIncludes(content, expected, label) { const managementPartial = read('views/partials/node-form/management.ejs'); assertIncludes(managementPartial, '/panel/nodes/<%= node._id %>/cron', 'management partial'); assertIncludes(managementPartial, "t('nodes.cronTasks')", 'management partial'); +assertIncludes(managementPartial, 'class="beta-badge"', 'management partial'); +assertIncludes(managementPartial, "t('network.betaBadge')", 'management partial'); assert( /if\s*\(\s*node\?\._id\s*&&\s*node\.type\s*!==\s*'virtual'\s*\)/.test(managementPartial), 'management partial should render management actions only for saved non-virtual nodes', ); +const styleCss = read('public/css/style.css'); +assertIncludes(styleCss, '.beta-badge', 'global stylesheet'); + const cronView = read('views/node-cron.ejs'); [ '/panel/nodes/<%= node._id %>/cron/data', diff --git a/views/partials/node-form/management.ejs b/views/partials/node-form/management.ejs index 0f5e9f8..295571f 100644 --- a/views/partials/node-form/management.ejs +++ b/views/partials/node-form/management.ejs @@ -217,6 +217,7 @@ <%= t('nodes.cronTasks') %> + <%= t('network.betaBadge') %> + +
@@ -644,9 +817,7 @@
-
-
@@ -671,6 +842,11 @@ <% }); %>
+
+ +
+ +
+
+
+
+
+ + <%- i18nKeys.cronResults %> +
+
+
+ + <%- i18nKeys.cronEmpty %> +
+
+
+
+
@@ -743,6 +961,7 @@ let selectedNodeIds = new Set(ALL_NODES.map(n => String(n._id))); let cmdHistory = []; let cmdHistoryIdx = -1; + const CRON_ENDPOINT = '/panel/broadcast/cron/apply'; try { cmdHistory = JSON.parse(localStorage.getItem('bc_cmd_history') || '[]'); } catch (_) {} @@ -1037,6 +1256,105 @@ return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } + // ── Multicast cron ──────────────────────────────────────────────── + function switchBroadcastMode(mode) { + document.querySelectorAll('#broadcastTabs .broadcast-tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.mode === mode); + }); + document.getElementById('terminalPanel').classList.toggle('hidden', mode !== 'terminal'); + document.getElementById('cronPanel').classList.toggle('hidden', mode !== 'cron'); + } + + function setCronBusy(value) { + document.getElementById('btnCronSave').disabled = value; + document.getElementById('btnCronSaveRun').disabled = value; + document.getElementById('cronRawBlock').disabled = value; + document.getElementById('cronUser').disabled = value; + } + + function clearCronResults(message) { + const results = document.getElementById('cronResults'); + results.textContent = ''; + if (message) { + const row = document.createElement('div'); + row.className = 'empty-state'; + row.style.padding = '24px 12px'; + const icon = document.createElement('i'); + icon.className = 'ti ti-clock-cog'; + const text = document.createElement('span'); + text.textContent = message; + row.appendChild(icon); + row.appendChild(text); + results.appendChild(row); + } + } + + function appendCronResult(result) { + const results = document.getElementById('cronResults'); + const row = document.createElement('div'); + row.className = 'cron-result-row ' + (result.success ? 'ok' : 'fail'); + + const node = document.createElement('div'); + node.className = 'cron-result-node'; + node.textContent = `${result.name || result.nodeId}${result.ip ? ' · ' + result.ip : ''}`; + + const message = document.createElement('div'); + message.className = 'cron-result-message'; + const runFailed = Array.isArray(result.runNow) && result.runNow.some(item => item.success === false); + if (result.error) { + message.textContent = `${i18n.cronFailed}: ${result.error}`; + } else if (runFailed) { + message.textContent = i18n.cronRunFailed; + } else if (result.saved) { + message.textContent = i18n.cronSaved; + } else { + message.textContent = i18n.cronSkipped; + } + + row.appendChild(node); + row.appendChild(message); + results.appendChild(row); + } + + async function applyCronBlock(runNow) { + if (selectedNodeIds.size === 0) { + clearCronResults(i18n.cronSelectNodes); + return; + } + + setCronBusy(true); + clearCronResults(i18n.running); + try { + const payload = { + selectedNodeIds: Array.from(selectedNodeIds), + user: document.getElementById('cronUser').value.trim() || 'root', + content: document.getElementById('cronRawBlock').value, + runNow: runNow === true, + }; + payload.nodeIds = payload.selectedNodeIds; + + const response = await fetch(CRON_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await response.json(); + if (!response.ok || data.success === false && !Array.isArray(data.results)) { + throw new Error(data.error || i18n.wsError); + } + + clearCronResults(''); + (data.results || []).forEach(appendCronResult); + if (!data.results || data.results.length === 0) { + clearCronResults(i18n.cronEmpty); + } + } catch (error) { + clearCronResults(error.message || i18n.wsError); + } finally { + setCronBusy(false); + } + } + // ── Textarea auto-resize ────────────────────────────────────────── function autoResize(el) { el.style.height = 'auto'; @@ -1049,8 +1367,10 @@ // ── Keyboard shortcuts ──────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { const input = document.getElementById('cmdInput'); + const cronRawBlock = document.getElementById('cronRawBlock'); input.addEventListener('input', () => autoResize(input)); + cronRawBlock.addEventListener('input', () => autoResize(cronRawBlock)); input.addEventListener('keydown', (e) => { const multiline = input.value.includes('\n'); diff --git a/views/nodes.ejs b/views/nodes.ejs index e2951b5..ed76b54 100644 --- a/views/nodes.ejs +++ b/views/nodes.ejs @@ -73,7 +73,7 @@ function nodeMetaFor(node) {