diff --git a/package.json b/package.json index a1be017..9dd870a 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 && node scripts/test-multicast-cron-service.js && node scripts/test-broadcast-panel.js && node scripts/test-broadcast-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/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-broadcast-panel.js b/scripts/test-broadcast-panel.js new file mode 100644 index 0000000..3308123 --- /dev/null +++ b/scripts/test-broadcast-panel.js @@ -0,0 +1,284 @@ +const assert = require('assert'); +const express = require('express'); +const Module = require('module'); + +const originalLoad = Module._load; + +let dbNodes = []; +const findCalls = []; +const rendered = []; +const serviceCalls = []; +let findError = null; +const id1 = '64f000000000000000000001'; +const id2 = '64f000000000000000000002'; +const id3 = '64f000000000000000000003'; + +function clone(value) { + return value == null ? value : JSON.parse(JSON.stringify(value)); +} + +const HyNode = { + find: (filter) => { + findCalls.push({ method: 'find', filter }); + return { + select(fields) { + findCalls.push({ method: 'select', fields }); + return this; + }, + populate(path, fields) { + findCalls.push({ method: 'populate', path, fields }); + return this; + }, + lean: async () => { + if (findError) throw findError; + return clone(dbNodes); + }, + }; + }, +}; + +const multicastCronService = { + applyCronBlockToNodes: async (nodes, options) => { + serviceCalls.push({ nodes: clone(nodes), options: clone(options) }); + if (!/^[a-z_][a-z0-9_-]{0,31}$/i.test(String(options.user || ''))) { + const error = new Error('Invalid cron user'); + error.statusCode = 400; + error.expose = true; + throw error; + } + if (String(options.content || '').includes('bad cron')) { + const error = new Error('Invalid cron line 1'); + error.statusCode = 400; + error.expose = true; + throw error; + } + return { + success: true, + summary: { total: nodes.length, saved: nodes.length, skipped: 0, failed: 0, runFailed: 0 }, + results: nodes.map(node => ({ nodeId: String(node._id), success: true, saved: true, runNow: [] })), + }; + }, +}; + +const stubs = { + '../../models/hyNodeModel': HyNode, + '../../services/multicastCronService': multicastCronService, + '../../utils/logger': { + info: () => {}, + warn: () => {}, + error: () => {}, + }, + './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(), + './nodeCron': 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 broadcastRouter = require('../src/routes/panel/broadcast'); +const panelRouter = require('../src/routes/panel'); +Module._load = originalLoad; + +function reset() { + dbNodes = []; + findCalls.length = 0; + rendered.length = 0; + serviceCalls.length = 0; + findError = 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; + }, + render(template, data) { + rendered.push({ template, data: clone(data) }); + this.rendered = { template, data }; + 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, { body = {} } = {}) { + const handlers = findRoute(broadcastRouter, method, path); + const handler = handlers[handlers.length - 1]; + const req = { + body, + ip: '127.0.0.1', + get: () => undefined, + }; + const res = makeRes(); + await handler(req, res, () => {}); + return res; +} + +(async () => { + assert(findRoute(broadcastRouter, 'get', '/broadcast')); + assert(findRoute(broadcastRouter, 'get', '/broadcast-terminal')); + assert(findRoute(broadcastRouter, 'post', '/broadcast/cron/apply').length > 1, 'cron apply route has local rate limiter'); + assert(panelRouter.stack.some(layer => layer.name === 'router' && layer.handle === broadcastRouter), 'panel index mounts broadcast router'); + + reset(); + dbNodes = [ + { _id: id1, name: 'Alpha', ip: '10.0.0.1', type: 'xray', status: 'online', flag: '🇳🇱', ssh: { port: 2222, username: 'admin' }, groups: [{ name: 'A' }] }, + { _id: id2, name: 'Duplicate', ip: '10.0.0.1', type: 'hysteria', status: 'online', ssh: { username: 'root' }, groups: [] }, + { _id: id3, name: 'Beta', ip: '10.0.0.2', type: 'hysteria', status: 'offline', ssh: { privateKey: 'key' }, groups: [] }, + { _id: 'v1', name: 'Virtual', ip: null, type: 'virtual', status: 'online', ssh: { username: 'root' }, groups: [] }, + ]; + let res = await runRoute('get', '/broadcast'); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(rendered[0].template, 'broadcast-terminal'); + assert.deepStrictEqual(findCalls[0], { + method: 'find', + filter: { + type: { $ne: 'virtual' }, + $or: [ + { 'ssh.password': { $exists: true, $ne: '' } }, + { 'ssh.privateKey': { $exists: true, $ne: '' } }, + ], + }, + }); + assert.deepStrictEqual(findCalls[1], { method: 'select', fields: '_id name ip type status flag ssh.port ssh.username groups' }); + assert.deepStrictEqual(findCalls[2], { method: 'populate', path: 'groups', fields: 'name' }); + assert.deepStrictEqual(rendered[0].data.nodes.map(node => node._id), [id1, id3]); + assert.strictEqual(rendered[0].data.nodes[0].sshPort, 2222); + assert.strictEqual(rendered[0].data.nodes[0].sshUsername, 'admin'); + assert.strictEqual(rendered[0].data.nodes[1].sshPort, 22); + assert.strictEqual(rendered[0].data.nodes[1].sshUsername, 'root'); + + reset(); + res = await runRoute('get', '/broadcast-terminal'); + assert.strictEqual(res.redirectedTo, '/panel/broadcast'); + + reset(); + findError = new Error('database unavailable'); + res = await runRoute('get', '/broadcast'); + assert.strictEqual(res.statusCode, 500); + assert.strictEqual(res.body, 'Error: database unavailable'); + + reset(); + dbNodes = [ + { _id: id1, name: 'Alpha', ip: '10.0.0.1', type: 'xray', ssh: { password: 'secret', username: 'root' } }, + { _id: id2, name: 'Beta', ip: '10.0.0.2', type: 'hysteria', ssh: { privateKey: 'key', username: 'deploy' } }, + { _id: id3, name: 'No SSH', ip: '10.0.0.3', type: 'xray', ssh: { username: 'root' } }, + ]; + res = await runRoute('post', '/broadcast/cron/apply', { + body: { + nodeIds: [id1, id2, id3], + user: 'root', + content: '*/5 * * * * /opt/job\n', + runNow: true, + }, + }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(serviceCalls.length, 1); + assert.deepStrictEqual(serviceCalls[0].nodes.map(node => node._id), [id1, id2]); + assert.deepStrictEqual(serviceCalls[0].options, { + user: 'root', + content: '*/5 * * * * /opt/job\n', + runNow: true, + }); + assert.strictEqual(res.body.success, true); + + reset(); + res = await runRoute('post', '/broadcast/cron/apply', { + body: { + nodeIds: [], + user: 'root', + content: '* * * * * /opt/job\n', + }, + }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Select at least one node' }); + + reset(); + res = await runRoute('post', '/broadcast/cron/apply', { + body: { + nodeIds: ['not-an-object-id'], + user: 'root', + content: '* * * * * /opt/job\n', + }, + }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid node id' }); + + reset(); + dbNodes = [{ _id: id1, name: 'Alpha', ip: '10.0.0.1', type: 'xray', ssh: { password: 'secret' } }]; + res = await runRoute('post', '/broadcast/cron/apply', { + body: { + nodeIds: [id1], + user: 'bad user', + content: '* * * * * /opt/job\n', + }, + }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid cron user' }); + + reset(); + dbNodes = [{ _id: id1, name: 'Alpha', ip: '10.0.0.1', type: 'xray', ssh: { password: 'secret' } }]; + res = await runRoute('post', '/broadcast/cron/apply', { + body: { + nodeIds: [id1], + user: 'root', + content: 'bad cron', + }, + }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid cron line 1' }); + + console.log('broadcast panel tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test-broadcast-ui.js b/scripts/test-broadcast-ui.js new file mode 100644 index 0000000..52a6157 --- /dev/null +++ b/scripts/test-broadcast-ui.js @@ -0,0 +1,136 @@ +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 broadcastView = read('views/broadcast-terminal.ejs'); +[ + 'id="broadcastTabs"', + 'data-mode="terminal"', + 'data-mode="cron"', + 'id="terminalPanel"', + 'id="cronPanel"', + 'id="cronUser"', + 'id="cronRawBlock"', + 'id="btnCronSave"', + 'id="btnCronSaveRun"', + 'id="cronResults"', + '/panel/broadcast/cron/apply', + 'selectedNodeIds: Array.from(selectedNodeIds)', + 'runNow: runNow === true', +].forEach(expected => assertIncludes(broadcastView, expected, 'broadcast view')); + +const nodeSelectorIndex = broadcastView.indexOf('id="nodeSelector"'); +const terminalPanelIndex = broadcastView.indexOf('id="terminalPanel"'); +const cronPanelIndex = broadcastView.indexOf('id="cronPanel"'); +assert(nodeSelectorIndex !== -1, 'broadcast view should include node selector'); +assert( + nodeSelectorIndex < terminalPanelIndex, + 'node selector should appear before terminal panel', +); +assert( + nodeSelectorIndex < cronPanelIndex, + 'node selector should appear before cron panel', +); +assert( + /betaBadge:\s*typeof t !== 'undefined' \? t\('network\.betaBadge'\)/.test(broadcastView), + 'broadcast view should use the shared beta badge translation', +); +assertIncludes(broadcastView, 'class="beta-badge"', 'broadcast cron tab'); + +[ + '/ws/broadcast', + 'type: \'exec\'', + 'type: \'cancel\'', + 'nodeIds: Array.from(selectedNodeIds)', +].forEach(expected => assertIncludes(broadcastView, expected, 'broadcast terminal behavior')); + +assert( + !/insertAdjacentHTML\s*\(/.test(broadcastView), + 'broadcast view should avoid insertAdjacentHTML', +); +assert( + !/\.innerHTML\s*\+=/.test(broadcastView), + 'broadcast view should avoid appending untrusted HTML', +); + +const nodesView = read('views/nodes.ejs'); +assertIncludes(nodesView, '/panel/broadcast', 'nodes view'); +assert(!nodesView.includes('/panel/broadcast-terminal'), 'nodes view should no longer link to legacy broadcast-terminal path'); + +const broadcastRoute = read('src/routes/panel/broadcast.js'); +assertIncludes(broadcastRoute, "router.get('/broadcast'", 'broadcast route'); +assertIncludes(broadcastRoute, "router.get('/broadcast-terminal'", 'broadcast route'); +assertIncludes(broadcastRoute, "res.redirect('/panel/broadcast')", 'broadcast route'); +assertIncludes(broadcastRoute, "router.post('/broadcast/cron/apply'", 'broadcast route'); +assertIncludes(broadcastRoute, "res.render('broadcast-terminal'", 'broadcast route'); +assertIncludes(broadcastRoute, ".select('_id name ip type status flag ssh.port ssh.username groups')", 'broadcast route'); + +const panelNodesRoute = read('src/routes/panel/nodes.js'); +assert(!panelNodesRoute.includes("router.get('/broadcast-terminal'"), 'nodes route should not own broadcast-terminal'); + +const requiredBroadcastKeys = [ + 'title', + 'terminalTab', + 'cronTab', + 'cronTitle', + 'cronUser', + 'cronRawBlock', + 'cronRawPlaceholder', + 'cronSave', + 'cronSaveRun', + 'cronResults', + 'cronSaved', + 'cronSkipped', + 'cronFailed', + 'cronRunFailed', + 'cronSelectNodes', + 'cronEmpty', + 'nodes', + 'selectAll', + 'deselectAll', + 'execute', + 'cancel', + 'summaryTotal', + 'summaryOk', + 'summaryFailed', +]; + +[ + 'src/locales/ru.json', + 'src/locales/en.json', + 'src/locales/zh-CN.json', +].forEach(localePath => { + const locale = readJson(localePath); + requiredBroadcastKeys.forEach(key => { + assert( + Object.prototype.hasOwnProperty.call(locale.broadcast, key), + `${localePath} should contain broadcast.${key}`, + ); + }); +}); + +const packageJson = readJson('package.json'); +[ + 'node scripts/test-multicast-cron-service.js', + 'node scripts/test-broadcast-panel.js', + 'node scripts/test-broadcast-ui.js', +].forEach(command => assertIncludes(packageJson.scripts.test, command, 'package npm test script')); + +console.log('broadcast UI tests passed'); diff --git a/scripts/test-multicast-cron-service.js b/scripts/test-multicast-cron-service.js new file mode 100644 index 0000000..098a126 --- /dev/null +++ b/scripts/test-multicast-cron-service.js @@ -0,0 +1,176 @@ +const assert = require('assert'); + +const { + createMulticastCronService, + extractRunnableCommands, + replaceManagedBlock, +} = require('../src/services/multicastCronService'); + +function createNode(id, ip = `10.0.0.${id}`) { + return { + _id: `node-${id}`, + name: `Node ${id}`, + ip, + ssh: { username: 'root', password: 'secret' }, + }; +} + +(async () => { + { + const content = replaceManagedBlock('* * * * * /usr/bin/old\n', '*/5 * * * * /usr/bin/new\n'); + assert(content.includes('* * * * * /usr/bin/old\n'), 'user cron line should be preserved'); + assert(content.includes('# BEGIN C3 CELERITY MULTICAST CRON'), 'managed block begin marker should be added'); + assert(content.includes('*/5 * * * * /usr/bin/new\n'), 'managed block content should be added'); + assert(content.includes('# END C3 CELERITY MULTICAST CRON'), 'managed block end marker should be added'); + } + + { + const current = [ + 'SHELL=/bin/bash', + '# BEGIN C3 CELERITY MULTICAST CRON', + '* * * * * /usr/bin/old', + '# END C3 CELERITY MULTICAST CRON', + '0 0 * * * /usr/bin/user', + '', + ].join('\n'); + const content = replaceManagedBlock(current, '@daily /usr/bin/new\n'); + assert(!content.includes('/usr/bin/old'), 'old managed command should be removed'); + assert(content.includes('@daily /usr/bin/new\n'), 'new managed command should be present'); + assert(content.includes('SHELL=/bin/bash\n'), 'prefix should be preserved'); + assert(content.includes('0 0 * * * /usr/bin/user\n'), 'suffix should be preserved'); + } + + { + const content = replaceManagedBlock([ + '* * * * * /usr/bin/user', + '# BEGIN C3 CELERITY MULTICAST CRON', + '*/10 * * * * /usr/bin/managed', + '# END C3 CELERITY MULTICAST CRON', + '', + ].join('\n'), ''); + assert(content.includes('* * * * * /usr/bin/user\n'), 'empty block should preserve user cron'); + assert(!content.includes('C3 CELERITY MULTICAST CRON'), 'empty block should remove managed markers'); + } + + assert.throws( + () => replaceManagedBlock('# BEGIN C3 CELERITY MULTICAST CRON\n* * * * * /usr/bin/job\n', '* * * * * /usr/bin/new\n'), + /managed cron block/i, + 'unbalanced markers should be rejected' + ); + assert.throws( + () => replaceManagedBlock('', '# BEGIN C3 CELERITY MULTICAST CRON\n* * * * * /usr/bin/job\n'), + /managed cron markers/i, + 'nested markers in raw block should be rejected' + ); + + { + const commands = extractRunnableCommands([ + 'SHELL=/bin/bash', + '# comment', + '*/5 * * * * /opt/job --flag', + '@daily /opt/daily # keep inline comment', + '* * * echo invalid', + '', + ].join('\n')); + assert.deepStrictEqual(commands, ['/opt/job --flag', '/opt/daily # keep inline comment']); + } + + { + const calls = []; + const remoteCronService = { + validateCronUser: () => true, + validateCronContent: () => true, + getCron: async (node, user) => { + calls.push({ method: 'getCron', nodeId: node._id, user }); + return { content: 'MAILTO=root\n', hash: `hash-${node._id}`, user }; + }, + saveCron: async (node, user, content, hash) => { + calls.push({ method: 'saveCron', nodeId: node._id, user, content, hash }); + if (node._id === 'node-2') throw new Error('save failed'); + return { success: true, hash: `new-${node._id}`, backupPath: `/tmp/${node._id}` }; + }, + runCommandNow: async (node, user, command) => { + calls.push({ method: 'runCommandNow', nodeId: node._id, user, command }); + return { code: 0, stdout: 'ok', stderr: '' }; + }, + }; + const service = createMulticastCronService({ remoteCronService, concurrency: 2 }); + const result = await service.applyCronBlockToNodes( + [createNode(1), createNode(2)], + { user: 'root', content: '*/5 * * * * /opt/job\n', runNow: true } + ); + assert.strictEqual(result.summary.total, 2); + assert.strictEqual(result.summary.saved, 1); + assert.strictEqual(result.summary.failed, 1); + assert.strictEqual(result.results[0].success, true); + assert.strictEqual(result.results[0].runNow.length, 1); + assert.strictEqual(result.results[1].success, false); + assert.deepStrictEqual( + calls.filter(call => call.method === 'saveCron').map(call => call.hash), + ['hash-node-1', 'hash-node-2'], + 'saveCron should use the hash loaded from each node' + ); + assert.deepStrictEqual( + calls.filter(call => call.method === 'runCommandNow').map(call => call.nodeId), + ['node-1'], + 'run-now should only execute after a successful save' + ); + } + + { + const calls = []; + const remoteCronService = { + validateCronUser: () => true, + validateCronContent: () => true, + getCron: async () => ({ content: replaceManagedBlock('', '* * * * * /opt/same\n'), hash: 'same-hash', user: 'root' }), + saveCron: async () => { + calls.push({ method: 'saveCron' }); + return { success: true }; + }, + runCommandNow: async () => { + calls.push({ method: 'runCommandNow' }); + return { code: 0, stdout: '', stderr: '' }; + }, + }; + const service = createMulticastCronService({ remoteCronService }); + const result = await service.applyCronBlockToNodes( + [createNode(1)], + { user: 'root', content: '* * * * * /opt/same\n', runNow: false } + ); + assert.strictEqual(result.results[0].skipped, true); + assert.strictEqual(calls.length, 0, 'unchanged content should not be saved or run'); + } + + { + const remoteCronService = { + validateCronUser: () => { + throw new Error('Invalid cron user'); + }, + validateCronContent: () => true, + }; + const service = createMulticastCronService({ remoteCronService }); + await assert.rejects( + () => service.applyCronBlockToNodes([createNode(1)], { user: 'bad user', content: '* * * * * /opt/job\n' }), + (error) => error.statusCode === 400 && error.expose === true && /Invalid cron user/.test(error.message) + ); + } + + { + const remoteCronService = { + validateCronUser: () => true, + validateCronContent: () => { + throw new Error('Invalid cron line 1'); + }, + }; + const service = createMulticastCronService({ remoteCronService }); + await assert.rejects( + () => service.applyCronBlockToNodes([createNode(1)], { user: 'root', content: '* * * bad\n' }), + (error) => error.statusCode === 400 && error.expose === true && /Invalid cron line/.test(error.message) + ); + } + + console.log('multicast cron service tests passed'); +})().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/scripts/test-node-cron-panel.js b/scripts/test-node-cron-panel.js new file mode 100644 index 0000000..0b8dc9a --- /dev/null +++ b/scripts/test-node-cron-panel.js @@ -0,0 +1,424 @@ +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, 200); + assert.strictEqual(rendered[0].template, 'cron-empty'); + assert.strictEqual(rendered[0].data.reason, 'virtual'); + assert.strictEqual(rendered[0].data.node._id, 'virtual-1'); + + 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, 200); + assert.strictEqual(rendered[0].template, 'cron-empty'); + assert.strictEqual(rendered[0].data.reason, 'no-ssh'); + assert.strictEqual(rendered[0].data.node._id, 'no-ssh'); + + reset(); + db.set('no-creds', { _id: 'no-creds', name: 'No creds', type: 'hysteria' }); + res = await runRoute('get', '/nodes/:id/cron', { id: 'no-creds' }); + assert.strictEqual(res.statusCode, 200); + assert.strictEqual(rendered[0].template, 'cron-empty'); + assert.strictEqual(rendered[0].data.reason, 'no-ssh'); + + 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('virtual-1', { _id: 'virtual-1', name: 'Virtual', type: 'virtual', ssh: { password: 'secret' } }); + res = await runRoute('get', '/nodes/:id/cron/data', { id: 'virtual-1', query: { user: 'root' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Virtual nodes do not support remote cron management' }); + assert(!serviceCalls.some(call => call.method === 'getCron'), 'virtual node is rejected before remote cron read'); + assert(!serviceCalls.some(call => call.method === 'getCronServiceStatus'), 'virtual node is rejected before remote status read'); + + reset(); + db.set('no-ssh', { _id: 'no-ssh', name: 'No SSH', type: 'xray', ssh: { username: 'root' } }); + res = await runRoute('get', '/nodes/:id/cron/data', { id: 'no-ssh', query: { user: 'root' } }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'SSH credentials are required for remote cron management' }); + assert(!serviceCalls.some(call => call.method === 'getCron'), 'missing SSH credentials are rejected before remote cron read'); + assert(!serviceCalls.some(call => call.method === 'getCronServiceStatus'), 'missing SSH credentials are rejected before remote status read'); + + 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' } }); + res = await runRoute('post', '/nodes/:id/cron/save', { + query: { user: 'root' }, + body: { user: 'bad user', content: 'MAILTO=\n', baseHash: 'hash-1' }, + }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Invalid cron user' }); + assert(!serviceCalls.some(call => call.method === 'saveCron'), 'query user does not mask invalid body user before remote save'); + + reset(); + db.set('virtual-1', { _id: 'virtual-1', name: 'Virtual', type: 'virtual', ssh: { password: 'secret' } }); + res = await runRoute('post', '/nodes/:id/cron/save', { + id: 'virtual-1', + body: { user: 'root', content: 'MAILTO=\n', baseHash: 'hash-1' }, + }); + assert.strictEqual(res.statusCode, 400); + assert.deepStrictEqual(res.body, { success: false, error: 'Virtual nodes do not support remote cron management' }); + assert(!serviceCalls.some(call => call.method === 'saveCron'), 'virtual node 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', { + query: { user: 'root' }, + 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'), 'query user does not mask invalid body user 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..20f2d71 --- /dev/null +++ b/scripts/test-node-cron-ui.js @@ -0,0 +1,214 @@ +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'); +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', + '/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"', +].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', +); +assert( + /runCommand\(task\.command\)\.catch\(error\s*=>\s*appendOutput\(error\.message\)\)/.test(cronView), + 'node cron view should display Run now request errors in cron output', +); + +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', + 'cronEmptyVirtualTitle', + 'cronEmptyVirtualDesc', + 'cronEmptySshTitle', + 'cronEmptySshDesc', + 'cronEmptySshStep1Title', + 'cronEmptySshStep1Desc', + 'cronEmptySshStep2Title', + 'cronEmptySshStep2Desc', + 'cronEmptySshStep3Title', + 'cronEmptySshStep3Desc', +]; + +const cronEmptyView = read('views/cron-empty.ejs'); +assertIncludes(cronEmptyView, 'cron-empty-card', 'cron empty view'); +assertIncludes(cronEmptyView, "t('nodes.cronBackToNode')", 'cron empty view'); +assertIncludes(cronEmptyView, "t('common.backToList')", 'cron empty view'); +assert( + /reason\s*===\s*'virtual'\s*\?/.test(cronEmptyView) && /reason\s*===\s*'no-ssh'/.test(cronEmptyView), + 'cron empty view should switch icon and copy based on reason', +); +assert( + /reason\s*===\s*'no-ssh'[\s\S]+cronEmptySshStep1Title[\s\S]+cronEmptySshStep2Title[\s\S]+cronEmptySshStep3Title/.test(cronEmptyView), + 'cron empty view should render the 3-step SSH guide only for the no-ssh reason', +); +assert( + /href="\/panel\/nodes\/<%= node\._id %>"/.test(cronEmptyView) && /href="\/panel\/nodes"/.test(cronEmptyView), + 'cron empty view should provide navigation back to the node and to the nodes list', +); +[ + 'react', + 'vue', + 'angular', + 'svelte', + 'alpine', + 'jquery', +].forEach(framework => { + assert( + !new RegExp(`<(script|link)[^>]+${framework}`, 'i').test(cronEmptyView), + `cron empty view should not import ${framework}`, + ); +}); + +[ + '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..dee72e3 --- /dev/null +++ b/scripts/test-remote-cron-service.js @@ -0,0 +1,360 @@ +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, options = {}) { + 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}`); + if (options.writeFile) { + return options.writeFile(remotePath, content, this); + } + 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$/); + 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$/); + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('rm -rf /tmp/celerity-cron-app-1710000000000-')) { + 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) => { + 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-')) { + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('umask 077 && crontab -u app -l > /tmp/celerity-cron-backup-app-')) { + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('rm -rf /tmp/celerity-cron-app-1710000000000-')) { + return { code: 7, stdout: '', stderr: 'cleanup failed' }; + } + throw new Error(`unexpected command: ${command}`); + }, { + writeFile: async () => { + throw new Error('write failed'); + }, + }); + const service = createRemoteCronService({ SSHClass, now: () => 1710000000000 }); + await assert.rejects( + () => service.saveCron(createNode('root'), 'app', '* * * * * echo new\n', hash('* * * * * echo old\n')), + /write failed/ + ); + assert(SSHClass.instances[0].commands.some(command => /^rm -rf \/tmp\/celerity-cron-app-1710000000000-[a-f0-9]{16}$/.test(command))); + } + + { + let currentContent = '* * * * * echo old\n'; + let readCount = 0; + const SSHClass = makeSSHClass(async (command) => { + if (command === 'crontab -u app -l') { + readCount += 1; + return { code: 0, stdout: currentContent, stderr: '' }; + } + if (command.startsWith('umask 077; mkdir -p /tmp/celerity-cron-app-1710000000000-')) { + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('umask 077 && crontab -u app -l > /tmp/celerity-cron-backup-app-')) { + return { code: 0, stdout: '', stderr: '' }; + } + if (command.includes('base64 -d') && command.includes('crontab -u app')) { + currentContent = '* * * * * echo first\n'; + return { code: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }); + const service = createRemoteCronService({ SSHClass, now: () => 1710000000000 }); + const baseHash = hash('* * * * * echo old\n'); + const results = await Promise.allSettled([ + service.saveCron(createNode('root'), 'app', '* * * * * echo first\n', baseHash), + service.saveCron(createNode('root'), 'app', '* * * * * echo second\n', baseHash), + ]); + assert.strictEqual(results.filter(result => result.status === 'fulfilled').length, 1); + const rejected = results.find(result => result.status === 'rejected'); + assert(rejected, 'one parallel save must reject'); + assert.strictEqual(rejected.reason.statusCode, 409); + assert.match(rejected.reason.message, /Cron changed/); + assert.strictEqual(readCount, 2); + } + + { + 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-')) { + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('umask 077 && crontab -u app -l > /tmp/celerity-cron-backup-app-')) { + return { code: 1, stdout: '', stderr: 'permission denied' }; + } + if (command.startsWith('rm -rf /tmp/celerity-cron-app-1710000000000-')) { + return { code: 0, stdout: '', stderr: '' }; + } + throw new Error(`unexpected command: ${command}`); + }); + const service = createRemoteCronService({ SSHClass, now: () => 1710000000000 }); + await assert.rejects( + () => service.saveCron(createNode('root'), 'app', '* * * * * echo new\n', hash('* * * * * echo old\n')), + (error) => error.statusCode === 502 && /permission denied/.test(error.message) + ); + assert(!SSHClass.instances[0].commands.some(command => command.startsWith('writeFile:'))); + assert(!SSHClass.instances[0].commands.some(command => command.includes('base64 -d'))); + 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) => { + 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-')) { + return { code: 0, stdout: '', stderr: '' }; + } + if (command.startsWith('umask 077 && crontab -u app -l > /tmp/celerity-cron-backup-app-')) { + return { code: 1, stdout: '', stderr: 'no crontab for app' }; + } + if (command.includes('base64 -d') && command.includes('crontab -u app')) { + 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', '* * * * * echo new\n', hash('* * * * * echo old\n')); + assert.strictEqual(result.success, true); + assert(SSHClass.instances[0].commands.some(command => command.startsWith('writeFile:'))); + } + + { + 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..f375bd9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -270,6 +270,47 @@ "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", + "cronEmptyVirtualTitle": "Cron is unavailable for virtual nodes", + "cronEmptyVirtualDesc": "Virtual nodes aggregate other nodes in the topology, so there is no server to connect to and no remote cron to manage.", + "cronEmptySshTitle": "SSH access is required", + "cronEmptySshDesc": "Remote cron management needs SSH access to the node. Configure a username with a password or private key in the node settings to continue.", + "cronEmptySshStep1Title": "Open node settings", + "cronEmptySshStep1Desc": "Use the button below to open this node's edit page.", + "cronEmptySshStep2Title": "Configure SSH access", + "cronEmptySshStep2Desc": "Scroll to the Management section and provide a username plus a password or a private key.", + "cronEmptySshStep3Title": "Return to this page", + "cronEmptySshStep3Desc": "Once saved, open the Cron page again and the editor will load automatically.", "executionLogs": "Execution logs:", "loadingLogs": "Loading logs...", "checking": "Checking...", @@ -1109,7 +1150,22 @@ "empty": "Execute a command to see output from nodes", "summaryTotal": "Total:", "summaryOk": "OK:", - "summaryFailed": "Failed:" + "summaryFailed": "Failed:", + "terminalTab": "Terminal", + "cronTab": "Multicast Cron", + "cronTitle": "Multicast Cron Tasks", + "cronUser": "Cron user", + "cronRawBlock": "Raw cron block", + "cronRawPlaceholder": "*/5 * * * * /opt/job.sh", + "cronSave": "Save C³ block", + "cronSaveRun": "Save + Run now", + "cronResults": "Cron results", + "cronSaved": "Saved", + "cronSkipped": "No changes", + "cronFailed": "Failed", + "cronRunFailed": "Run failed", + "cronSelectNodes": "Select at least one node", + "cronEmpty": "Save a cron block to see per-node results" }, "backup": { "creating": "Creating backup...", diff --git a/src/locales/ru.json b/src/locales/ru.json index d1d98fe..b6dd1c1 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -270,6 +270,47 @@ "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 сохранен", + "cronEmptyVirtualTitle": "Cron недоступен для виртуальных нод", + "cronEmptyVirtualDesc": "Виртуальные ноды только агрегируют другие ноды в топологии, у них нет своего сервера, поэтому удалённым cron управлять нельзя.", + "cronEmptySshTitle": "Нужен SSH-доступ", + "cronEmptySshDesc": "Для удалённого управления cron нужен SSH-доступ к ноде. Укажите имя пользователя и пароль или приватный ключ в настройках ноды, чтобы продолжить.", + "cronEmptySshStep1Title": "Откройте настройки ноды", + "cronEmptySshStep1Desc": "Нажмите кнопку ниже, чтобы перейти к редактированию этой ноды.", + "cronEmptySshStep2Title": "Настройте SSH-доступ", + "cronEmptySshStep2Desc": "Прокрутите до секции «Управление» и укажите имя пользователя с паролем или приватным ключом.", + "cronEmptySshStep3Title": "Вернитесь на эту страницу", + "cronEmptySshStep3Desc": "После сохранения снова откройте страницу Cron — редактор загрузится автоматически.", "executionLogs": "Логи выполнения:", "loadingLogs": "Загрузка логов...", "checking": "Проверка...", @@ -1109,7 +1150,22 @@ "empty": "Введите команду, чтобы увидеть вывод с нод", "summaryTotal": "Всего:", "summaryOk": "Успешно:", - "summaryFailed": "Ошибок:" + "summaryFailed": "Ошибок:", + "terminalTab": "Терминал", + "cronTab": "Multicast Cron", + "cronTitle": "Multicast Cron задачи", + "cronUser": "Пользователь cron", + "cronRawBlock": "Raw cron блок", + "cronRawPlaceholder": "*/5 * * * * /opt/job.sh", + "cronSave": "Сохранить C³ блок", + "cronSaveRun": "Сохранить и запустить", + "cronResults": "Результаты cron", + "cronSaved": "Сохранено", + "cronSkipped": "Без изменений", + "cronFailed": "Ошибка", + "cronRunFailed": "Ошибка запуска", + "cronSelectNodes": "Выберите хотя бы одну ноду", + "cronEmpty": "Сохраните cron блок, чтобы увидеть результат по нодам" }, "backup": { "creating": "Создание бэкапа...", diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index a52f3a3..0a13067 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -270,6 +270,47 @@ "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 已保存", + "cronEmptyVirtualTitle": "虚拟节点不支持 Cron", + "cronEmptyVirtualDesc": "虚拟节点仅在拓扑中聚合其他节点,没有自己的服务器,因此无法进行远程 Cron 管理。", + "cronEmptySshTitle": "需要 SSH 访问权限", + "cronEmptySshDesc": "远程 Cron 管理需要该节点的 SSH 访问权限。请在节点设置中配置用户名和密码或私钥以继续。", + "cronEmptySshStep1Title": "打开节点设置", + "cronEmptySshStep1Desc": "点击下方按钮打开此节点的编辑页面。", + "cronEmptySshStep2Title": "配置 SSH 访问", + "cronEmptySshStep2Desc": "滚动到「管理」部分,填写用户名以及密码或私钥。", + "cronEmptySshStep3Title": "返回此页面", + "cronEmptySshStep3Desc": "保存后再次打开 Cron 页面,编辑器将自动加载。", "executionLogs": "执行日志:", "loadingLogs": "正在加载日志...", "checking": "正在检查...", @@ -1109,7 +1150,22 @@ "empty": "执行命令以查看来自节点的输出", "summaryTotal": "总计:", "summaryOk": "成功:", - "summaryFailed": "失败:" + "summaryFailed": "失败:", + "terminalTab": "终端", + "cronTab": "组播 Cron", + "cronTitle": "组播 Cron 任务", + "cronUser": "Cron 用户", + "cronRawBlock": "原始 cron 块", + "cronRawPlaceholder": "*/5 * * * * /opt/job.sh", + "cronSave": "保存 C³ 块", + "cronSaveRun": "保存并立即运行", + "cronResults": "Cron 结果", + "cronSaved": "已保存", + "cronSkipped": "无变化", + "cronFailed": "失败", + "cronRunFailed": "运行失败", + "cronSelectNodes": "至少选择一个节点", + "cronEmpty": "保存 cron 块后可查看每个节点的结果" }, "backup": { "creating": "正在创建备份...", diff --git a/src/routes/panel/broadcast.js b/src/routes/panel/broadcast.js new file mode 100644 index 0000000..0ede7dd --- /dev/null +++ b/src/routes/panel/broadcast.js @@ -0,0 +1,140 @@ +const express = require('express'); +const rateLimit = require('express-rate-limit'); +const router = express.Router(); + +const HyNode = require('../../models/hyNodeModel'); +const multicastCronService = require('../../services/multicastCronService'); +const logger = require('../../utils/logger'); + +const writeLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 10, + standardHeaders: true, + legacyHeaders: false, + handler: (_req, res) => { + res.status(429).json({ success: false, error: 'Too many broadcast cron requests. Try again in a minute.' }); + }, +}); + +function hasSshCredentials(node) { + return !!(node?.ssh?.password || node?.ssh?.privateKey); +} + +function normalizeBroadcastNodes(nodes) { + const seenIps = new Set(); + const sshNodes = []; + for (const node of nodes || []) { + if (node.type === 'virtual' || !node.ip) continue; + if (seenIps.has(node.ip)) continue; + seenIps.add(node.ip); + sshNodes.push({ + _id: node._id, + name: node.name, + ip: node.ip, + type: node.type, + status: node.status, + flag: node.flag, + sshPort: node.ssh?.port || 22, + sshUsername: node.ssh?.username || 'root', + groups: node.groups, + }); + } + return sshNodes; +} + +async function loadBroadcastPageNodes() { + const nodes = await HyNode.find({ + type: { $ne: 'virtual' }, + $or: [ + { 'ssh.password': { $exists: true, $ne: '' } }, + { 'ssh.privateKey': { $exists: true, $ne: '' } }, + ], + }) + .select('_id name ip type status flag ssh.port ssh.username groups') + .populate('groups', 'name') + .lean(); + return normalizeBroadcastNodes(nodes); +} + +async function loadCronTargetNodes(nodeIds) { + const requested = new Set(nodeIds.map(id => String(id))); + const nodes = await HyNode.find({ + _id: { $in: nodeIds }, + type: { $ne: 'virtual' }, + $or: [ + { 'ssh.password': { $exists: true, $ne: '' } }, + { 'ssh.privateKey': { $exists: true, $ne: '' } }, + ], + }).lean(); + + const seenIps = new Set(); + return (nodes || []).filter((node) => { + if (!requested.has(String(node._id))) return false; + if (node.type === 'virtual' || !node.ip || !hasSshCredentials(node)) return false; + if (seenIps.has(node.ip)) return false; + seenIps.add(node.ip); + return true; + }); +} + +function sendJsonError(res, error, fallbackStatus = 500) { + const status = Number.isInteger(error?.statusCode) ? error.statusCode : fallbackStatus; + return res.status(status).json({ + success: false, + error: error?.expose ? error.message : (status < 500 ? error.message : 'Broadcast cron operation failed'), + }); +} + +function isValidObjectId(value) { + return /^[a-f0-9]{24}$/i.test(String(value || '')); +} + +router.get('/broadcast', async (_req, res) => { + try { + const nodes = await loadBroadcastPageNodes(); + return res.render('broadcast-terminal', { nodes }); + } catch (error) { + return res.status(500).send('Error: ' + error.message); + } +}); + +router.get('/broadcast-terminal', (_req, res) => { + res.redirect('/panel/broadcast'); +}); + +router.post('/broadcast/cron/apply', writeLimiter, async (req, res) => { + try { + const nodeIds = Array.isArray(req.body.nodeIds) ? req.body.nodeIds.filter(Boolean) : []; + if (nodeIds.length === 0) { + return res.status(400).json({ success: false, error: 'Select at least one node' }); + } + if (!nodeIds.every(isValidObjectId)) { + return res.status(400).json({ success: false, error: 'Invalid node id' }); + } + + const content = typeof req.body.content === 'string' ? req.body.content : ''; + const user = req.body.user || 'root'; + const runNow = req.body.runNow === true; + const nodes = await loadCronTargetNodes(nodeIds); + if (nodes.length === 0) { + return res.status(400).json({ success: false, error: 'No nodes with SSH credentials found' }); + } + + const result = await multicastCronService.applyCronBlockToNodes(nodes, { user, content, runNow }); + logger.info('[PanelBroadcast] Multicast cron applied', { + requested: nodeIds.length, + targeted: nodes.length, + total: result.summary.total, + saved: result.summary.saved, + skipped: result.summary.skipped, + failed: result.summary.failed, + runFailed: result.summary.runFailed, + }); + return res.json(result); + } catch (error) { + logger.warn('[PanelBroadcast] Multicast cron failed', { error: error.message }); + return sendJsonError(res, error); + } +}); + +module.exports = router; diff --git a/src/routes/panel/index.js b/src/routes/panel/index.js index f0e0e17..441b97b 100644 --- a/src/routes/panel/index.js +++ b/src/routes/panel/index.js @@ -10,6 +10,8 @@ const { checkIpWhitelist, requireAuth, requireOnboarding } = require('./helpers' const authRoutes = require('./auth'); const wizardRoutes = require('./wizard'); +const nodeCronRoutes = require('./nodeCron'); +const broadcastRoutes = require('./broadcast'); const nodesRoutes = require('./nodes'); const usersRoutes = require('./users'); const settingsRoutes = require('./settings'); @@ -26,6 +28,8 @@ router.use('/', authRoutes); router.use('/', requireAuth, wizardRoutes); // All other routes require authentication and completed onboarding +router.use('/', requireAuth, requireOnboarding, nodeCronRoutes); +router.use('/', requireAuth, requireOnboarding, broadcastRoutes); 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..b9000c7 --- /dev/null +++ b/src/routes/panel/nodeCron.js @@ -0,0 +1,232 @@ +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, user } = {}) { + 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(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' || !hasSshCredentials(node)) { + return render(res, 'cron-empty', { + title: `Cron: ${node.name || node.ip || node._id}`, + page: 'nodes', + node, + reason: node.type === 'virtual' ? 'virtual' : 'no-ssh', + error: null, + }); + } + + 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, user: req.query.user }); + 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, user: req.body.user }); + 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, user: req.body.user }); + 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/routes/panel/nodes.js b/src/routes/panel/nodes.js index 10bd900..fef2346 100644 --- a/src/routes/panel/nodes.js +++ b/src/routes/panel/nodes.js @@ -1057,42 +1057,6 @@ router.post('/nodes/:id/outbounds', async (req, res) => { } }); -// GET /panel/broadcast-terminal - Broadcast terminal page -router.get('/broadcast-terminal', async (req, res) => { - try { - const nodes = await HyNode.find({ - $or: [ - { 'ssh.password': { $exists: true, $ne: '' } }, - { 'ssh.privateKey': { $exists: true, $ne: '' } }, - ], - }) - .select('_id name ip type status flag ssh.port ssh.username groups') - .populate('groups', 'name') - .lean(); - // Deduplicate by IP (one physical server may have two protocol nodes) - const seenIps = new Set(); - const sshNodes = []; - for (const n of nodes) { - if (seenIps.has(n.ip)) continue; - seenIps.add(n.ip); - sshNodes.push({ - _id: n._id, - name: n.name, - ip: n.ip, - type: n.type, - status: n.status, - flag: n.flag, - sshPort: n.ssh?.port || 22, - sshUsername: n.ssh?.username || 'root', - groups: n.groups, - }); - } - res.render('broadcast-terminal', { nodes: sshNodes }); - } catch (error) { - res.status(500).send('Error: ' + error.message); - } -}); - // GET /panel/nodes/:id/terminal - SSH terminal router.get('/nodes/:id/terminal', async (req, res) => { try { diff --git a/src/services/multicastCronService.js b/src/services/multicastCronService.js new file mode 100644 index 0000000..d97c019 --- /dev/null +++ b/src/services/multicastCronService.js @@ -0,0 +1,204 @@ +const remoteCronServiceDefault = require('./remoteCronService'); + +const MANAGED_BEGIN = '# BEGIN C3 CELERITY MULTICAST CRON'; +const MANAGED_END = '# END C3 CELERITY MULTICAST CRON'; +const DEFAULT_CONCURRENCY = 3; + +function normalizeContent(content) { + return String(content || '').replace(/\r\n?/g, '\n'); +} + +function ensureTrailingNewline(content) { + return content && !content.endsWith('\n') ? `${content}\n` : content; +} + +function createValidationError(message) { + const error = new Error(message); + error.statusCode = 400; + error.expose = true; + return error; +} + +function validateManagedMarkers(content) { + if (content.includes(MANAGED_BEGIN) || content.includes(MANAGED_END)) { + throw createValidationError('Raw cron content must not include managed cron markers'); + } +} + +function markerIndexes(lines) { + const begins = []; + const ends = []; + lines.forEach((line, index) => { + if (line.trim() === MANAGED_BEGIN) begins.push(index); + if (line.trim() === MANAGED_END) ends.push(index); + }); + if (begins.length !== ends.length || begins.length > 1) { + throw createValidationError('Invalid managed cron block markers'); + } + if (begins.length === 1 && begins[0] >= ends[0]) { + throw createValidationError('Invalid managed cron block markers'); + } + return begins.length === 1 ? { begin: begins[0], end: ends[0] } : null; +} + +function compactBlankLines(content) { + return content.replace(/\n{3,}/g, '\n\n'); +} + +function replaceManagedBlock(currentContent, blockContent) { + const current = normalizeContent(currentContent); + const block = normalizeContent(blockContent).trim(); + validateManagedMarkers(block); + + const lines = current.split('\n'); + if (lines[lines.length - 1] === '') lines.pop(); + const indexes = markerIndexes(lines); + + const replacement = block + ? [MANAGED_BEGIN, ...block.split('\n'), MANAGED_END] + : []; + const nextLines = indexes + ? [...lines.slice(0, indexes.begin), ...replacement, ...lines.slice(indexes.end + 1)] + : [...lines, ...replacement]; + + return ensureTrailingNewline(compactBlankLines(nextLines.join('\n').trim())); +} + +function extractRunnableCommands(content) { + const commands = []; + normalizeContent(content).split('\n').forEach((rawLine) => { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || /^[A-Za-z_][A-Za-z0-9_]*=.*$/.test(line)) { + return; + } + + if (line.startsWith('@')) { + const parts = line.split(/\s+/); + if (parts.length >= 2) { + commands.push(line.slice(parts[0].length).trim()); + } + return; + } + + const parts = line.split(/\s+/); + if (parts.length >= 6) { + commands.push(parts.slice(5).join(' ')); + } + }); + return commands; +} + +async function mapLimit(items, limit, worker) { + const results = new Array(items.length); + let nextIndex = 0; + const workerCount = Math.min(Math.max(1, limit), items.length); + + async function run() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await worker(items[index], index); + } + } + + await Promise.all(Array.from({ length: workerCount }, run)); + return results; +} + +function safeNodeResult(node, extra) { + return { + nodeId: String(node?._id || ''), + name: node?.name || '', + ip: node?.ip || '', + ...extra, + }; +} + +function createMulticastCronService({ remoteCronService = remoteCronServiceDefault, concurrency = DEFAULT_CONCURRENCY } = {}) { + async function applyOneNode(node, options, runnableCommands) { + try { + const cron = await remoteCronService.getCron(node, options.user); + const nextContent = replaceManagedBlock(cron.content, options.content); + const changed = normalizeContent(cron.content) !== nextContent; + let saveResult = null; + + if (changed) { + saveResult = await remoteCronService.saveCron(node, options.user, nextContent, cron.hash); + } + + const runNow = []; + if (options.runNow) { + for (const command of runnableCommands) { + try { + const result = await remoteCronService.runCommandNow(node, options.user, command); + runNow.push({ command, success: result.code === 0, ...result }); + } catch (error) { + runNow.push({ command, success: false, error: error.message || 'Run failed' }); + } + } + } + + return safeNodeResult(node, { + success: runNow.every(result => result.success !== false), + saved: changed, + skipped: !changed, + hash: saveResult?.hash || cron.hash, + backupPath: saveResult?.backupPath || null, + runNow, + }); + } catch (error) { + return safeNodeResult(node, { + success: false, + saved: false, + skipped: false, + error: error.message || 'Cron operation failed', + runNow: [], + }); + } + } + + async function applyCronBlockToNodes(nodes, options = {}) { + const user = options.user || 'root'; + const content = normalizeContent(options.content); + try { + remoteCronService.validateCronUser(user); + validateManagedMarkers(content); + remoteCronService.validateCronContent(content); + } catch (error) { + throw createValidationError(error.message || 'Invalid cron input'); + } + + const runnableCommands = extractRunnableCommands(content); + const results = await mapLimit(nodes, concurrency, (node) => ( + applyOneNode(node, { user, content, runNow: !!options.runNow }, runnableCommands) + )); + + const summary = results.reduce((acc, result) => { + acc.total += 1; + if (result.saved) acc.saved += 1; + if (result.skipped) acc.skipped += 1; + if (!result.success) acc.failed += 1; + if (result.runNow?.some(item => item.success === false)) acc.runFailed += 1; + return acc; + }, { total: 0, saved: 0, skipped: 0, failed: 0, runFailed: 0 }); + + return { + success: summary.failed === 0, + summary, + results, + }; + } + + return { applyCronBlockToNodes }; +} + +const defaultService = createMulticastCronService(); + +module.exports = { + MANAGED_BEGIN, + MANAGED_END, + createMulticastCronService, + extractRunnableCommands, + replaceManagedBlock, + applyCronBlockToNodes: (...args) => defaultService.applyCronBlockToNodes(...args), +}; diff --git a/src/services/remoteCronService.js b/src/services/remoteCronService.js new file mode 100644 index 0000000..1a1303f --- /dev/null +++ b/src/services/remoteCronService.js @@ -0,0 +1,366 @@ +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'); + const saveLocks = new Map(); + + function getNodeLockPart(node) { + return [ + node?._id || node?.id || '', + node?.ip || '', + node?.name || '', + ].map(value => String(value)).join('|') || 'unknown'; + } + + async function withSaveLock(node, user, callback) { + const key = `${getNodeLockPart(node)}:${user}`; + const previous = saveLocks.get(key)?.promise || Promise.resolve(); + let release; + const currentPromise = new Promise((resolve) => { + release = resolve; + }); + const entry = { + promise: previous.then(() => currentPromise, () => currentPromise), + }; + saveLocks.set(key, entry); + await previous.catch(() => {}); + try { + return await callback(); + } finally { + release(); + if (saveLocks.get(key) === entry) { + saveLocks.delete(key); + } + } + } + + 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 cleanupTempDir(ssh, tempDir) { + try { + await ssh.exec(`rm -rf ${tempDir}`); + } catch (error) { + return undefined; + } + return undefined; + } + + function createRemoteError(result, fallbackMessage) { + const error = new Error(result?.stderr || result?.stdout || fallbackMessage); + error.statusCode = 502; + return error; + } + + 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 withSaveLock(node, user, () => 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; + } + try { + const backupResult = await ssh.exec(`umask 077 && ${crontabReadCommand(node, user)} > ${backupPath}`); + if (backupResult.code !== 0 && !isMissingCrontab(backupResult)) { + throw createRemoteError(backupResult, 'Failed to backup crontab'); + } + 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}`); + if (result.code !== 0) { + throw createRemoteError(result, 'Failed to install crontab'); + } + } catch (error) { + await cleanupTempDir(ssh, tempDir); + throw error; + } + await cleanupTempDir(ssh, tempDir); + + 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/broadcast-terminal.ejs b/views/broadcast-terminal.ejs index fe0182c..cd2cd1c 100644 --- a/views/broadcast-terminal.ejs +++ b/views/broadcast-terminal.ejs @@ -100,6 +100,38 @@ border: 1px solid rgba(99, 102, 241, 0.25); } + .broadcast-tabs { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--glass-border); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); + } + + .broadcast-tab { + border: 0; + background: transparent; + color: var(--text-muted); + padding: 7px 12px; + border-radius: 999px; + display: inline-flex; + align-items: center; + gap: 7px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: all 0.16s ease; + } + + .broadcast-tab.active { + color: #fff; + background: linear-gradient(135deg, var(--accent), #7c3aed); + box-shadow: 0 8px 26px rgba(99,102,241,0.28); + } + .header-right { display: flex; gap: 10px; @@ -344,6 +376,119 @@ letter-spacing: 0.02em; } + .mode-panel { + display: flex; + flex-direction: column; + gap: 14px; + flex: 1; + min-height: 0; + } + + .mode-panel.hidden { display: none; } + + .cron-panel { + padding: 16px; + display: flex; + flex-direction: column; + gap: 14px; + } + + .cron-panel-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; + flex-wrap: wrap; + } + + .cron-panel-title { + color: var(--text-primary); + font-size: 16px; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; + } + + .cron-panel-subtitle { + color: var(--text-muted); + font-size: 12.5px; + margin-top: 4px; + } + + .cron-grid { + display: grid; + grid-template-columns: minmax(180px, 260px) minmax(0, 1fr); + gap: 12px; + } + + .cron-field { + width: 100%; + background: rgba(10, 10, 11, 0.55); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + padding: 10px 12px; + color: var(--text-primary); + outline: none; + } + + .cron-editor { + width: 100%; + min-height: 280px; + resize: vertical; + background: rgba(10, 10, 11, 0.55); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + padding: 12px; + color: var(--text-primary); + outline: none; + font-family: 'JetBrains Mono', 'Menlo', 'Monaco', 'Courier New', monospace; + font-size: 13px; + line-height: 1.55; + } + + .cron-field:focus, + .cron-editor:focus { + border-color: rgba(99, 102, 241, 0.55); + box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.18); + } + + .cron-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + } + + .cron-results { + min-height: 180px; + max-height: 420px; + overflow: auto; + background: rgba(10, 10, 11, 0.55); + border: 1px solid var(--glass-border); + border-radius: var(--radius-sm); + padding: 12px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .cron-result-row { + display: grid; + grid-template-columns: minmax(140px, 220px) 1fr; + gap: 10px; + padding: 10px; + border-radius: var(--radius-sm); + background: rgba(255,255,255,0.03); + border: 1px solid var(--glass-border); + font-size: 12.5px; + } + + .cron-result-row.ok { border-color: rgba(34,197,94,0.45); } + .cron-result-row.fail { border-color: rgba(239,68,68,0.45); } + .cron-result-node { color: var(--text-primary); font-weight: 600; } + .cron-result-message { color: var(--text-secondary); font-family: 'JetBrains Mono', monospace; white-space: pre-wrap; word-break: break-word; } + /* ── Output area ─────────────────────────────────────────────────── */ .output-area { flex: 1; @@ -592,6 +737,10 @@ .command-actions .btn { flex: 1; } .hint { display: none; } .node-selector { max-height: 120px; } + .cron-grid { grid-template-columns: 1fr; } + .cron-actions { justify-content: stretch; } + .cron-actions .btn { flex: 1; } + .cron-result-row { grid-template-columns: 1fr; } } @@ -619,6 +768,22 @@ timeout: typeof t !== 'undefined' ? t('broadcast.timeout') : 'Timed out', exitCode: typeof t !== 'undefined' ? t('broadcast.exitCode') : 'exit', empty: typeof t !== 'undefined' ? t('broadcast.empty') : 'Execute a command to see output', + terminalTab: typeof t !== 'undefined' ? t('broadcast.terminalTab') : 'Terminal', + cronTab: typeof t !== 'undefined' ? t('broadcast.cronTab') : 'Multicast Cron', + betaBadge: typeof t !== 'undefined' ? t('network.betaBadge') : 'BETA', + cronTitle: typeof t !== 'undefined' ? t('broadcast.cronTitle') : 'Multicast Cron', + cronUser: typeof t !== 'undefined' ? t('broadcast.cronUser') : 'Cron user', + cronRawBlock: typeof t !== 'undefined' ? t('broadcast.cronRawBlock') : 'Raw cron block', + cronRawPlaceholder: typeof t !== 'undefined' ? t('broadcast.cronRawPlaceholder'): '*/5 * * * * /opt/job.sh', + cronSave: typeof t !== 'undefined' ? t('broadcast.cronSave') : 'Save C³ block', + cronSaveRun: typeof t !== 'undefined' ? t('broadcast.cronSaveRun') : 'Save + Run now', + cronResults: typeof t !== 'undefined' ? t('broadcast.cronResults') : 'Cron results', + cronSaved: typeof t !== 'undefined' ? t('broadcast.cronSaved') : 'Saved', + cronSkipped: typeof t !== 'undefined' ? t('broadcast.cronSkipped') : 'No changes', + cronFailed: typeof t !== 'undefined' ? t('broadcast.cronFailed') : 'Failed', + cronRunFailed: typeof t !== 'undefined' ? t('broadcast.cronRunFailed') : 'Run failed', + cronSelectNodes: typeof t !== 'undefined' ? t('broadcast.cronSelectNodes') : 'Select at least one node', + cronEmpty: typeof t !== 'undefined' ? t('broadcast.cronEmpty') : 'Save a cron block to see per-node results', }; %> @@ -631,6 +796,14 @@