#!/usr/bin/env node /** * TuneVault Oracle HTTP Proxy * =========================== * Runs on your Oracle server. Exposes an HTTP endpoint that TuneVault * calls through your HTTPS proxy to collect Oracle health metrics. * * Architecture: * TuneVault (Render) → HTTPS → Outbound Proxy → localhost:3100 → Oracle :1521 * * Quick Start: * export TUNEVAULT_API_KEY="your-secret-key-here" * node oracle-proxy.js * * With PM2 (recommended for production): * npm install -g pm2 * TUNEVAULT_API_KEY="your-key" pm2 start oracle-proxy.js --name tunevault-proxy * pm2 save && pm2 startup * * Requirements: * - Node.js 18+ * - npm install oracledb (thin mode — no Oracle Instant Client needed) * - TUNEVAULT_API_KEY environment variable * * The proxy listens on 127.0.0.1:3100 only. * Your HTTPS proxy should point to http://localhost:3100 * * Update your proxy config to route: * hostname: oracledb.yourdomain.com * service: http://localhost:3100 (was: tcp://localhost:1521) */ 'use strict'; const http = require('http'); const { execFile, execFileSync } = require('child_process'); const oracledb = require('oracledb'); // ============================================================ // Config // ============================================================ const PORT = parseInt(process.env.PORT || '3100', 10); const HOST = process.env.BIND_HOST || '127.0.0.1'; // localhost only — outbound proxy handles external const API_KEY = process.env.TUNEVAULT_API_KEY; if (!API_KEY) { console.error('FATAL: TUNEVAULT_API_KEY environment variable is required.'); console.error('Set it with: export TUNEVAULT_API_KEY="your-secret-key-here"'); process.exit(1); } // oracledb 6.x defaults to thin mode — no Instant Client needed // Thin mode supports Oracle 12.1+ // ============================================================ // Shell Command Whitelist (EBS 12.2.x application tier only) // ============================================================ // Only status subcommands are whitelisted — NO start/stop/restart. // All commands use $ADMIN_SCRIPTS_HOME — NO hardcoded paths. // admanagedsrvctl.sh is the unified controller for all managed servers. // Individual *ctl.sh scripts are for non-managed services only. // // Entry format: { script, args, purpose } // script — filename only; resolved via ADMIN_SCRIPTS_HOME at runtime // args — array of allowed arguments (exact match required) // purpose — description for audit log and security page const SHELL_COMMAND_WHITELIST = [ { script: 'adcmctl.sh', args: ['status'], purpose: 'Concurrent Manager status' }, { script: 'adalnctl.sh', args: ['status'], purpose: 'APPS TNS Listener status' }, { script: 'admanagedsrvctl.sh', args: ['status', 'oacore_server1'], purpose: 'OACore managed server status' }, { script: 'admanagedsrvctl.sh', args: ['status', 'forms_server1'], purpose: 'Forms managed server status' }, { script: 'admanagedsrvctl.sh', args: ['status', 'oafm_server1'], purpose: 'OA Framework managed server status' }, { script: 'admanagedsrvctl.sh', args: ['status', 'wfmlrsvc'], purpose: 'Workflow Mailer managed service status' }, { script: 'admanagedsrvctl.sh', args: ['status', 'opp'], purpose: 'Output Post Processor status' }, { script: 'adadminsrvctl.sh', args: ['status'], purpose: 'Admin Server status' }, { script: 'adnodemgrctl.sh', args: ['status'], purpose: 'Node Manager status' }, { script: 'adopmnctl.sh', args: ['status'], purpose: 'OPMN status' }, { script: 'mwactl.sh', args: ['status'], purpose: 'Middleware Agent status' }, { script: 'adapcctl.sh', args: ['status'], purpose: 'Apache/OHS status' }, ]; // Build a lookup key for O(1) whitelist validation // Key = "script:arg1:arg2:..." e.g. "admanagedsrvctl.sh:status:oacore_server1" const WHITELIST_KEY_SET = new Set( SHELL_COMMAND_WHITELIST.map(e => [e.script, ...e.args].join(':')) ); // Resolve ADMIN_SCRIPTS_HOME from env — required when shell commands are used. // No fallback: if it is not set the endpoint rejects immediately with a clear error. // Customers set this in their PM2/systemd environment before starting the proxy. function getAdminScriptsHome() { return process.env.ADMIN_SCRIPTS_HOME || null; } // ============================================================ // OS Command Whitelist (for /api/os/exec endpoint) // ============================================================ // Commands sent as the "command" field in the request body. // Exact-match keys resolve to an argv array. No shell=true. // Dynamic tail/cat commands use prefix-validated path resolution. // Max output: 64KB. Timeout: 10s. // allow_role: "any" | "apps" (apps requires PROXY_ROLE=apps env var) const OS_CMD_WHITELIST = [ // DB Tier { key: 'df -h', argv: ['df', '-h'], allowRole: 'any' }, { key: 'free -m', argv: ['free', '-m'], allowRole: 'any' }, { key: 'uptime', argv: ['uptime'], allowRole: 'any' }, { key: "cat /proc/cpuinfo | grep processor | wc -l", argv: ['sh', '-c', 'grep processor /proc/cpuinfo | wc -l'], allowRole: 'any' }, { key: 'top -bn1 | head -20', argv: ['sh', '-c', 'top -bn1 | head -20'], allowRole: 'any' }, { key: "ps aux | grep -E '(ora_|tnslsnr)'", argv: ['sh', '-c', "ps aux | grep -E '(ora_|tnslsnr)'"], allowRole: 'any' }, { key: 'cat /etc/os-release', argv: ['cat', '/etc/os-release'], allowRole: 'any' }, { key: 'vmstat 1 3', argv: ['vmstat', '1', '3'], allowRole: 'any' }, // EBS Apps Tier { key: "ps aux | grep -E '(FNDLIBR|FNDSM|OAFM|oacore|forms)'", argv: ['sh', '-c', "ps aux | grep -E '(FNDLIBR|FNDSM|OAFM|oacore|forms)'"], allowRole: 'apps' }, { key: 'ls -la $INST_TOP/logs/', argv: ['sh', '-c', 'ls -la $INST_TOP/logs/'], allowRole: 'apps' }, ]; const OS_CMD_MAP = new Map(OS_CMD_WHITELIST.map(e => [e.key, e])); // Allowed absolute path prefixes for tail/cat dynamic commands const TAIL_ALLOWED_PREFIXES = [ '/u01/', '/u02/', '/u03/', '/u04/', '/oracle/', '/app/oracle/', '/opt/oracle/', '/prod/', '/d01/', '/d02/', ]; if (process.env.ORACLE_BASE) TAIL_ALLOWED_PREFIXES.push(process.env.ORACLE_BASE); if (process.env.INST_TOP) TAIL_ALLOWED_PREFIXES.push(process.env.INST_TOP); // Compiled safe-path: printable ASCII, no shell metacharacters, no .. const SAFE_PATH_RE = /^[a-zA-Z0-9_/.\-]+$/; function validateTailPath(rawPath) { const path = (rawPath || '').trim(); if (!path.startsWith('/')) return { path: null, error: 'path must be absolute' }; if (path.split('/').includes('..')) return { path: null, error: 'path traversal not allowed' }; if (!SAFE_PATH_RE.test(path)) return { path: null, error: 'path contains unsafe characters' }; const allowed = TAIL_ALLOWED_PREFIXES.filter(Boolean); if (!allowed.some(p => path.startsWith(p))) { return { path: null, error: 'path not under allowed prefix; allowed: ' + allowed.join(', ') }; } return { path, error: null }; } function runOsCommand(argv, timeout = 10000, maxOutput = 65536) { return new Promise((resolve) => { const t0 = Date.now(); // Use execFile for non-sh commands, exec for sh -c pipelines const bin = argv[0]; const args = argv.slice(1); execFile(bin, args, { timeout, maxBuffer: maxOutput + 1024 }, (err, stdout, stderr) => { const durationMs = Date.now() - t0; let out = stdout || ''; if (out.length > maxOutput) out = out.slice(0, maxOutput) + '\n[truncated]'; const exitCode = err ? (typeof err.code === 'number' ? err.code : 1) : 0; // timeout produces ETIMEDOUT; treat as 124 const finalExit = (err && err.killed) ? 124 : exitCode; resolve({ stdout: out, stderr: stderr || '', exit_code: finalExit, duration_ms: durationMs }); }); }); } // ============================================================ // HTTP Server // ============================================================ const server = http.createServer(async (req, res) => { // CORS headers (not needed for reverse proxy, but safe) res.setHeader('Content-Type', 'application/json'); res.setHeader('X-Powered-By', 'TuneVault-Proxy'); // Health ping — no auth required if (req.method === 'GET' && req.url === '/health') { return send(res, 200, { status: 'healthy', proxy: 'TuneVault Oracle Proxy', version: '3.5.7', proxy_version: '3.5.7' }); } // All other endpoints require API key const authHeader = req.headers['x-api-key'] || req.headers['authorization']?.replace('Bearer ', ''); if (authHeader !== API_KEY) { return send(res, 401, { error: 'Unauthorized — invalid or missing API key' }); } // POST /api/healthcheck — run Oracle health queries if (req.method === 'POST' && req.url === '/api/healthcheck') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const params = JSON.parse(body || '{}'); const { service_name, username, password, host, port } = params; // AWR (DBA_HIST) reads are Diagnostics Pack usage. The server sends // awr_allowed based on the customer's per-connection consent; default // false = no AWR. Older servers omit it → treated as false (fail-closed). const awrAllowed = params.awr_allowed === true; if (!service_name || !username || !password) { return send(res, 400, { error: 'service_name, username, and password are required' }); } // Connect to Oracle locally const oracleHost = host || 'localhost'; const oraclePort = parseInt(port || '1521', 10); console.log(`[${new Date().toISOString()}] Health check: ${username}@${oracleHost}:${oraclePort}/${service_name} awr_allowed=${awrAllowed}`); const metrics = await collectMetrics({ host: oracleHost, port: oraclePort, serviceName: service_name, username, password, awrAllowed }); return send(res, 200, { success: true, metrics }); } catch (err) { console.error('Health check failed:', err.message); return send(res, 500, { success: false, error: formatOracleError(err) }); } }); return; } // POST /api/test — RETIRED (direct-connect testing path removed in v3.5.7) // Architecture rule: all proxy testing goes through the outbound long-poll channel // or the local CLI. No inbound direct-connect endpoints. if (req.method === 'POST' && req.url === '/api/test') { return send(res, 410, { error: 'Gone — /api/test was the legacy direct-connect testing path and has been retired.', replacement: { on_proxy_host: 'sudo tunevault-proxy diagnose', in_product: 'https://tunevault.app/connections/:id → Run Diagnostics', }, architecture_doc: 'https://tunevault.app/docs/architecture#outbound-long-poll', }); } // POST /api/addm — on-demand ADDM findings (Enterprise Edition + Diagnostics Pack) if (req.method === 'POST' && req.url === '/api/addm') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const params = JSON.parse(body || '{}'); const { service_name, username, password, host, port, lookback_hours } = params; if (!service_name || !username || !password) { return send(res, 400, { error: 'service_name, username, and password are required' }); } const oracleHost = host || 'localhost'; const oraclePort = parseInt(port || '1521', 10); const lookbackHours = Math.min(parseInt(lookback_hours) || 24, 168); console.log(`[${new Date().toISOString()}] ADDM query: ${username}@${oracleHost}:${oraclePort}/${service_name} (${lookbackHours}h)`); const result = await queryAddmFindings({ host: oracleHost, port: oraclePort, serviceName: service_name, username, password }, lookbackHours); return send(res, 200, { success: true, result }); } catch (err) { console.error('ADDM query failed:', err.message); return send(res, 500, { success: false, error: formatOracleError(err) }); } }); return; } // POST /api/addm-run — create AWR snapshot + run ADDM analysis (EE + Diagnostics Pack) if (req.method === 'POST' && req.url === '/api/addm-run') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const params = JSON.parse(body || '{}'); const { service_name, username, password, host, port } = params; if (!service_name || !username || !password) { return send(res, 400, { error: 'service_name, username, and password are required' }); } const oracleHost = host || 'localhost'; const oraclePort = parseInt(port || '1521', 10); console.log(`[${new Date().toISOString()}] ADDM run-now: ${username}@${oracleHost}:${oraclePort}/${service_name}`); const result = await runAddmNow({ host: oracleHost, port: oraclePort, serviceName: service_name, username, password }); return send(res, 200, { success: true, result }); } catch (err) { console.error('ADDM run-now failed:', err.message); return send(res, 500, { success: false, error: formatOracleError(err) }); } }); return; } // POST /api/maintenance — auto-maintenance window status (all editions) if (req.method === 'POST' && req.url === '/api/maintenance') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const params = JSON.parse(body || '{}'); const { service_name, username, password, host, port } = params; if (!service_name || !username || !password) { return send(res, 400, { error: 'service_name, username, and password are required' }); } const oracleHost = host || 'localhost'; const oraclePort = parseInt(port || '1521', 10); console.log(`[${new Date().toISOString()}] Maintenance query: ${username}@${oracleHost}:${oraclePort}/${service_name}`); const result = await queryMaintenanceWindows({ host: oracleHost, port: oraclePort, serviceName: service_name, username, password }); return send(res, 200, { success: true, result }); } catch (err) { console.error('Maintenance query failed:', err.message); return send(res, 500, { success: false, error: formatOracleError(err) }); } }); return; } // POST /api/parameters — Oracle init parameters with recommended values (all editions) if (req.method === 'POST' && req.url === '/api/parameters') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { try { const params = JSON.parse(body || '{}'); const { service_name, username, password, host, port } = params; if (!service_name || !username || !password) { return send(res, 400, { error: 'service_name, username, and password are required' }); } const oracleHost = host || 'localhost'; const oraclePort = parseInt(port || '1521', 10); console.log(`[${new Date().toISOString()}] Parameters query: ${username}@${oracleHost}:${oraclePort}/${service_name}`); const result = await queryOracleParameters({ host: oracleHost, port: oraclePort, serviceName: service_name, username, password }); return send(res, 200, { success: true, result }); } catch (err) { console.error('Parameters query failed:', err.message); return send(res, 500, { success: false, error: formatOracleError(err) }); } }); return; } // POST /api/shell-command — execute a whitelisted EBS application-tier status command // Requires: { script, args } where [script, ...args] matches an entry in SHELL_COMMAND_WHITELIST. // Uses ADMIN_SCRIPTS_HOME env var — rejects if not set. // Execution log: ISO timestamp, requester IP, script+args, exit code, SHA-256 of stdout. if (req.method === 'POST' && req.url === '/api/shell-command') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { const ts = new Date().toISOString(); const requesterIp = req.socket?.remoteAddress || 'unknown'; let script, args; try { const parsed = JSON.parse(body || '{}'); script = parsed.script; args = Array.isArray(parsed.args) ? parsed.args : []; } catch (e) { return send(res, 400, { error: 'Invalid JSON body' }); } if (!script) { return send(res, 400, { error: '"script" is required (filename only, no path)' }); } // Reject any path traversal in the script field if (script.includes('/') || script.includes('..') || script.includes('\0')) { console.log(`[${ts}] SHELL_REJECTED ip=${requesterIp} script=${script} reason=path_traversal`); return send(res, 400, { error: 'script must be a filename only — no path separators' }); } // Validate all arg values for safety (no shell metacharacters) const SAFE_ARG = /^[a-zA-Z0-9._\-]+$/; for (const arg of args) { if (!SAFE_ARG.test(arg)) { console.log(`[${ts}] SHELL_REJECTED ip=${requesterIp} script=${script} reason=unsafe_arg arg=${arg}`); return send(res, 400, { error: `Unsafe argument: ${arg}` }); } } // Whitelist check — exact match on script + args combination const lookupKey = [script, ...args].join(':'); if (!WHITELIST_KEY_SET.has(lookupKey)) { console.log(`[${ts}] SHELL_REJECTED ip=${requesterIp} key=${lookupKey} reason=not_whitelisted`); return send(res, 403, { error: 'Command not in whitelist', requested: lookupKey, hint: 'Only status subcommands for EBS 12.2.x application tier are whitelisted.' }); } const adminScriptsHome = getAdminScriptsHome(); if (!adminScriptsHome) { console.log(`[${ts}] SHELL_REJECTED ip=${requesterIp} key=${lookupKey} reason=no_ADMIN_SCRIPTS_HOME`); return send(res, 503, { error: 'ADMIN_SCRIPTS_HOME environment variable is not set on this proxy', hint: 'Set ADMIN_SCRIPTS_HOME in the proxy environment before using shell commands.' }); } const scriptPath = `${adminScriptsHome}/${script}`; // Find the whitelist entry for this key to get the purpose string const entry = SHELL_COMMAND_WHITELIST.find(e => [e.script, ...e.args].join(':') === lookupKey); console.log(`[${ts}] SHELL_EXEC ip=${requesterIp} script=${scriptPath} args=${args.join(' ')} purpose="${entry?.purpose}"`); try { const { stdout, stderr, exitCode } = await runShellCommand(scriptPath, args); // Hash output for audit log — prevents storing sensitive content in logs const { createHash } = require('crypto'); const outputHash = createHash('sha256').update(stdout).digest('hex').slice(0, 16); console.log(`[${ts}] SHELL_DONE ip=${requesterIp} script=${script} args=${args.join(' ')} exit=${exitCode} output_hash=${outputHash}`); return send(res, 200, { success: true, script, args, purpose: entry?.purpose, exit_code: exitCode, stdout, stderr: stderr || '', output_hash: outputHash, executed_at: ts }); } catch (err) { console.log(`[${ts}] SHELL_ERROR ip=${requesterIp} script=${script} args=${args.join(' ')} err=${err.message}`); return send(res, 500, { success: false, error: err.message }); } }); return; } // POST /api/execute-sql — execute a whitelisted SQL statement and return results as JSON. // Accepts: { service_name, username, password, host, port, sql } // The TuneVault server enforces the command whitelist before calling this endpoint. // The proxy executes the SQL as-received — no second whitelist check here. // 30s query timeout. SELECT results capped at 500 rows. DML returns rowsAffected. if (req.method === 'POST' && req.url === '/api/execute-sql') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { const ts = new Date().toISOString(); const requesterIp = req.socket?.remoteAddress || 'unknown'; let params; try { params = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { error: 'Invalid JSON body' }); } const { service_name, username, password, host, port, sql } = params; if (!service_name || !username || !password) { return send(res, 400, { error: 'service_name, username, and password are required' }); } if (!sql || !sql.trim()) { return send(res, 400, { error: 'sql is required' }); } const oracleHost = host || 'localhost'; const oraclePort = parseInt(port || '1521', 10); console.log(`[${ts}] EXECUTE_SQL ip=${requesterIp} target=${username}@${oracleHost}:${oraclePort}/${service_name} sql_len=${sql.length}`); let connection; const t0 = Date.now(); try { const connectString = `${oracleHost}:${oraclePort}/${service_name}`; connection = await oracledb.getConnection({ user: username, password: password, connectString, connectTimeout: 20, }); const sqlTrimmed = sql.trim().replace(/;+$/, ''); // strip trailing semicolons (OCI rejects them) const result = await connection.execute(sqlTrimmed, [], { outFormat: oracledb.OUT_FORMAT_OBJECT, fetchArraySize: 500, maxRows: 500, // 30-second execution timeout callTimeout: 30000, }); const durationMs = Date.now() - t0; if (result.metaData && result.rows !== undefined) { // SELECT result — serialize rows to plain objects const columns = result.metaData.map(c => c.name); const rows = (result.rows || []).map(row => { const out = {}; columns.forEach(col => { const v = row[col]; if (v instanceof Date) out[col] = v.toISOString(); else if (Buffer.isBuffer(v)) out[col] = v.toString('hex'); else out[col] = v; }); return out; }); console.log(`[${ts}] EXECUTE_SQL_DONE ip=${requesterIp} rows=${rows.length} duration_ms=${durationMs}`); return send(res, 200, { success: true, columns, rows, duration_ms: durationMs }); } else { // DML / DDL result console.log(`[${ts}] EXECUTE_SQL_DONE ip=${requesterIp} rows_affected=${result.rowsAffected || 0} duration_ms=${durationMs}`); return send(res, 200, { success: true, rowsAffected: result.rowsAffected || 0, duration_ms: durationMs }); } } catch (err) { const durationMs = Date.now() - t0; const oraError = formatOracleError(err); console.log(`[${ts}] EXECUTE_SQL_ERROR ip=${requesterIp} duration_ms=${durationMs} err=${oraError}`); return send(res, 200, { success: false, error: oraError, duration_ms: durationMs }); } finally { if (connection) { try { await connection.close(); } catch (_) {} } } }); return; } // POST /api/os/exec — run a whitelisted OS command on the local server. // Accepts: { command: "" } // Returns: { stdout, stderr, exit_code, duration_ms } // Security: strict whitelist match; no shell=true for exact-key commands; // dynamic tail/cat paths validated against allowed prefixes. if (req.method === 'POST' && req.url === '/api/os/exec') { let body = ''; req.on('data', chunk => { body += chunk.toString(); }); req.on('end', async () => { const ts = new Date().toISOString(); const ip = req.socket?.remoteAddress || 'unknown'; let params; try { params = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { error: 'Invalid JSON body' }); } const commandKey = (params.command || '').trim(); if (!commandKey) return send(res, 400, { error: '"command" is required' }); const proxyRole = (process.env.PROXY_ROLE || 'db').toLowerCase(); // ── Dynamic tail with path ───────────────────────────────────────────── const tailMatch = commandKey.match(/^tail -n (\d+) (.+)$/); if (tailMatch) { const lines = Math.min(parseInt(tailMatch[1], 10), 500); const { path, error } = validateTailPath(tailMatch[2]); if (error) { console.log(`[${ts}] OS_EXEC_REJECT ip=${ip} key=${commandKey} reason=${error}`); return send(res, 403, { error: `rejected: ${error}` }); } console.log(`[${ts}] OS_EXEC ip=${ip} tail lines=${lines} path=${path}`); const result = await runOsCommand(['tail', '-n', String(lines), path]); return send(res, 200, result); } // ── Dynamic cat with path ────────────────────────────────────────────── const catMatch = commandKey.match(/^cat (\/[^ ]+)$/); if (catMatch) { const { path, error } = validateTailPath(catMatch[1]); if (error) { console.log(`[${ts}] OS_EXEC_REJECT ip=${ip} key=${commandKey} reason=${error}`); return send(res, 403, { error: `rejected: ${error}` }); } console.log(`[${ts}] OS_EXEC ip=${ip} cat path=${path}`); const result = await runOsCommand(['cat', path]); return send(res, 200, result); } // ── Exact whitelist match ────────────────────────────────────────────── const entry = OS_CMD_MAP.get(commandKey); if (!entry) { console.log(`[${ts}] OS_EXEC_REJECT ip=${ip} key=${commandKey} reason=not_whitelisted`); return send(res, 403, { error: 'command not whitelisted', received: commandKey }); } if (entry.allowRole === 'apps' && proxyRole !== 'apps') { console.log(`[${ts}] OS_EXEC_REJECT ip=${ip} key=${commandKey} reason=role_mismatch proxy_role=${proxyRole}`); return send(res, 403, { error: 'this command requires PROXY_ROLE=apps on the proxy server' }); } console.log(`[${ts}] OS_EXEC ip=${ip} key=${commandKey}`); const result = await runOsCommand(entry.argv); return send(res, 200, result); }); return; } return send(res, 404, { error: 'Not found' }); }); function send(res, status, data) { res.writeHead(status); res.end(JSON.stringify(data)); } // ============================================================ // Shell Command Execution // ============================================================ // Wraps execFile in a promise. Uses the pre-validated scriptPath and args array. // 30s timeout — status commands should return in < 5s; this guards against hangs. // Never spawns a shell (execFile, not exec) — no shell interpolation of arguments. function runShellCommand(scriptPath, args) { return new Promise((resolve, reject) => { execFile(scriptPath, args, { timeout: 30000, maxBuffer: 256 * 1024 }, (err, stdout, stderr) => { if (err && err.code === 'ENOENT') { return reject(new Error(`Script not found: ${scriptPath} — check ADMIN_SCRIPTS_HOME`)); } // Non-zero exit is normal for "service not running" — return it, don't throw const exitCode = err ? (err.code || 1) : 0; resolve({ stdout: stdout || '', stderr: stderr || '', exitCode }); }); }); } server.listen(PORT, HOST, () => { console.log(`TuneVault Oracle Proxy listening on ${HOST}:${PORT}`); console.log(`Health: http://localhost:${PORT}/health`); console.log(`Proxy is ready. Update your outbound HTTPS proxy to route to http://localhost:${PORT}`); }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { console.error(`Port ${PORT} is already in use. Change with: PORT=3101 node oracle-proxy.js`); } else { console.error('Server error:', err.message); } process.exit(1); }); // ============================================================ // Oracle Connection Test // ============================================================ async function testConnection({ host, port, serviceName, username, password }) { let connection; try { const connectString = `${host}:${port}/${serviceName}`; connection = await oracledb.getConnection({ user: username, password: password, connectString, connectTimeout: 15 }); const result = await connection.execute(`SELECT banner FROM v$version WHERE ROWNUM = 1`); const version = result.rows?.[0]?.[0] || 'Connected'; return { success: true, message: 'Connection successful', version }; } catch (err) { return { success: false, message: formatOracleError(err) }; } finally { if (connection) { try { await connection.close(); } catch (e) { /* ignore */ } } } } // ============================================================ // Oracle Metrics Collection // ============================================================ async function collectMetrics({ host, port, serviceName, username, password, awrAllowed = false }) { let connection; try { const connectString = `${host}:${port}/${serviceName}`; connection = await oracledb.getConnection({ user: username, password: password, connectString, connectTimeout: 30 }); const awrAvailable = await checkAwrAvailability(connection, awrAllowed); const [instanceInfo, tablespaces, waitEvents, topSql, indexAnalysis, sgaStats, pgaStats, osStats, undoStats, tempStats, alertLog, resourceLimits, sgaPgaHistory, backupStats, appsHealth, dbObjects, sessionStats, securityStats, schemaStats] = await Promise.all([ queryInstanceInfo(connection), queryTablespaces(connection), queryWaitEvents(connection), queryTopSql(connection), queryIndexAnalysis(connection), querySgaStats(connection), queryPgaStats(connection), queryOsStats(connection), queryUndoStats(connection, awrAvailable), queryTempStats(connection, awrAvailable), queryAlertLog(connection), queryResourceLimits(connection, awrAvailable), querySgaPgaHistory(connection, awrAvailable), queryBackupStats(connection), queryAppsHealth(connection), queryDbObjects(connection), querySessionStats(connection), querySecurityStats(connection), querySchemaStats(connection) ]); return { instance: instanceInfo, tablespaces, wait_events: waitEvents, top_sql: topSql, index_analysis: indexAnalysis, sga_stats: sgaStats, pga_stats: pgaStats, redo_stats: { redo_size_mb_per_hour: 0, log_switches_per_hour: 0, log_file_size_mb: 0, log_groups: 0, avg_log_sync_ms: 0, max_log_sync_ms: 0 }, os_stats: osStats, undo_stats: undoStats, temp_stats: tempStats, alert_log: alertLog, resource_limits: resourceLimits, sga_pga_history: sgaPgaHistory, backup_stats: backupStats, apps_health: appsHealth, db_objects: dbObjects, session_stats: sessionStats, security_stats: securityStats, schema_stats: schemaStats, awr_available: awrAvailable, proxy_version: '3.5.3', snapshot_info: { begin_snap_id: 0, end_snap_id: 0, begin_time: new Date(Date.now() - 12 * 60 * 60 * 1000).toISOString(), end_time: new Date().toISOString(), elapsed_time_min: 720, db_time_min: 0 } }; } finally { if (connection) { try { await connection.close(); } catch (e) { /* ignore */ } } } } // ============================================================ // Query Functions // ============================================================ async function queryInstanceInfo(conn) { try { const result = await conn.execute(` SELECT d.NAME as db_name, i.INSTANCE_NAME, i.HOST_NAME, i.VERSION, d.PLATFORM_NAME, TO_CHAR(i.STARTUP_TIME, 'YYYY-MM-DD HH24:MI:SS') as startup_time, ROUND(SYSDATE - i.STARTUP_TIME) as uptime_days, (SELECT VALUE FROM v$parameter WHERE name = 'cpu_count') as cpus, ROUND((SELECT TO_NUMBER(VALUE)/1024/1024/1024 FROM v$parameter WHERE name = 'sga_target'), 1) as sga_target_gb, ROUND((SELECT TO_NUMBER(VALUE)/1024/1024/1024 FROM v$parameter WHERE name = 'pga_aggregate_target'), 1) as pga_target_gb, (SELECT TO_NUMBER(VALUE) FROM v$parameter WHERE name = 'db_block_size') as db_block_size FROM v$database d, v$instance i `); const row = result.rows?.[0]; if (!row) throw new Error('No instance data'); return { db_name: row[0] || 'UNKNOWN', instance_name: row[1] || 'unknown', host_name: row[2] || 'unknown', version: row[3] || 'Unknown', platform: row[4] || 'Unknown', startup_time: row[5] || '', uptime_days: row[6] || 0, rac: false, cpus: parseInt(row[7]) || 1, sga_target_gb: parseFloat(row[8]) || 0, pga_aggregate_target_gb: parseFloat(row[9]) || 0, db_block_size: parseInt(row[10]) || 8192 }; } catch (err) { console.error('Instance query failed:', err.message); try { const r1 = await conn.execute(`SELECT name FROM v$database`); const r2 = await conn.execute(`SELECT instance_name, host_name, version FROM v$instance`); return { db_name: r1.rows?.[0]?.[0] || 'UNKNOWN', instance_name: r2.rows?.[0]?.[0] || 'unknown', host_name: r2.rows?.[0]?.[1] || 'unknown', version: r2.rows?.[0]?.[2] || 'Unknown', platform: 'Unknown', startup_time: '', uptime_days: 0, rac: false, cpus: 1, sga_target_gb: 0, pga_aggregate_target_gb: 0, db_block_size: 8192 }; } catch (e2) { return { db_name: 'UNKNOWN', instance_name: 'unknown', host_name: 'unknown', version: 'Unknown', platform: 'Unknown', startup_time: '', uptime_days: 0, rac: false, cpus: 1, sga_target_gb: 0, pga_aggregate_target_gb: 0, db_block_size: 8192 }; } } } async function queryTablespaces(conn) { try { const result = await conn.execute(` SELECT ts.TABLESPACE_NAME, ROUND(um.USED_SPACE * ts_block.BLOCK_SIZE / 1024 / 1024 / 1024, 1) as used_gb, ROUND(um.TABLESPACE_SIZE * ts_block.BLOCK_SIZE / 1024 / 1024 / 1024, 1) as total_gb, ROUND(um.USED_PERCENT, 1) as pct_used, CASE WHEN df.autoext > 0 THEN 1 ELSE 0 END as autoextend FROM DBA_TABLESPACE_USAGE_METRICS um JOIN DBA_TABLESPACES ts ON ts.TABLESPACE_NAME = um.TABLESPACE_NAME LEFT JOIN (SELECT TABLESPACE_NAME, BLOCK_SIZE FROM DBA_TABLESPACES) ts_block ON ts_block.TABLESPACE_NAME = um.TABLESPACE_NAME LEFT JOIN ( SELECT TABLESPACE_NAME, SUM(CASE WHEN AUTOEXTENSIBLE = 'YES' THEN 1 ELSE 0 END) as autoext FROM DBA_DATA_FILES GROUP BY TABLESPACE_NAME ) df ON df.TABLESPACE_NAME = um.TABLESPACE_NAME ORDER BY um.USED_PERCENT DESC `); return (result.rows || []).map(row => { const pct = parseFloat(row[3]) || 0; return { name: row[0], used_gb: parseFloat(row[1]) || 0, total_gb: parseFloat(row[2]) || 0, pct_used: pct, autoextend: row[4] > 0, status: pct > 90 ? 'critical' : pct > 80 ? 'warning' : 'ok' }; }); } catch (err) { console.error('Tablespace primary query failed:', err.message); try { const result = await conn.execute(` SELECT df.TABLESPACE_NAME, ROUND(SUM(df.BYTES) / 1024 / 1024 / 1024, 1) as total_gb, ROUND((SUM(df.BYTES) - NVL(fs.free_bytes, 0)) / 1024 / 1024 / 1024, 1) as used_gb, ROUND((1 - NVL(fs.free_bytes, 0) / SUM(df.BYTES)) * 100, 1) as pct_used, MAX(CASE WHEN df.AUTOEXTENSIBLE = 'YES' THEN 1 ELSE 0 END) as autoextend FROM DBA_DATA_FILES df LEFT JOIN (SELECT TABLESPACE_NAME, SUM(BYTES) as free_bytes FROM DBA_FREE_SPACE GROUP BY TABLESPACE_NAME) fs ON fs.TABLESPACE_NAME = df.TABLESPACE_NAME GROUP BY df.TABLESPACE_NAME, fs.free_bytes ORDER BY pct_used DESC `); return (result.rows || []).map(row => { const pct = parseFloat(row[3]) || 0; return { name: row[0], used_gb: parseFloat(row[2]) || 0, total_gb: parseFloat(row[1]) || 0, pct_used: pct, autoextend: row[4] > 0, status: pct > 90 ? 'critical' : pct > 80 ? 'warning' : 'ok' }; }); } catch (e2) { return []; } } } async function queryWaitEvents(conn) { try { const result = await conn.execute(` SELECT EVENT, WAIT_CLASS, TOTAL_WAITS, ROUND(TIME_WAITED / 100, 1) as time_waited_s, CASE WHEN TOTAL_WAITS > 0 THEN ROUND((TIME_WAITED / 100 / TOTAL_WAITS) * 1000, 2) ELSE 0 END as avg_wait_ms FROM V$SYSTEM_EVENT WHERE WAIT_CLASS NOT IN ('Idle') AND TOTAL_WAITS > 0 ORDER BY TIME_WAITED DESC FETCH FIRST 15 ROWS ONLY `); const rows = result.rows || []; const totalTime = rows.reduce((s, r) => s + (parseFloat(r[3]) || 0), 0); return rows.map(row => ({ event: row[0], wait_class: row[1], total_waits: parseInt(row[2]) || 0, time_waited_s: parseFloat(row[3]) || 0, avg_wait_ms: parseFloat(row[4]) || 0, pct_db_time: totalTime > 0 ? Math.round((parseFloat(row[3]) / totalTime) * 1000) / 10 : 0 })); } catch (err) { console.error('Wait events query failed:', err.message); return []; } } async function queryTopSql(conn) { try { const result = await conn.execute(` SELECT SQL_ID, SUBSTR(SQL_TEXT, 1, 500), EXECUTIONS, ROUND(ELAPSED_TIME / 1000000, 1), ROUND(CPU_TIME / 1000000, 1), BUFFER_GETS, DISK_READS, ROWS_PROCESSED, CASE WHEN EXECUTIONS > 0 THEN ROUND(ELAPSED_TIME / EXECUTIONS / 1000, 2) ELSE 0 END as elapsed_per_exec_ms, CASE WHEN EXECUTIONS > 0 THEN ROUND(BUFFER_GETS / EXECUTIONS) ELSE 0 END as buffer_gets_per_exec, PLAN_HASH_VALUE FROM V$SQL WHERE EXECUTIONS > 0 AND ELAPSED_TIME > 0 AND PARSING_SCHEMA_NAME NOT IN ('SYS','SYSTEM','DBSNMP','SYSMAN','OUTLN','MDSYS','ORDSYS','EXFSYS','WMSYS','APPQOSSYS','DBSFWUSER') AND SQL_TEXT NOT LIKE '%v$%' AND SQL_TEXT NOT LIKE '%V$%' AND COMMAND_TYPE IN (2, 3, 6, 7, 189) ORDER BY ELAPSED_TIME DESC FETCH FIRST 10 ROWS ONLY `); return (result.rows || []).map(row => { const elapsedPerExec = parseFloat(row[8]) || 0; const bufferGetsPerExec = parseInt(row[9]) || 0; let issue = 'Normal operation'; if (elapsedPerExec > 20) issue = 'Very slow execution — check execution plan'; else if (elapsedPerExec > 5) issue = 'Slow execution — review query and indexes'; if (bufferGetsPerExec > 1000) issue = 'High buffer gets — possible full table scan or missing index'; if (parseInt(row[6]) > parseInt(row[5]) * 0.1 && parseInt(row[6]) > 10000) issue = 'High disk reads relative to buffer gets — data not in cache'; return { sql_id: row[0], sql_text: row[1] || '', executions: parseInt(row[2]) || 0, elapsed_time_s: parseFloat(row[3]) || 0, cpu_time_s: parseFloat(row[4]) || 0, buffer_gets: parseInt(row[5]) || 0, disk_reads: parseInt(row[6]) || 0, rows_processed: parseInt(row[7]) || 0, elapsed_per_exec_ms: elapsedPerExec, buffer_gets_per_exec: bufferGetsPerExec, plan_hash: String(row[10] || '0'), issue }; }); } catch (err) { console.error('Top SQL query failed:', err.message); return []; } } async function queryIndexAnalysis(conn) { try { const result = await conn.execute(` SELECT i.OWNER, i.INDEX_NAME, i.TABLE_NAME, ROUND(s.LEAF_BLOCKS * (SELECT TO_NUMBER(VALUE) FROM v$parameter WHERE name = 'db_block_size') / 1024 / 1024) as size_mb, i.BLEVEL, s.LEAF_BLOCKS, i.CLUSTERING_FACTOR, NVL(s.PCT_DIRECT_ACCESS, 100) as pct_direct_access, i.STATUS, CASE WHEN i.STATUS != 'VALID' THEN 'unusable' WHEN i.BLEVEL > 4 THEN 'critical' WHEN NVL(s.PCT_DIRECT_ACCESS, 100) < 50 THEN 'critical' WHEN i.BLEVEL > 3 THEN 'fragmented' WHEN NVL(s.PCT_DIRECT_ACCESS, 100) < 70 THEN 'fragmented' ELSE 'ok' END as health_status FROM DBA_INDEXES i LEFT JOIN DBA_IND_STATISTICS s ON s.OWNER = i.OWNER AND s.INDEX_NAME = i.INDEX_NAME AND s.PARTITION_NAME IS NULL WHERE i.OWNER NOT IN ('SYS','SYSTEM','DBSNMP','SYSMAN','OUTLN','MDSYS','ORDSYS','EXFSYS','WMSYS','XDB','CTXSYS','APPQOSSYS','DBSFWUSER','APEX_040000','APEX_040200','APEX_050000','FLOWS_FILES') AND i.INDEX_TYPE = 'NORMAL' AND NVL(s.LEAF_BLOCKS, 0) > 100 ORDER BY CASE WHEN i.STATUS != 'VALID' THEN 1 WHEN i.BLEVEL > 4 THEN 2 WHEN i.BLEVEL > 3 THEN 3 ELSE 4 END, s.LEAF_BLOCKS DESC NULLS LAST FETCH FIRST 20 ROWS ONLY `); return (result.rows || []).map(row => { const blevel = parseInt(row[4]) || 0; const pctDirect = parseInt(row[7]) || 100; const estPctDeleted = Math.max(0, Math.min(100, Math.round(100 - pctDirect + (blevel > 3 ? (blevel - 3) * 15 : 0)))); return { owner: row[0], index_name: row[1], table_name: row[2], size_mb: parseInt(row[3]) || 0, blevel, leaf_blocks: parseInt(row[5]) || 0, clustering_factor: parseInt(row[6]) || 0, pct_deleted: estPctDeleted, status: row[9] || 'ok' }; }); } catch (err) { console.error('Index analysis query failed:', err.message); return []; } } async function querySgaStats(conn) { try { const [sgaR, hitR, libR, dictR, spR, parseR, compR] = await Promise.all([ conn.execute(`SELECT ROUND(SUM(VALUE) / 1024 / 1024 / 1024, 1) FROM V$SGA`), conn.execute(`SELECT ROUND((1 - (phys.VALUE / (db_gets.VALUE + con_gets.VALUE))) * 100, 1) FROM (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'physical reads') phys, (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'db block gets') db_gets, (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'consistent gets') con_gets`), conn.execute(`SELECT ROUND(SUM(PINS - RELOADS) / NULLIF(SUM(PINS), 0) * 100, 1) FROM V$LIBRARYCACHE`), conn.execute(`SELECT ROUND(SUM(GETS - GETMISSES) / NULLIF(SUM(GETS), 0) * 100, 1) FROM V$ROWCACHE`), conn.execute(`SELECT ROUND(free_bytes.val / total_bytes.val * 100, 1) FROM (SELECT SUM(BYTES) as val FROM V$SGASTAT WHERE POOL = 'shared pool' AND NAME = 'free memory') free_bytes, (SELECT SUM(BYTES) as val FROM V$SGASTAT WHERE POOL = 'shared pool') total_bytes`), conn.execute(`SELECT ROUND(hp.VALUE / NULLIF(GREATEST(uptime.VALUE, 1), 0), 1), ROUND((tp.VALUE - hp.VALUE) / NULLIF(GREATEST(uptime.VALUE, 1), 0), 1) FROM (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'parse count (hard)') hp, (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'parse count (total)') tp, (SELECT (SYSDATE - STARTUP_TIME) * 86400 as VALUE FROM V$INSTANCE) uptime`), conn.execute(`SELECT NAME, ROUND(VALUE / 1024 / 1024 / 1024, 1) FROM V$SGA`) ]); const components = {}; (compR.rows || []).forEach(r => { const name = (r[0] || '').toLowerCase(); if (name.includes('buffer')) components.buffer_cache_gb = parseFloat(r[1]) || 0; if (name.includes('shared')) components.shared_pool_gb = parseFloat(r[1]) || 0; if (name.includes('large')) components.large_pool_gb = parseFloat(r[1]) || 0; if (name.includes('java')) components.java_pool_gb = parseFloat(r[1]) || 0; if (name.includes('stream')) components.streams_pool_gb = parseFloat(r[1]) || 0; }); return { sga_size_gb: parseFloat(sgaR.rows?.[0]?.[0]) || 0, buffer_cache_gb: components.buffer_cache_gb || 0, shared_pool_gb: components.shared_pool_gb || 0, large_pool_gb: components.large_pool_gb || 0, java_pool_gb: components.java_pool_gb || 0, streams_pool_gb: components.streams_pool_gb || 0, buffer_cache_hit_ratio: parseFloat(hitR.rows?.[0]?.[0]) || 0, library_cache_hit_ratio: parseFloat(libR.rows?.[0]?.[0]) || 0, dictionary_cache_hit_ratio: parseFloat(dictR.rows?.[0]?.[0]) || 0, shared_pool_free_pct: parseFloat(spR.rows?.[0]?.[0]) || 0, hard_parses_per_sec: parseFloat(parseR.rows?.[0]?.[0]) || 0, soft_parses_per_sec: parseFloat(parseR.rows?.[0]?.[1]) || 0 }; } catch (err) { console.error('SGA stats query failed:', err.message); return { sga_size_gb: 0, buffer_cache_gb: 0, shared_pool_gb: 0, large_pool_gb: 0, java_pool_gb: 0, streams_pool_gb: 0, buffer_cache_hit_ratio: 0, library_cache_hit_ratio: 0, dictionary_cache_hit_ratio: 0, shared_pool_free_pct: 0, hard_parses_per_sec: 0, soft_parses_per_sec: 0 }; } } async function queryPgaStats(conn) { try { const r = await conn.execute(` SELECT ROUND((SELECT TO_NUMBER(VALUE)/1024/1024/1024 FROM v$parameter WHERE name = 'pga_aggregate_target'), 1), ROUND((SELECT VALUE/1024/1024/1024 FROM V$PGASTAT WHERE NAME = 'total PGA allocated'), 1), ROUND((SELECT VALUE/1024/1024/1024 FROM V$PGASTAT WHERE NAME = 'maximum PGA allocated'), 1), (SELECT VALUE FROM V$PGASTAT WHERE NAME = 'over allocation count'), ROUND((SELECT VALUE FROM V$PGASTAT WHERE NAME = 'cache hit percentage'), 1) FROM DUAL `); const wa = await conn.execute(` SELECT ROUND(optimal.cnt/NULLIF(total.cnt,0)*100,1), ROUND(onepass.cnt/NULLIF(total.cnt,0)*100,1), ROUND(multipass.cnt/NULLIF(total.cnt,0)*100,1) FROM (SELECT SUM(OPTIMAL_EXECUTIONS+ONEPASS_EXECUTIONS+MULTIPASSES_EXECUTIONS) as cnt FROM V$SQL_WORKAREA_HISTOGRAM WHERE LOW_OPTIMAL_SIZE>0) total, (SELECT SUM(OPTIMAL_EXECUTIONS) as cnt FROM V$SQL_WORKAREA_HISTOGRAM WHERE LOW_OPTIMAL_SIZE>0) optimal, (SELECT SUM(ONEPASS_EXECUTIONS) as cnt FROM V$SQL_WORKAREA_HISTOGRAM WHERE LOW_OPTIMAL_SIZE>0) onepass, (SELECT SUM(MULTIPASSES_EXECUTIONS) as cnt FROM V$SQL_WORKAREA_HISTOGRAM WHERE LOW_OPTIMAL_SIZE>0) multipass `); const row = r.rows?.[0] || []; const waRow = wa.rows?.[0] || []; return { pga_target_gb: parseFloat(row[0]) || 0, pga_allocated_gb: parseFloat(row[1]) || 0, pga_max_allocated_gb: parseFloat(row[2]) || 0, over_allocation_count: parseInt(row[3]) || 0, cache_hit_pct: parseFloat(row[4]) || 0, optimal_executions_pct: parseFloat(waRow[0]) || 0, onepass_executions_pct: parseFloat(waRow[1]) || 0, multipass_executions_pct: parseFloat(waRow[2]) || 0 }; } catch (err) { console.error('PGA stats query failed:', err.message); return { pga_target_gb: 0, pga_allocated_gb: 0, pga_max_allocated_gb: 0, over_allocation_count: 0, cache_hit_pct: 0, optimal_executions_pct: 0, onepass_executions_pct: 0, multipass_executions_pct: 0 }; } } async function queryOsStats(conn) { try { const result = await conn.execute(` SELECT STAT_NAME, VALUE FROM V$OSSTAT WHERE STAT_NAME IN ('NUM_CPUS','IDLE_TIME','BUSY_TIME','USER_TIME','SYS_TIME','IOWAIT_TIME','PHYSICAL_MEMORY_BYTES','FREE_MEMORY_BYTES') `); const stats = {}; (result.rows || []).forEach(r => { stats[r[0]] = parseFloat(r[1]) || 0; }); const totalCpuTime = (stats.IDLE_TIME || 0) + (stats.BUSY_TIME || 0); const cpuPct = totalCpuTime > 0 ? Math.round((stats.BUSY_TIME || 0) / totalCpuTime * 1000) / 10 : 0; const ioPct = totalCpuTime > 0 ? Math.round((stats.IOWAIT_TIME || 0) / totalCpuTime * 1000) / 10 : 0; return { cpu_count: parseInt(stats.NUM_CPUS) || 1, avg_cpu_utilization_pct: cpuPct, max_cpu_utilization_pct: Math.min(cpuPct * 1.3, 100), avg_io_wait_pct: ioPct, physical_memory_gb: Math.round((stats.PHYSICAL_MEMORY_BYTES || 0) / 1024 / 1024 / 1024 * 10) / 10, free_memory_gb: Math.round((stats.FREE_MEMORY_BYTES || 0) / 1024 / 1024 / 1024 * 10) / 10, swap_used_gb: 0, avg_disk_read_ms: 0, avg_disk_write_ms: 0 }; } catch (err) { console.error('OS stats query failed:', err.message); return { cpu_count: 1, avg_cpu_utilization_pct: 0, max_cpu_utilization_pct: 0, avg_io_wait_pct: 0, physical_memory_gb: 0, free_memory_gb: 0, swap_used_gb: 0, avg_disk_read_ms: 0, avg_disk_write_ms: 0 }; } } // ============================================================ // Wave A: Undo, Temp, Alert Log, Resource Limits, SGA/PGA History // ============================================================ async function checkAwrAvailability(conn, awrAllowed) { // Consent first: no diagnostics_pack consent → no AWR probe, no DBA_HIST read. // Mirrors oracle-client.js. The four AWR-bearing queries below already guard // their DBA_HIST blocks on the returned awrAvailable, so a false here closes // every AWR read in this agent. if (!awrAllowed) return false; try { await conn.execute(`SELECT COUNT(*) FROM DBA_HIST_UNDOSTAT WHERE ROWNUM = 1`); return true; } catch (err) { return false; } } async function queryUndoStats(conn, awrAvailable) { try { const currentResult = await conn.execute(` SELECT UNDOBLKS, TXNCOUNT, MAXQUERYLEN, MAXCONCURRENCY, TUNED_UNDORETENTION, EXPIREDBLKS, UNEXPIREDBLKS, ACTIVEBLKS FROM V$UNDOSTAT WHERE ROWNUM = 1 ORDER BY END_TIME DESC `); const tsResult = await conn.execute(` SELECT d.TABLESPACE_NAME, SUM(d.BYTES)/1073741824 AS TOTAL_GB, SUM(d.BYTES - NVL(f.FREE_BYTES,0))/1073741824 AS USED_GB, ROUND(SUM(d.BYTES - NVL(f.FREE_BYTES,0))/SUM(d.BYTES)*100,1) AS PCT_USED, t.RETENTION FROM DBA_DATA_FILES d JOIN DBA_TABLESPACES t ON t.TABLESPACE_NAME=d.TABLESPACE_NAME LEFT JOIN (SELECT FILE_ID, SUM(BYTES) AS FREE_BYTES FROM DBA_FREE_SPACE GROUP BY FILE_ID) f ON f.FILE_ID=d.FILE_ID WHERE t.CONTENTS='UNDO' GROUP BY d.TABLESPACE_NAME, t.RETENTION `); const row = (currentResult.rows||[[]])[0]||[]; const tsRow = (tsResult.rows||[[]])[0]||[]; const current = { undo_blocks: parseInt(row[0])||0, transaction_count: parseInt(row[1])||0, max_query_length_s: parseInt(row[2])||0, max_concurrency: parseInt(row[3])||0, tuned_undo_retention_s: parseInt(row[4])||900, expired_blocks: parseInt(row[5])||0, unexpired_blocks: parseInt(row[6])||0, active_blocks: parseInt(row[7])||0, tablespace_name: tsRow[0]||'UNDOTBS1', total_gb: parseFloat(tsRow[1])||0, used_gb: parseFloat(tsRow[2])||0, pct_used: parseFloat(tsRow[3])||0, retention_mode: tsRow[4]||'NOGUARANTEE' }; let historical = { peak_pct_used: null, peak_time: null, peak_query_length_s: null, lookback_days: 30 }; if (awrAvailable) { try { const hr = await conn.execute(` SELECT ROUND(MAX(u.UNDOBLKS)/NULLIF(d.TOTAL_BLOCKS,0)*100,1), TO_CHAR(MAX(u.END_TIME) KEEP (DENSE_RANK LAST ORDER BY u.UNDOBLKS),'YYYY-MM-DD HH24:MI'), MAX(u.MAXQUERYLEN), ROUND(MAX(u.TUNED_UNDORETENTION)/60,0) FROM DBA_HIST_UNDOSTAT u CROSS JOIN ( SELECT SUM(BLOCKS) AS TOTAL_BLOCKS FROM DBA_DATA_FILES df JOIN DBA_TABLESPACES t ON t.TABLESPACE_NAME=df.TABLESPACE_NAME WHERE t.CONTENTS='UNDO' ) d WHERE u.END_TIME > SYSDATE-30 `); const h = (hr.rows||[[]])[0]||[]; historical = { peak_pct_used: parseFloat(h[0])||null, peak_time: h[1]||null, peak_query_length_s: parseInt(h[2])||null, max_tuned_retention_min: parseInt(h[3])||null, lookback_days: 30 }; } catch(e) {} } return { current, historical, awr_available: awrAvailable }; } catch(err) { console.error('Undo stats query failed:', err.message); return { current: { tablespace_name:'UNDOTBS1', total_gb:0, used_gb:0, pct_used:0, tuned_undo_retention_s:900, max_query_length_s:0, retention_mode:'NOGUARANTEE' }, historical: { peak_pct_used:null, peak_time:null, lookback_days:30 }, awr_available: awrAvailable }; } } async function queryTempStats(conn, awrAvailable) { try { const freeResult = await conn.execute(` SELECT TABLESPACE_NAME, ROUND(TABLESPACE_SIZE/1073741824,2), ROUND(FREE_SPACE/1073741824,2), ROUND((TABLESPACE_SIZE-FREE_SPACE)/NULLIF(TABLESPACE_SIZE,0)*100,1) FROM DBA_TEMP_FREE_SPACE `); const sessionResult = await conn.execute(` SELECT s.SID, s.SERIAL#, s.USERNAME, ROUND(s.BLOCKS*8192/1048576,1), s.TABLESPACE FROM V$TEMPSEG_USAGE s ORDER BY s.BLOCKS DESC FETCH FIRST 10 ROWS ONLY `).catch(() => ({ rows: [] })); const freeRow = (freeResult.rows||[[]])[0]||[]; const totalGb = parseFloat(freeRow[1])||0, freeGb = parseFloat(freeRow[2])||0; const current = { tablespace_name: freeRow[0]||'TEMP', total_gb: totalGb, used_gb: Math.max(0, totalGb-freeGb), free_gb: freeGb, pct_used: parseFloat(freeRow[3])||0, top_sessions: (sessionResult.rows||[]).map(r=>({ sid:r[0], serial:r[1], username:r[2]||'UNKNOWN', temp_mb:parseFloat(r[3])||0, tablespace:r[4]||'' })) }; let historical = { peak_gb:null, peak_pct:null, peak_time:null, lookback_days:30 }; if (awrAvailable) { try { const hr = await conn.execute(`SELECT NULL, NULL, NULL FROM DUAL`); // Simplified: DBA_HIST_TBSPC_SPACE_USAGE join is temp-tablespace specific — returning null when not easy to isolate historical = { peak_gb: null, peak_pct: null, peak_time: null, lookback_days: 30 }; } catch(e) {} } return { current, historical, awr_available: awrAvailable }; } catch(err) { console.error('Temp stats query failed:', err.message); return { current:{ tablespace_name:'TEMP', total_gb:0, used_gb:0, free_gb:0, pct_used:0, top_sessions:[] }, historical:{ peak_gb:null, peak_pct:null, peak_time:null, lookback_days:30 }, awr_available: awrAvailable }; } } async function queryAlertLog(conn) { try { const result = await conn.execute(` SELECT TO_CHAR(ORIGINATING_TIMESTAMP,'YYYY-MM-DD HH24:MI:SS'), MESSAGE_TEXT FROM V$DIAG_ALERT_EXT WHERE ORIGINATING_TIMESTAMP > SYSDATE-1 AND (MESSAGE_TEXT LIKE 'ORA-%' OR MESSAGE_TEXT LIKE '%checkpoint%' OR MESSAGE_TEXT LIKE '%corruption%' OR MESSAGE_TEXT LIKE '%recovery%' OR MESSAGE_TEXT LIKE '%error%' OR MESSAGE_TEXT LIKE '%warning%' OR MESSAGE_TEXT LIKE '%TNS-%' OR MESSAGE_TEXT LIKE '%instance%' OR MESSAGE_TEXT LIKE 'Thread%') ORDER BY ORIGINATING_TIMESTAMP DESC FETCH FIRST 200 ROWS ONLY `); const classified = (result.rows||[]).map(r => { const msg = (r[1]||'').trim(); let severity = 'info'; if (/ORA-600|ORA-7445|ORA-1578|ORA-04031|ORA-01555/.test(msg)) severity = 'critical'; else if (/ORA-\d{4,5}/.test(msg)) severity = 'warning'; else if (/checkpoint not complete|cannot allocate new log|block corruption|instance termination/.test(msg.toLowerCase())) severity = 'critical'; else if (/checkpoint|redo log switch|archiv|TNS-1\d{4}/.test(msg.toLowerCase())) severity = 'warning'; else if (/TNS-12560|TNS-12537|opiodr aborting|Fatal NI/.test(msg)) severity = 'noise'; return { ts: r[0]||'', message: msg, severity }; }); const summary = { total: classified.length, critical: classified.filter(e=>e.severity==='critical').length, warning: classified.filter(e=>e.severity==='warning').length, info: classified.filter(e=>e.severity==='info').length, noise: classified.filter(e=>e.severity==='noise').length }; return { entries: classified.slice(0,100), summary }; } catch(err) { const msg = err.message || ''; // V$DIAG_ALERT_EXT requires an explicit grant even with SELECT_CATALOG_ROLE const missingGrant = msg.includes('ORA-00942') || msg.includes('ORA-01031'); const errorMsg = missingGrant ? 'Alert log access requires GRANT SELECT ON V_$DIAG_ALERT_EXT TO your_user' : msg; if (!missingGrant) console.error('Alert log query failed:', msg); return { entries:[], summary:{ total:0, critical:0, warning:0, info:0, noise:0 }, error: errorMsg }; } } async function queryResourceLimits(conn, awrAvailable) { try { const currentResult = await conn.execute(` SELECT RESOURCE_NAME, CURRENT_UTILIZATION, MAX_UTILIZATION, INITIAL_ALLOCATION, LIMIT_VALUE FROM V$RESOURCE_LIMIT WHERE RESOURCE_NAME IN ('sessions','processes','enqueue_locks','enqueue_resources','dml_locks','temporary_table_locks','transactions','max_rollback_segments') ORDER BY CASE RESOURCE_NAME WHEN 'sessions' THEN 1 WHEN 'processes' THEN 2 WHEN 'transactions' THEN 3 WHEN 'enqueue_locks' THEN 4 ELSE 9 END `); const current = (currentResult.rows||[]).map(r => { const limitVal = r[4]==='UNLIMITED' ? null : (parseInt(r[4])||null); const maxUtil = parseInt(r[2])||0; const pctUsed = limitVal ? Math.round(maxUtil/limitVal*100) : null; return { resource:r[0]||'', current_utilization:parseInt(r[1])||0, max_utilization:maxUtil, initial_allocation:r[3]||'0', limit_value:limitVal, limit_display:r[4]||'0', pct_max_used:pctUsed, status: pctUsed!==null?(pctUsed>=90?'critical':pctUsed>=80?'warning':'ok'):'ok' }; }); let historical = {}; if (awrAvailable) { try { const hr = await conn.execute(` SELECT RESOURCE_NAME, MAX(CURRENT_UTILIZATION), MAX(MAX_UTILIZATION) FROM DBA_HIST_RESOURCE_LIMIT WHERE SNAP_ID IN (SELECT SNAP_ID FROM DBA_HIST_SNAPSHOT WHERE END_INTERVAL_TIME > SYSDATE-30) GROUP BY RESOURCE_NAME `); (hr.rows||[]).forEach(r => { historical[r[0]] = { hist_max:parseInt(r[1])||0, hist_peak:parseInt(r[2])||0 }; }); } catch(e) {} } return { current, historical, awr_available: awrAvailable }; } catch(err) { console.error('Resource limits query failed:', err.message); return { current:[], historical:{}, awr_available: awrAvailable }; } } async function querySgaPgaHistory(conn, awrAvailable) { try { const paramResult = await conn.execute(` SELECT NAME, VALUE FROM V$PARAMETER WHERE NAME IN ('sga_target','pga_aggregate_target','sga_max_size','memory_target','memory_max_target') `); const params = {}; (paramResult.rows||[]).forEach(r => { params[r[0]] = parseInt(r[1])||0; }); const current = { sga_target_gb: Math.round((params['sga_target']||0)/1073741824*10)/10, pga_target_gb: Math.round((params['pga_aggregate_target']||0)/1073741824*10)/10, sga_max_gb: Math.round((params['sga_max_size']||0)/1073741824*10)/10, memory_target_gb: Math.round((params['memory_target']||0)/1073741824*10)/10 }; const resizeResult = await conn.execute(` SELECT TO_CHAR(START_TIME,'YYYY-MM-DD HH24:MI'), COMPONENT, OPER_TYPE, ROUND(INITIAL_SIZE/1073741824,2), ROUND(FINAL_SIZE/1073741824,2), STATUS FROM V$SGA_RESIZE_OPS ORDER BY START_TIME DESC FETCH FIRST 20 ROWS ONLY `).catch(() => ({ rows:[] })); const resizeOps = (resizeResult.rows||[]).map(r => ({ op_time:r[0]||'', component:r[1]||'', oper_type:r[2]||'', from_gb:parseFloat(r[3])||0, to_gb:parseFloat(r[4])||0, status:r[5]||'' })); let pgaHistory = { peak_allocated_gb:null, peak_time:null }; let sgaComponentHistory = []; if (awrAvailable) { try { const pr = await conn.execute(` SELECT ROUND(MAX(VALUE)/1073741824,2), TO_CHAR(MAX(s.END_INTERVAL_TIME) KEEP (DENSE_RANK LAST ORDER BY p.VALUE),'YYYY-MM-DD HH24:MI') FROM DBA_HIST_PGASTAT p JOIN DBA_HIST_SNAPSHOT s ON s.SNAP_ID=p.SNAP_ID WHERE p.NAME='maximum PGA allocated' AND s.END_INTERVAL_TIME>SYSDATE-30 `); const phr = (pr.rows||[[]])[0]||[]; pgaHistory = { peak_allocated_gb:parseFloat(phr[0])||null, peak_time:phr[1]||null, lookback_days:30 }; } catch(e) {} try { const sr = await conn.execute(` SELECT NAME, ROUND(MAX(VALUE)/1073741824,2), ROUND(MIN(VALUE)/1073741824,2) FROM DBA_HIST_SGA WHERE SNAP_ID IN (SELECT SNAP_ID FROM DBA_HIST_SNAPSHOT WHERE END_INTERVAL_TIME>SYSDATE-30) AND NAME IN ('Database Buffers','Shared Pool Size','Large Pool Size','Java Pool Size') GROUP BY NAME `); sgaComponentHistory = (sr.rows||[]).map(r => ({ component:r[0]||'', peak_gb:parseFloat(r[1])||0, min_gb:parseFloat(r[2])||0 })); } catch(e) {} } return { current, resize_ops: resizeOps, pga_history: pgaHistory, sga_component_history: sgaComponentHistory, awr_available: awrAvailable }; } catch(err) { console.error('SGA/PGA history query failed:', err.message); return { current:{ sga_target_gb:0, pga_target_gb:0, sga_max_gb:0, memory_target_gb:0 }, resize_ops:[], pga_history:{ peak_allocated_gb:null, peak_time:null }, sga_component_history:[], awr_available: awrAvailable }; } } // ============================================================ // Wave B: Backup & Recovery Health Checks // ============================================================ /** * Master function — runs all 4 backup checks and returns structured backup_stats. * Gracefully handles non-RMAN databases (all checks return null on failure). */ async function queryBackupStats(conn) { const [rmanBackup, fraUsage, archivelogRate, backupValidation] = await Promise.all([ queryRmanBackup(conn), queryFraUsage(conn), queryArchivelogRate(conn), queryBackupValidation(conn) ]); // Compute overall backup status (worst of the 4 checks) const statuses = [rmanBackup, fraUsage, archivelogRate, backupValidation] .map(c => (c && c.status) || 'unknown'); const overallStatus = statuses.includes('critical') ? 'critical' : statuses.includes('warning') ? 'warning' : statuses.every(s => s === 'ok') ? 'ok' : 'unknown'; return { rman_backup: rmanBackup, fra_usage: fraUsage, archivelog_rate: archivelogRate, backup_validation: backupValidation, overall_status: overallStatus }; } /** * Check 1: RMAN Backup Freshness * No full backup in >48h = critical | >24h = warning | <24h = ok */ async function queryRmanBackup(conn) { try { // Last backup job by type from V$RMAN_BACKUP_JOB_DETAILS const jobResult = await conn.execute(` SELECT INPUT_TYPE, STATUS, TO_CHAR(START_TIME, 'YYYY-MM-DD HH24:MI:SS') AS START_TIME, TO_CHAR(END_TIME, 'YYYY-MM-DD HH24:MI:SS') AS END_TIME, ROUND((SYSDATE - END_TIME) * 24, 1) AS HOURS_AGO, ROUND(OUTPUT_BYTES / 1073741824, 2) AS SIZE_GB, ELAPSED_SECONDS FROM ( SELECT INPUT_TYPE, STATUS, START_TIME, END_TIME, OUTPUT_BYTES, ELAPSED_SECONDS, ROW_NUMBER() OVER (PARTITION BY INPUT_TYPE ORDER BY END_TIME DESC) AS RN FROM V$RMAN_BACKUP_JOB_DETAILS WHERE STATUS = 'COMPLETED' ) WHERE RN = 1 ORDER BY CASE INPUT_TYPE WHEN 'DB FULL' THEN 1 WHEN 'DB INCR' THEN 2 WHEN 'ARCHIVELOG' THEN 3 ELSE 4 END `).catch(() => ({ rows: [] })); // Recent backup jobs (last 10 regardless of type) const recentResult = await conn.execute(` SELECT INPUT_TYPE, STATUS, TO_CHAR(START_TIME, 'YYYY-MM-DD HH24:MI:SS') AS START_TIME, TO_CHAR(END_TIME, 'YYYY-MM-DD HH24:MI:SS') AS END_TIME, ROUND((SYSDATE - END_TIME) * 24, 1) AS HOURS_AGO, ROUND(OUTPUT_BYTES / 1073741824, 2) AS SIZE_GB, ELAPSED_SECONDS FROM V$RMAN_BACKUP_JOB_DETAILS ORDER BY START_TIME DESC FETCH FIRST 10 ROWS ONLY `).catch(() => ({ rows: [] })); const lastByType = (jobResult.rows || []).map(r => ({ input_type: r[0] || '', status: r[1] || '', start_time: r[2] || '', end_time: r[3] || '', hours_ago: parseFloat(r[4]) || 0, size_gb: parseFloat(r[5]) || 0, elapsed_seconds: parseInt(r[6]) || 0 })); const recentJobs = (recentResult.rows || []).map(r => ({ input_type: r[0] || '', status: r[1] || '', start_time: r[2] || '', end_time: r[3] || '', hours_ago: parseFloat(r[4]) || 0, size_gb: parseFloat(r[5]) || 0, elapsed_seconds: parseInt(r[6]) || 0 })); // Find last full backup age const fullBackup = lastByType.find(b => b.input_type === 'DB FULL'); const incrBackup = lastByType.find(b => b.input_type === 'DB INCR'); const archBackup = lastByType.find(b => b.input_type === 'ARCHIVELOG'); const fullHoursAgo = fullBackup ? fullBackup.hours_ago : null; let status = 'unknown'; if (recentJobs.length === 0 && lastByType.length === 0) { status = 'unknown'; // No RMAN usage detected } else if (fullHoursAgo === null) { status = 'critical'; // No full backup ever } else if (fullHoursAgo > 48) { status = 'critical'; } else if (fullHoursAgo > 24) { status = 'warning'; } else { status = 'ok'; } return { status, rman_available: recentJobs.length > 0 || lastByType.length > 0, last_by_type: lastByType, recent_jobs: recentJobs, full_backup_hours_ago: fullHoursAgo, last_full_backup: fullBackup || null, last_incremental_backup: incrBackup || null, last_archivelog_backup: archBackup || null }; } catch (err) { console.error('RMAN backup query failed:', err.message); return { status: 'unknown', rman_available: false, last_by_type: [], recent_jobs: [], error: err.message }; } } /** * Check 2: Fast Recovery Area (FRA) Usage * >90% used AND <10% reclaimable = critical | >80% = warning | <80% = ok */ async function queryFraUsage(conn) { try { // FRA overview from V$RECOVERY_FILE_DEST const destResult = await conn.execute(` SELECT NAME, ROUND(SPACE_LIMIT / 1073741824, 2) AS LIMIT_GB, ROUND(SPACE_USED / 1073741824, 2) AS USED_GB, ROUND(SPACE_RECLAIMABLE / 1073741824, 2) AS RECLAIMABLE_GB, NUMBER_OF_FILES FROM V$RECOVERY_FILE_DEST `).catch(() => ({ rows: [] })); // Breakdown by file type from V$FLASH_RECOVERY_AREA_USAGE const usageResult = await conn.execute(` SELECT FILE_TYPE, ROUND(PERCENT_SPACE_USED, 1) AS PCT_USED, ROUND(PERCENT_SPACE_RECLAIMABLE, 1) AS PCT_RECLAIMABLE, NUMBER_OF_FILES FROM V$FLASH_RECOVERY_AREA_USAGE ORDER BY PERCENT_SPACE_USED DESC `).catch(() => ({ rows: [] })); // Archivelog generation rate (last 24h) for "hours until full" prediction const genRateResult = await conn.execute(` SELECT ROUND(SUM(BLOCKS * BLOCK_SIZE) / 1073741824, 2) AS ARCHIVELOGS_24H_GB FROM V$ARCHIVED_LOG WHERE COMPLETION_TIME > SYSDATE - 1 AND STANDBY_DEST = 'NO' `).catch(() => ({ rows: [[0]] })); const destRow = (destResult.rows || [[]])[0] || []; const limitGb = parseFloat(destRow[1]) || 0; const usedGb = parseFloat(destRow[2]) || 0; const reclaimableGb = parseFloat(destRow[3]) || 0; const fraLocation = destRow[0] || ''; const pctUsed = limitGb > 0 ? Math.round((usedGb / limitGb) * 1000) / 10 : 0; const pctReclaimable = limitGb > 0 ? Math.round((reclaimableGb / limitGb) * 1000) / 10 : 0; const archivelogs24hGb = parseFloat((genRateResult.rows || [[0]])[0]?.[0]) || 0; // Hours until FRA full: (limitGb - usedGb + reclaimableGb) / hourly_rate const availableGb = limitGb - usedGb + reclaimableGb; const hourlyRateGb = archivelogs24hGb / 24; const hoursUntilFull = (hourlyRateGb > 0 && limitGb > 0) ? Math.round(availableGb / hourlyRateGb) : null; const fileTypeBreakdown = (usageResult.rows || []).map(r => ({ file_type: r[0] || '', pct_used: parseFloat(r[1]) || 0, pct_reclaimable: parseFloat(r[2]) || 0, number_of_files: parseInt(r[3]) || 0 })); let status = 'unknown'; if (limitGb === 0) { status = 'unknown'; // FRA not configured } else if (pctUsed > 90 && pctReclaimable < 10) { status = 'critical'; } else if (pctUsed > 80) { status = 'warning'; } else { status = 'ok'; } return { status, fra_configured: limitGb > 0, location: fraLocation, limit_gb: limitGb, used_gb: usedGb, reclaimable_gb: reclaimableGb, pct_used: pctUsed, pct_reclaimable: pctReclaimable, archivelogs_24h_gb: archivelogs24hGb, hours_until_full: hoursUntilFull, file_type_breakdown: fileTypeBreakdown }; } catch (err) { console.error('FRA usage query failed:', err.message); return { status: 'unknown', fra_configured: false, error: err.message }; } } /** * Check 3: Archivelog Generation Rate * Not in archivelog mode = critical | switch frequency >20/hour = warning | otherwise = ok */ async function queryArchivelogRate(conn) { try { // Archivelog mode + current sequence const modeResult = await conn.execute(` SELECT LOG_MODE, ROUND((SYSDATE - STARTUP_TIME) * 24) AS HOURS_UP FROM V$DATABASE, V$INSTANCE `).catch(() => ({ rows: [['ARCHIVELOG', 0]] })); // Recent archivelog generation (last 24h) const archResult = await conn.execute(` SELECT TO_CHAR(COMPLETION_TIME, 'YYYY-MM-DD HH24') AS HOUR, COUNT(*) AS LOG_COUNT, ROUND(SUM(BLOCKS * BLOCK_SIZE) / 1048576, 1) AS SIZE_MB FROM V$ARCHIVED_LOG WHERE COMPLETION_TIME > SYSDATE - 1 AND STANDBY_DEST = 'NO' GROUP BY TO_CHAR(COMPLETION_TIME, 'YYYY-MM-DD HH24') ORDER BY HOUR DESC `).catch(() => ({ rows: [] })); // Redo log groups and sizes const logResult = await conn.execute(` SELECT l.GROUP#, l.MEMBERS, ROUND(l.BYTES / 1048576, 0) AS SIZE_MB, l.STATUS, l.ARCHIVED FROM V$LOG l ORDER BY l.GROUP# `).catch(() => ({ rows: [] })); // Log switch frequency from V$LOG_HISTORY (last 24h) const switchResult = await conn.execute(` SELECT ROUND(COUNT(*) / 24.0, 1) AS SWITCHES_PER_HOUR, COUNT(*) AS SWITCHES_24H FROM V$LOG_HISTORY WHERE FIRST_TIME > SYSDATE - 1 `).catch(() => ({ rows: [[0, 0]] })); const modeRow = (modeResult.rows || [['ARCHIVELOG', 0]])[0] || ['ARCHIVELOG', 0]; const logMode = modeRow[0] || 'ARCHIVELOG'; const archHourly = (archResult.rows || []).map(r => ({ hour: r[0] || '', log_count: parseInt(r[1]) || 0, size_mb: parseFloat(r[2]) || 0 })); const logGroups = (logResult.rows || []).map(r => ({ group_num: parseInt(r[0]) || 0, members: parseInt(r[1]) || 0, size_mb: parseInt(r[2]) || 0, status: r[3] || '', archived: r[4] || '' })); const switchRow = (switchResult.rows || [[0, 0]])[0] || [0, 0]; const switchesPerHour = parseFloat(switchRow[0]) || 0; const switches24h = parseInt(switchRow[1]) || 0; const totalArchivelogs24h = archHourly.reduce((sum, h) => sum + h.log_count, 0); const totalSizeMb24h = archHourly.reduce((sum, h) => sum + h.size_mb, 0); let status = 'ok'; if (logMode !== 'ARCHIVELOG') { status = 'critical'; // Not in archivelog mode } else if (switchesPerHour > 20) { status = 'warning'; } else { status = 'ok'; } return { status, log_mode: logMode, archivelog_mode: logMode === 'ARCHIVELOG', switches_per_hour: switchesPerHour, switches_24h: switches24h, archivelogs_24h: totalArchivelogs24h, total_size_mb_24h: totalSizeMb24h, hourly_breakdown: archHourly.slice(0, 24), log_groups: logGroups }; } catch (err) { console.error('Archivelog rate query failed:', err.message); return { status: 'unknown', archivelog_mode: null, error: err.message }; } } /** * Check 4: Backup Validation * Corruption found or last 3 RMAN jobs failed = critical */ async function queryBackupValidation(conn) { try { // Recent RMAN operations from V$RMAN_STATUS const rmanStatusResult = await conn.execute(` SELECT OPERATION, STATUS, TO_CHAR(START_TIME, 'YYYY-MM-DD HH24:MI:SS') AS START_TIME, TO_CHAR(END_TIME, 'YYYY-MM-DD HH24:MI:SS') AS END_TIME, MBYTES_PROCESSED, OUTPUT FROM V$RMAN_STATUS WHERE OPERATION IN ('BACKUP', 'RESTORE', 'RECOVER', 'DELETE', 'VALIDATE') AND START_TIME > SYSDATE - 7 ORDER BY START_TIME DESC FETCH FIRST 20 ROWS ONLY `).catch(() => ({ rows: [] })); // Backup corruption from V$BACKUP_CORRUPTION const backupCorrResult = await conn.execute(` SELECT COUNT(*) AS CORRUPT_COUNT, SUM(BLOCKS) AS CORRUPT_BLOCKS FROM V$BACKUP_CORRUPTION `).catch(() => ({ rows: [[0, 0]] })); // Copy corruption from V$COPY_CORRUPTION const copyCorrResult = await conn.execute(` SELECT COUNT(*) AS CORRUPT_COUNT, SUM(BLOCKS) AS CORRUPT_BLOCKS FROM V$COPY_CORRUPTION `).catch(() => ({ rows: [[0, 0]] })); const rmanOps = (rmanStatusResult.rows || []).map(r => ({ operation: r[0] || '', status: r[1] || '', start_time: r[2] || '', end_time: r[3] || '', mbytes_processed: parseFloat(r[4]) || 0, output: (r[5] || '').substring(0, 300) })); const backupCorrupt = parseInt((backupCorrResult.rows || [[0]])[0]?.[0]) || 0; const backupCorruptBlocks = parseInt((backupCorrResult.rows || [[0, 0]])[0]?.[1]) || 0; const copyCorrupt = parseInt((copyCorrResult.rows || [[0]])[0]?.[0]) || 0; const copyCorruptBlocks = parseInt((copyCorrResult.rows || [[0, 0]])[0]?.[1]) || 0; const totalCorruptions = backupCorrupt + copyCorrupt; // Check last 3 RMAN backup jobs const recentBackups = rmanOps.filter(op => op.operation === 'BACKUP').slice(0, 3); const last3Failed = recentBackups.length > 0 && recentBackups.every(b => b.status === 'FAILED'); let status = 'ok'; if (totalCorruptions > 0) { status = 'critical'; } else if (last3Failed) { status = 'critical'; } else if (recentBackups.some(b => b.status === 'FAILED')) { status = 'warning'; } else { status = 'ok'; } return { status, backup_corruptions: backupCorrupt, backup_corrupt_blocks: backupCorruptBlocks, copy_corruptions: copyCorrupt, copy_corrupt_blocks: copyCorruptBlocks, total_corruptions: totalCorruptions, recent_operations: rmanOps, last_3_backups_failed: last3Failed }; } catch (err) { console.error('Backup validation query failed:', err.message); return { status: 'unknown', total_corruptions: 0, error: err.message }; } } // ============================================================ // Wave E: EBS Apps Health Checks // All EBS tables schema-qualified with APPS. for SYSTEM user // ============================================================ async function queryAppsHealth(conn) { try { // EBS detection const ebsDetect = await conn.execute( `SELECT COUNT(*) FROM APPS.FND_APPLICATION WHERE ROWNUM = 1` ).catch(() => null); if (!ebsDetect) return null; const [cmResult, requestResult, oppResult, wfComponentsResult, stuckNotifResult, wfErrorResult] = await Promise.all([ // ── Concurrent Manager queues (verified against EBS 12.2.12) ── conn.execute(` SELECT DISTINCT b.user_concurrent_queue_name AS manager_name, a.target_node AS node, a.running_processes AS actual_procs, a.max_processes AS target_procs, DECODE(b.control_code, 'D', 'Deactivating', 'E', 'Deactivated', 'N', 'Node unavai', 'A', 'Activating', 'X', 'Terminated', 'T', 'Terminating', 'V', 'Verifying', 'O', 'Suspending', 'P', 'Suspended', 'Q', 'Resuming', 'R', 'Restarting', 'Running') AS status_label FROM apps.fnd_concurrent_queues a, apps.fnd_concurrent_queues_vl b WHERE a.concurrent_queue_id = b.concurrent_queue_id AND a.max_processes != 0 ORDER BY a.max_processes DESC `).catch(() => ({ rows: [] })), // Pending / running request counts conn.execute(` SELECT PHASE_CODE, COUNT(*) FROM APPS.FND_CONCURRENT_REQUESTS WHERE PHASE_CODE IN ('P','R') AND HOLD_FLAG = 'N' GROUP BY PHASE_CODE `).catch(() => ({ rows: [] })), // ── OPP (dedicated query) ────────────────────────────────────── conn.execute(` SELECT b.user_concurrent_queue_name AS manager_name, a.running_processes AS actual, a.max_processes AS target FROM apps.fnd_concurrent_queues a, apps.fnd_concurrent_queues_vl b WHERE a.concurrent_queue_id = b.concurrent_queue_id AND a.concurrent_queue_name = 'FNDCPOPP' `).catch(() => ({ rows: [] })), // ── All Workflow components (WF% types, all 13 components) ── conn.execute(` SELECT component_type, component_name, component_status, startup_mode FROM apps.fnd_svc_components WHERE component_type LIKE 'WF%' ORDER BY 1, 2 `).catch(() => ({ rows: [] })), conn.execute(` SELECT COUNT(*) FROM APPS.WF_NOTIFICATIONS WHERE STATUS = 'OPEN' AND MAIL_STATUS = 'MAIL' AND BEGIN_DATE < SYSDATE - 1/24 `).catch(() => ({ rows: [[0]] })), conn.execute(`SELECT COUNT(*) FROM APPS.WF_ERROR`).catch(() => ({ rows: [[0]] })) ]); // Process managers (new query: [name, node, actual, target, status_label]) const managers = (cmResult.rows || []).map(r => ({ display_name: r[0] || '', node: r[1] || '', actual: parseInt(r[2]) || 0, target: parseInt(r[3]) || 0, status_label: r[4] || 'Running' })); const icm = managers.find(m => (m.display_name || '').toLowerCase().includes('internal concurrent manager') ); let pendingCount = 0, runningCount = 0; (requestResult.rows || []).forEach(r => { if (r[0] === 'P') pendingCount = parseInt(r[1]) || 0; if (r[0] === 'R') runningCount = parseInt(r[1]) || 0; }); const icmDown = !icm || (icm.actual === 0 && icm.target > 0); let cmStatus = icmDown ? 'critical' : 'ok'; if (!icmDown) { const underStaffed = managers.some(m => m.target > 0 && m.actual < m.target); if (underStaffed || pendingCount > 50) cmStatus = 'warning'; } // OPP (dedicated query result) const oppRows = oppResult.rows || []; const oppActual = oppRows.length > 0 ? parseInt(oppRows[0][1]) || 0 : 0; const oppTarget = oppRows.length > 0 ? parseInt(oppRows[0][2]) || 0 : 0; const oppData = { manager_name: oppRows.length > 0 ? (oppRows[0][0] || 'Output Post Processor') : 'Output Post Processor', actual: oppActual, target: oppTarget, status: oppActual === 0 && oppTarget > 0 ? 'critical' : oppActual < oppTarget ? 'warning' : 'ok', recommendation: oppActual === 0 && oppTarget > 0 ? 'OPP is not running — PDF/output generation will fail. Start OPP from the CM admin page.' : oppActual < oppTarget ? `OPP has ${oppActual}/${oppTarget} processes running — throughput may be degraded.` : `OPP healthy: ${oppActual}/${oppTarget} process(es) running.` }; // Workflow (all WF% components) const wfServices = (wfComponentsResult.rows || []).map(r => ({ type: r[0] || '', name: r[1] || '', status: r[2] || 'UNKNOWN', startup_mode: r[3] || '' })); const mailer = wfServices.find(s => s.type === 'WF_MAILER' || (s.name || '').toLowerCase().includes('mailer') ); const mailerRunning = !!(mailer && mailer.status === 'RUNNING'); const stuckNotif = parseInt((stuckNotifResult.rows || [[0]])[0]?.[0]) || 0; const wfErrCount = parseInt((wfErrorResult.rows || [[0]])[0]?.[0]) || 0; const criticalComps = wfServices.filter(s => s.status === 'DEACTIVATED_SYSTEM' || s.status === 'STOPPED'); const warningComps = wfServices.filter(s => s.status !== 'RUNNING' && s.status !== 'DEACTIVATED_SYSTEM' && s.status !== 'STOPPED'); let wfStatus = 'ok'; if (mailer && !mailerRunning) wfStatus = 'critical'; else if (criticalComps.length > 0) wfStatus = 'critical'; else if (stuckNotif > 50 || wfErrCount > 20) wfStatus = 'critical'; else if (warningComps.length > 0 || stuckNotif > 5 || wfErrCount > 0) wfStatus = 'warning'; // Overall const statuses = [cmStatus, wfStatus, oppData.status]; const overallStatus = statuses.includes('critical') ? 'critical' : statuses.includes('warning') ? 'warning' : 'ok'; // ── Wave F: APPS_ENV and ADOP_STATUS ──────────────────────── const appsEnvResult = await queryAppsEnv(conn); const adopResult = await queryAdopStatus(); // Fold new check statuses into overall const allStatuses = [cmStatus, wfStatus, oppData.status, appsEnvResult.status, adopResult.status]; const newOverall = allStatuses.includes('critical') ? 'critical' : allStatuses.includes('warning') ? 'warning' : 'ok'; return { is_ebs: true, status: newOverall, concurrent_managers: { status: cmStatus, icm_down: icmDown, managers, pending_requests: pendingCount, running_requests: runningCount }, opp: oppData, workflow: { status: wfStatus, mailer_running: mailerRunning, mailer_component: mailer || null, services: wfServices, stuck_notifications: stuckNotif, error_count: wfErrCount }, apps_env: appsEnvResult, adop_status: adopResult }; } catch (err) { console.error('EBS apps health query failed:', err.message); return null; } } // ============================================================ // Wave F: APPS_ENV Check // Query EBS environment variables from the database and (when // the proxy runs on the app tier) from the OS environment. // ============================================================ /** * APPS_ENV — display key EBS Apps environment variables. * * Sources (highest to lowest priority): * 1. OS environment (proxy runs on app tier — variables set by * sourcing the EBS context file, e.g. CONTEXT_FILE, INST_TOP …) * 2. FND_PROFILE_OPTION_VALUES for DB-accessible profile options * (e.g. APPS_JDBC_URL stored in FND_OAM_METVAL) * * Returns check_id, status, severity, message, and a display object * with columns/rows for table rendering in the UI. */ async function queryAppsEnv(conn) { const CRITICAL_VARS = [ 'CONTEXT_FILE', 'INST_TOP', 'COMMON_TOP', 'ADMIN_SCRIPTS_HOME' ]; const ALL_VARS = [ 'CONTEXT_FILE', 'ADMIN_SCRIPTS_HOME', 'INST_TOP', 'COMMON_TOP', 'FMW_HOME', 'EBS_DOMAIN_HOME', 'APPS_JDBC_URL', 'TWO_TASK', 'ORACLE_HOME', 'ORACLE_SID', 'TNS_ADMIN' ]; // Step 1: read from OS environment (available when proxy runs on EBS app tier) const envVars = {}; for (const v of ALL_VARS) { const val = process.env[v]; if (val !== undefined) envVars[v] = val; } // Step 2: supplement missing vars from Oracle DB where possible try { // APPS_JDBC_URL: stored in FND_OAM_METVAL (EBS 12.2+) under key 'APPS_JDBC_URL' const jdbcResult = await conn.execute( `SELECT METVAL_CLOB FROM APPS.FND_OAM_METVAL WHERE METNAME = 'APPS_JDBC_URL' AND ROWNUM = 1` ).catch(() => null); if (jdbcResult && jdbcResult.rows && jdbcResult.rows[0] && !envVars['APPS_JDBC_URL']) { envVars['APPS_JDBC_URL'] = String(jdbcResult.rows[0][0] || '').substring(0, 200); } } catch (e) { /* non-fatal */ } try { // TNS_ADMIN: read from v$parameter (LOCAL_LISTENER or similar) as a heuristic const tnsResult = await conn.execute( `SELECT VALUE FROM v$parameter WHERE name = 'tns_admin' AND ROWNUM = 1` ).catch(() => null); if (tnsResult && tnsResult.rows && tnsResult.rows[0] && !envVars['TNS_ADMIN']) { const val = String(tnsResult.rows[0][0] || ''); if (val) envVars['TNS_ADMIN'] = val; } } catch (e) { /* non-fatal */ } try { // ORACLE_SID: from v$instance const sidResult = await conn.execute(`SELECT INSTANCE_NAME FROM v$instance`).catch(() => null); if (sidResult && sidResult.rows && sidResult.rows[0] && !envVars['ORACLE_SID']) { envVars['ORACLE_SID'] = String(sidResult.rows[0][0] || ''); } } catch (e) { /* non-fatal */ } // Step 3: build rows const rows = ALL_VARS.map(v => { const val = envVars[v]; const present = val !== undefined && val !== ''; const isCritical = CRITICAL_VARS.includes(v); return { variable: v, value: present ? val : null, state: present ? 'OK' : 'MISSING', critical: isCritical }; }); // Step 4: compute status const missingCritical = rows.filter(r => r.critical && r.state === 'MISSING'); const missingAny = rows.filter(r => r.state === 'MISSING'); let status, message; if (missingCritical.length > 0) { status = 'critical'; message = `${missingCritical.length} critical EBS env var(s) missing: ${missingCritical.map(r => r.variable).join(', ')}`; } else if (missingAny.length > 0) { status = 'warning'; message = `${missingAny.length} EBS env var(s) not detected (non-critical): ${missingAny.map(r => r.variable).join(', ')}`; } else { status = 'ok'; message = `All ${rows.length} EBS environment variables are present.`; } return { check_id: 'APPS_ENV', status, severity: missingCritical.length > 0 ? 'critical' : missingAny.length > 0 ? 'warning' : 'ok', message, os_env_available: Object.keys(envVars).some(k => process.env[k] !== undefined), display: { columns: ['Variable', 'State', 'Value'], rows: rows.map(r => [r.variable, r.state, r.value ? r.value.substring(0, 120) : '—']) }, variables: rows }; } // ============================================================ // Wave F: ADOP_STATUS Check // Run `adop -status -detail` on the OS (proxy runs on EBS server) // and parse Session ID, Node, Phase, Status. // SKIP if APPS_PWD not available in environment. // ============================================================ /** * ADOP_STATUS — run adop patching utility status check. * * Requirements: * - Proxy runs on EBS app tier * - APPS_PWD set in environment (used to pass credentials non-interactively) * - adop utility on PATH (or ADMIN_SCRIPTS_HOME in environment) * * Parsing targets: * Session ID, Node, Phase, Status * * Returns check_id, status, severity, message, display (columns + rows). */ async function queryAdopStatus() { // Locate adop binary const adminScriptsHome = process.env.ADMIN_SCRIPTS_HOME || ''; const adopBin = adminScriptsHome ? `${adminScriptsHome}/adop` : 'adop'; // If APPS_PWD not in environment, return an actionable warning instead of silently skipping. // This surfaces the missing config to the user in the dashboard rather than hiding it. const appsPwd = process.env.APPS_PWD || process.env.APPS_PASSWORD || ''; if (!appsPwd) { return { check_id: 'ADOP_STATUS', status: 'warning', severity: 'warning', message: 'Set APPS_PWD in proxy.env to enable ADOP patching session detection. See setup guide: https://tunevault.app/docs#proxy-setup', display: { columns: ['Field', 'Value'], rows: [['Action Required', 'Add APPS_PWD= to your proxy.env file and restart the proxy service to enable ADOP patch session monitoring.']] }, sessions: [] }; } return new Promise(resolve => { const TIMEOUT_MS = 30000; const env = Object.assign({}, process.env, { APPS_PWD: appsPwd }); const proc = execFile( adopBin, ['-status', '-detail'], { env, timeout: TIMEOUT_MS, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => { if (err && err.killed) { resolve({ check_id: 'ADOP_STATUS', status: 'warning', severity: 'warning', message: `adop -status timed out after ${TIMEOUT_MS / 1000}s.`, display: { columns: ['Field', 'Value'], rows: [['Status', 'TIMEOUT']] }, sessions: [] }); return; } if (err && !stdout) { // adop not installed or not on PATH const msg = (err.message || '').substring(0, 200); resolve({ check_id: 'ADOP_STATUS', status: 'warning', severity: 'warning', message: `adop command failed: ${msg}`, display: { columns: ['Field', 'Value'], rows: [['Error', msg]] }, sessions: [] }); return; } // Parse the output const output = (stdout || '') + (stderr || ''); const sessions = parseAdopOutput(output); // Determine status const failedSessions = sessions.filter(s => (s.status || '').toUpperCase() === 'FAILED'); const activeSessions = sessions.filter(s => ['INPROGRESS', 'IN_PROGRESS', 'RUNNING', 'ACTIVE'].includes((s.status || '').toUpperCase())); let status, message; if (sessions.length === 0) { status = 'ok'; message = 'No ADOP sessions found — no active or recent patching activity.'; } else if (failedSessions.length > 0) { status = 'critical'; message = `${failedSessions.length} ADOP session(s) in FAILED state. Review and clean up before next patch cycle.`; } else if (activeSessions.length > 0) { status = 'warning'; message = `${activeSessions.length} ADOP patching session(s) currently active.`; } else { status = 'ok'; message = `ADOP: ${sessions.length} historical session(s) found, none in FAILED state.`; } resolve({ check_id: 'ADOP_STATUS', status, severity: failedSessions.length > 0 ? 'critical' : activeSessions.length > 0 ? 'warning' : 'ok', message, display: { columns: ['Session ID', 'Node', 'Phase', 'Status'], rows: sessions.map(s => [s.session_id || '—', s.node || '—', s.phase || '—', s.status || '—']) }, sessions, raw_output: output.substring(0, 2000) }); } ); // Safety net — execFile timeout param above should handle it, but belt-and-suspenders setTimeout(() => { try { proc.kill('SIGTERM'); } catch (e) { /* ignore */ } }, TIMEOUT_MS + 1000); }); } /** * Parse `adop -status -detail` output. * * Typical output block per session: * SESSION ID : 42 * NODE : ebsapp01 * PHASE : FINALIZE * STATUS : SUCCESS * * Returns array of { session_id, node, phase, status }. */ function parseAdopOutput(text) { const sessions = []; // Split by blank lines to separate session blocks const blocks = text.split(/\n\s*\n/); for (const block of blocks) { const lines = block.split('\n').map(l => l.trim()).filter(Boolean); const session = {}; for (const line of lines) { const m = line.match(/^(SESSION\s*ID|NODE|PHASE|STATUS)\s*[:\-]\s*(.+)/i); if (m) { const key = m[1].trim().toLowerCase().replace(/\s+/, '_'); const value = m[2].trim(); if (key === 'session_id') session.session_id = value; else if (key === 'node') session.node = value; else if (key === 'phase') session.phase = value; else if (key === 'status') session.status = value; } } // Only include blocks that look like adop session entries if (session.session_id || session.phase || session.status) { sessions.push(session); } } return sessions; } // ============================================================ // Wave G: Database Objects & Session Stats (OB03, OB04, OB06, OB07, OB09, OB12) // ============================================================ /** * queryDbObjects — collects invalid objects, stale stats, SCN headroom, SPFILE status. * All queries are read-only against SYS-visible views. */ async function queryDbObjects(conn) { try { const EXCLUDED_OWNERS = `'SYS','SYSTEM','DBSNMP','SYSMAN','OUTLN','MDSYS','ORDSYS','XDB','CTXSYS','WMSYS','EXFSYS','APPQOSSYS','DBSFWUSER','OJVMSYS','DVSYS','LBACSYS'`; const [invalidR, staleR, scnR, spfileR, recycleR, controlR] = await Promise.all([ // Invalid objects count conn.execute(` SELECT COUNT(*) AS invalid_count, COUNT(CASE WHEN OBJECT_TYPE IN ('PACKAGE BODY','PACKAGE') THEN 1 END) AS invalid_packages, COUNT(CASE WHEN OBJECT_TYPE = 'PROCEDURE' THEN 1 END) AS invalid_procedures, COUNT(CASE WHEN OBJECT_TYPE = 'VIEW' THEN 1 END) AS invalid_views, COUNT(CASE WHEN OBJECT_TYPE = 'TRIGGER' THEN 1 END) AS invalid_triggers FROM DBA_OBJECTS WHERE STATUS = 'INVALID' AND OWNER NOT IN (${EXCLUDED_OWNERS}) `).catch(() => ({ rows: [[0,0,0,0,0]] })), // Stale statistics count conn.execute(` SELECT COUNT(*) AS stale_count, COUNT(CASE WHEN LAST_ANALYZED IS NULL THEN 1 END) AS never_analyzed FROM DBA_TAB_STATISTICS WHERE STALE_STATS = 'YES' AND OWNER NOT IN (${EXCLUDED_OWNERS}) `).catch(() => ({ rows: [[0,0]] })), // SCN headroom (days remaining) conn.execute(` SELECT CURRENT_SCN, ROUND((TO_NUMBER(SYSDATE - TO_DATE('01-01-1988','DD-MM-YYYY')) * 24 * 3600 * 16384 * 1024 - CURRENT_SCN) / (24*3600*16384), 0) AS days_remaining FROM V$DATABASE `).catch(() => ({ rows: [[null, null]] })), // SPFILE in use conn.execute(` SELECT DECODE(COUNT(*),0,'PFILE','SPFILE') AS param_file_type FROM V$PARAMETER WHERE NAME = 'spfile' AND VALUE IS NOT NULL `).catch(() => ({ rows: [['UNKNOWN']] })), // Recycle bin size conn.execute(` SELECT COUNT(*) AS object_count, ROUND(SUM(SPACE)*8192/1073741824, 2) AS size_gb FROM DBA_RECYCLEBIN `).catch(() => ({ rows: [[0, 0]] })), // Control file count conn.execute(` SELECT COUNT(*) AS controlfile_count, SUM(CASE WHEN STATUS IS NOT NULL AND STATUS != '' THEN 1 ELSE 0 END) AS invalid_count FROM V$CONTROLFILE `).catch(() => ({ rows: [[0, 0]] })) ]); const invalidRow = (invalidR.rows || [[0,0,0,0,0]])[0] || [0,0,0,0,0]; const staleRow = (staleR.rows || [[0,0]])[0] || [0,0]; const scnRow = (scnR.rows || [[null,null]])[0] || [null,null]; const spfileRow = (spfileR.rows || [['UNKNOWN']])[0] || ['UNKNOWN']; const recycleRow = (recycleR.rows || [[0,0]])[0] || [0,0]; const ctrlRow = (controlR.rows || [[0,0]])[0] || [0,0]; return { invalid_objects: { count: parseInt(invalidRow[0]) || 0, packages: parseInt(invalidRow[1]) || 0, procedures: parseInt(invalidRow[2]) || 0, views: parseInt(invalidRow[3]) || 0, triggers: parseInt(invalidRow[4]) || 0 }, stale_stats: { stale_count: parseInt(staleRow[0]) || 0, never_analyzed: parseInt(staleRow[1]) || 0 }, scn_headroom: { current_scn: parseInt(scnRow[0]) || null, days_remaining: parseInt(scnRow[1]) || null }, spfile: { param_file_type: String(spfileRow[0] || 'UNKNOWN'), using_spfile: String(spfileRow[0] || '') === 'SPFILE' }, recyclebin: { object_count: parseInt(recycleRow[0]) || 0, size_gb: parseFloat(recycleRow[1]) || 0 }, controlfiles: { count: parseInt(ctrlRow[0]) || 0, invalid_count: parseInt(ctrlRow[1]) || 0 } }; } catch (err) { console.error('DB objects query failed:', err.message); return { invalid_objects: { count: 0, packages: 0, procedures: 0, views: 0, triggers: 0 }, stale_stats: { stale_count: 0, never_analyzed: 0 }, scn_headroom: { current_scn: null, days_remaining: null }, spfile: { param_file_type: 'UNKNOWN', using_spfile: false }, recyclebin: { object_count: 0, size_gb: 0 }, controlfiles: { count: 0, invalid_count: 0 } }; } } /** * querySessionStats — active/blocked session counts, long-running SQL. * Covers OB06_BLOCKING_LOCKS, OB07_LISTENER_SESSIONS, PF09_LONG_RUNNING_SQL. */ async function querySessionStats(conn) { try { const [sessionR, blockedR, longSqlR] = await Promise.all([ // Active user sessions conn.execute(` SELECT COUNT(*) AS total_sessions, COUNT(CASE WHEN STATUS = 'ACTIVE' AND TYPE = 'USER' THEN 1 END) AS active_sessions, COUNT(CASE WHEN TYPE = 'USER' THEN 1 END) AS user_sessions FROM V$SESSION `).catch(() => ({ rows: [[0,0,0]] })), // Blocked sessions conn.execute(` SELECT COUNT(*) AS blocked_count FROM V$SESSION WHERE BLOCKING_SESSION IS NOT NULL AND STATUS = 'ACTIVE' `).catch(() => ({ rows: [[0]] })), // Long-running SQL (>5 minutes) conn.execute(` SELECT COUNT(*) AS long_running_count, ROUND(MAX((SYSDATE - SQL_EXEC_START) * 1440), 1) AS max_runtime_min FROM V$SESSION WHERE STATUS = 'ACTIVE' AND TYPE = 'USER' AND SQL_EXEC_START IS NOT NULL AND (SYSDATE - SQL_EXEC_START) * 1440 > 5 `).catch(() => ({ rows: [[0, 0]] })) ]); const sessRow = (sessionR.rows || [[0,0,0]])[0] || [0,0,0]; const blockRow = (blockedR.rows || [[0]])[0] || [0]; const longRow = (longSqlR.rows || [[0,0]])[0] || [0,0]; return { total_sessions: parseInt(sessRow[0]) || 0, active_sessions: parseInt(sessRow[1]) || 0, user_sessions: parseInt(sessRow[2]) || 0, blocked_sessions: parseInt(blockRow[0]) || 0, long_running_sql_count: parseInt(longRow[0]) || 0, max_runtime_min: parseFloat(longRow[1]) || 0 }; } catch (err) { console.error('Session stats query failed:', err.message); return { total_sessions: 0, active_sessions: 0, user_sessions: 0, blocked_sessions: 0, long_running_sql_count: 0, max_runtime_min: 0 }; } } /** * querySecurityStats — covers SEC01-SEC07: default passwords, public privs, * audit settings, password policy, unlocked users, obj audit, roles. * All queries are experimental — Oracle security posture checks. */ async function querySecurityStats(conn) { try { const [defaultPwdR, publicPrivR, unlicensedUsersR, profileR, auditR, dbaUsersR] = await Promise.all([ // Default/well-known passwords check (count of accounts using DEFAULT profile OR password = username) conn.execute(` SELECT COUNT(*) AS default_pwd_count FROM DBA_USERS_WITH_DEFPWD WHERE ACCOUNT_STATUS = 'OPEN' `).catch(() => ({ rows: [[0]] })), // Dangerous PUBLIC grants (EXECUTE on UTL_FILE, UTL_HTTP, DBMS_JAVA, etc.) conn.execute(` SELECT COUNT(*) AS dangerous_public_grants FROM DBA_SYS_PRIVS WHERE GRANTEE = 'PUBLIC' AND PRIVILEGE IN ('CREATE PROCEDURE','CREATE ANY PROCEDURE','CREATE ANY TRIGGER','ALTER SYSTEM','ALTER DATABASE','DROP ANY TABLE','EXECUTE ANY PROCEDURE') `).catch(() => ({ rows: [[0]] })), // Unlicensed/schema-only accounts that are OPEN (should be locked) conn.execute(` SELECT COUNT(*) AS open_schema_accounts FROM DBA_USERS WHERE ACCOUNT_STATUS = 'OPEN' AND AUTHENTICATION_TYPE = 'NONE' AND USERNAME NOT IN ('SYS','SYSTEM') `).catch(() => ({ rows: [[0]] })), // Password policy (verify function in DEFAULT profile) conn.execute(` SELECT LIMIT AS password_verify_function FROM DBA_PROFILES WHERE PROFILE = 'DEFAULT' AND RESOURCE_NAME = 'PASSWORD_VERIFY_FUNCTION' `).catch(() => ({ rows: [['NULL']] })), // Audit enabled check conn.execute(` SELECT VALUE AS audit_trail FROM V$PARAMETER WHERE NAME = 'audit_trail' `).catch(() => ({ rows: [['NONE']] })), // DBA-privileged users (should be minimal) conn.execute(` SELECT COUNT(DISTINCT GRANTEE) AS dba_user_count FROM DBA_SYS_PRIVS WHERE PRIVILEGE = 'DBA' AND GRANTEE NOT IN ('SYS','SYSTEM','DBA','SYSMAN') `).catch(() => ({ rows: [[0]] })) ]); const defPwd = parseInt((defaultPwdR.rows || [[0]])[0]?.[0]) || 0; const pubPrivs = parseInt((publicPrivR.rows || [[0]])[0]?.[0]) || 0; const openSchema = parseInt((unlicensedUsersR.rows || [[0]])[0]?.[0]) || 0; const pwdVerifyFn = String((profileR.rows || [['NULL']])[0]?.[0] || 'NULL'); const auditTrail = String((auditR.rows || [['NONE']])[0]?.[0] || 'NONE'); const dbaUserCount = parseInt((dbaUsersR.rows || [[0]])[0]?.[0]) || 0; return { default_pwd_accounts: defPwd, dangerous_public_grants: pubPrivs, open_schema_accounts: openSchema, password_verify_function: pwdVerifyFn, password_policy_active: pwdVerifyFn !== 'NULL' && pwdVerifyFn !== 'null', audit_trail: auditTrail, audit_enabled: auditTrail !== 'NONE' && auditTrail !== 'none', dba_user_count: dbaUserCount }; } catch (err) { console.error('Security stats query failed:', err.message); return { default_pwd_accounts: 0, dangerous_public_grants: 0, open_schema_accounts: 0, password_verify_function: 'UNKNOWN', password_policy_active: false, audit_trail: 'UNKNOWN', audit_enabled: false, dba_user_count: 0 }; } } /** * querySchemaStats — table/segment growth, recyclebin, datafile status. * Covers ST04_SEGMENT_GROWTH, ST05_DATAFILE_STATUS, ST06_RECYCLEBIN_SIZE, ST07. */ async function querySchemaStats(conn) { try { const EXCLUDED_OWNERS = `'SYS','SYSTEM','DBSNMP','SYSMAN','OUTLN','MDSYS','ORDSYS','XDB','CTXSYS','WMSYS','EXFSYS'`; const [topSegR, datafileR, sortR, ftScanR] = await Promise.all([ // Top 10 segments by size conn.execute(` SELECT OWNER, SEGMENT_NAME, SEGMENT_TYPE, ROUND(SUM(BYTES)/1073741824, 2) AS size_gb FROM DBA_SEGMENTS WHERE OWNER NOT IN (${EXCLUDED_OWNERS}) GROUP BY OWNER, SEGMENT_NAME, SEGMENT_TYPE ORDER BY size_gb DESC FETCH FIRST 10 ROWS ONLY `).catch(() => ({ rows: [] })), // Offline/problem datafiles conn.execute(` SELECT COUNT(*) AS problem_count, COUNT(CASE WHEN STATUS = 'OFFLINE' THEN 1 END) AS offline_count FROM DBA_DATA_FILES WHERE STATUS NOT IN ('AVAILABLE','ONLINE') `).catch(() => ({ rows: [[0,0]] })), // Disk sort stats from V$SYSSTAT conn.execute(` SELECT d.VALUE AS disk_sorts, m.VALUE AS mem_sorts FROM (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'sorts (disk)') d, (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'sorts (memory)') m `).catch(() => ({ rows: [[0,0]] })), // Full table scan stats conn.execute(` SELECT s.VALUE AS long_scans, i.VALUE AS index_lookups FROM (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'table scans (long tables)') s, (SELECT VALUE FROM V$SYSSTAT WHERE NAME = 'table fetch by rowid') i `).catch(() => ({ rows: [[0,0]] })) ]); const topSegments = (topSegR.rows || []).map(r => ({ owner: r[0] || '', segment_name: r[1] || '', segment_type: r[2] || '', size_gb: parseFloat(r[3]) || 0 })); const dfRow = (datafileR.rows || [[0,0]])[0] || [0,0]; const sortRow = (sortR.rows || [[0,0]])[0] || [0,0]; const scanRow = (ftScanR.rows || [[0,0]])[0] || [0,0]; const diskSorts = parseInt(sortRow[0]) || 0; const memSorts = parseInt(sortRow[1]) || 0; const diskSortPct = (diskSorts + memSorts) > 0 ? Math.round(diskSorts / (diskSorts + memSorts) * 10000) / 100 : 0; const longScans = parseInt(scanRow[0]) || 0; const indexLookups = parseInt(scanRow[1]) || 0; const ftScanPct = (longScans + indexLookups) > 0 ? Math.round(longScans / (longScans + indexLookups) * 10000) / 100 : 0; return { top_segments: topSegments, problem_datafiles: parseInt(dfRow[0]) || 0, offline_datafiles: parseInt(dfRow[1]) || 0, disk_sort_pct: diskSortPct, disk_sorts: diskSorts, mem_sorts: memSorts, full_table_scan_pct: ftScanPct, long_scans: longScans }; } catch (err) { console.error('Schema stats query failed:', err.message); return { top_segments: [], problem_datafiles: 0, offline_datafiles: 0, disk_sort_pct: 0, disk_sorts: 0, mem_sorts: 0, full_table_scan_pct: 0, long_scans: 0 }; } } // ============================================================ // ADDM Findings (on-demand panel) // ============================================================ async function queryAddmFindings(connParams, lookbackHours) { let connection; try { const connectString = `${connParams.host}:${connParams.port || 1521}/${connParams.serviceName}`; connection = await oracledb.getConnection({ user: connParams.username, password: connParams.password, connectString, connectTimeout: 30 }); // License check: Enterprise Edition required let enterprise = false; try { const r = await connection.execute(`SELECT BANNER FROM V$VERSION WHERE ROWNUM = 1`); const banner = String(r.rows && r.rows[0] && r.rows[0][0] || '').toUpperCase(); enterprise = banner.includes('ENTERPRISE'); } catch (e) { enterprise = true; // assume EE if V$VERSION inaccessible } if (!enterprise) { return { licensed: false, not_licensed_reason: 'Oracle Standard Edition detected. ADDM requires Oracle Enterprise Edition + Diagnostics Pack.', lookback_hours: lookbackHours, task_name: null, task_id: null, findings: [] }; } // Diagnostics Pack check let diagnosticsLicensed = false; try { const r = await connection.execute(` SELECT DETECTED_USAGES, CURRENTLY_USED FROM DBA_FEATURE_USAGE_STATISTICS WHERE NAME = 'Diagnostic Pack' FETCH FIRST 1 ROWS ONLY `); const row = r.rows && r.rows[0]; if (row) { const detected = parseInt(row[0]) || 0; if (detected > 0 || row[1] === 'TRUE') diagnosticsLicensed = true; } } catch (e) { /* fall through */ } if (!diagnosticsLicensed) { try { const p = await connection.execute( `SELECT VALUE FROM v$parameter WHERE name = 'control_management_pack_access'` ); const val = String(p.rows && p.rows[0] && p.rows[0][0] || '').toUpperCase(); diagnosticsLicensed = val === 'DIAGNOSTIC+TUNING' || val === 'DIAGNOSTIC'; } catch (e) { /* ignore */ } } if (!diagnosticsLicensed) { return { licensed: false, not_licensed_reason: 'Oracle Diagnostics Pack license not detected. ADDM queries skipped.', lookback_hours: lookbackHours, task_name: null, task_id: null, findings: [] }; } // Find most recent completed ADDM task let taskId = null, taskName = null; try { const taskResult = await connection.execute(` SELECT t.TASK_ID, t.TASK_NAME FROM DBA_ADVISOR_TASKS t WHERE t.ADVISOR_NAME = 'ADDM' AND t.STATUS = 'COMPLETED' AND t.COMPLETION_DATE >= SYSDATE - :lbDays ORDER BY t.COMPLETION_DATE DESC FETCH FIRST 1 ROWS ONLY `, { lbDays: lookbackHours / 24 }); const taskRow = taskResult.rows && taskResult.rows[0]; if (taskRow) { taskId = parseInt(taskRow[0]); taskName = String(taskRow[1]); } } catch (e) { /* DBA_ADVISOR_TASKS not accessible */ } if (!taskId) { return { licensed: true, lookback_hours: lookbackHours, task_name: null, task_id: null, findings: [], info: `No completed ADDM tasks found in the last ${lookbackHours} hours.` }; } // Fetch findings let rawFindings = []; try { const findResult = await connection.execute(` SELECT f.FINDING_ID, f.TYPE, f.NAME, f.MESSAGE, f.IMPACT_DB_PERCENT, f.SQL_ID FROM DBA_ADVISOR_FINDINGS f WHERE f.TASK_ID = :taskId ORDER BY NVL(f.IMPACT_DB_PERCENT, 0) DESC `, { taskId }); rawFindings = (findResult.rows || []).map(row => ({ finding_id: parseInt(row[0]), type: String(row[1] || 'INFORMATION'), name: String(row[2] || ''), message: String(row[3] || ''), impact_pct: parseFloat(row[4]) || 0, sql_id: row[5] ? String(row[5]) : null })); } catch (e) { /* DBA_ADVISOR_FINDINGS not accessible */ } if (rawFindings.length === 0) { return { licensed: true, lookback_hours: lookbackHours, task_name: taskName, task_id: taskId, findings: [], // Completed task with zero findings = idle DB, not a data gap no_findings_reason: 'Analysis completed — no significant database activity detected. This is normal for idle or lightly-used databases.' }; } // Fetch recommendations const findingIds = rawFindings.map(f => f.finding_id); const recMap = {}; try { const idPlaceholders = findingIds.map((_, i) => `:f${i}`).join(','); const bindObj = { taskId }; findingIds.forEach((id, i) => { bindObj[`f${i}`] = id; }); const recResult = await connection.execute(` SELECT r.FINDING_ID, r.REC_ID, r.TYPE, r.BENEFIT_DB_PERCENT, r.MESSAGE FROM DBA_ADVISOR_RECOMMENDATIONS r WHERE r.TASK_ID = :taskId AND r.FINDING_ID IN (${idPlaceholders}) ORDER BY r.FINDING_ID, r.REC_ID `, bindObj); for (const row of (recResult.rows || [])) { const fid = parseInt(row[0]); if (!recMap[fid]) recMap[fid] = []; recMap[fid].push({ rec_id: parseInt(row[1]), type: String(row[2] || ''), benefit_pct: parseFloat(row[3]) || 0, message: String(row[4] || ''), actions: [] }); } } catch (e) { /* ignore */ } // Fetch actions try { const idPlaceholders = findingIds.map((_, i) => `:f${i}`).join(','); const bindObj = { taskId }; findingIds.forEach((id, i) => { bindObj[`f${i}`] = id; }); const actResult = await connection.execute(` SELECT a.FINDING_ID, a.REC_ID, a.ACTION_ID, a.COMMAND, a.ATTR1, a.ATTR2, a.ATTR3, a.ATTR4 FROM DBA_ADVISOR_ACTIONS a WHERE a.TASK_ID = :taskId AND a.FINDING_ID IN (${idPlaceholders}) ORDER BY a.FINDING_ID, a.REC_ID, a.ACTION_ID `, bindObj); for (const row of (actResult.rows || [])) { const fid = parseInt(row[0]), rid = parseInt(row[1]); const recs = recMap[fid] || []; const rec = recs.find(r => r.rec_id === rid); if (rec) { rec.actions.push({ action_id: parseInt(row[2]), command: String(row[3] || ''), attr1: row[4] ? String(row[4]) : null, attr2: row[5] ? String(row[5]) : null, attr3: row[6] ? String(row[6]) : null, attr4: row[7] ? String(row[7]) : null }); } } } catch (e) { /* ignore */ } // Merge findings + recommendations const findings = rawFindings.map(f => ({ ...f, severity: f.impact_pct >= 10 ? 'critical' : f.impact_pct >= 3 ? 'warning' : 'info', recommendations: recMap[f.finding_id] || [] })); return { licensed: true, lookback_hours: lookbackHours, task_name: taskName, task_id: taskId, findings }; } finally { if (connection) { try { await connection.close(); } catch (e) { /* ignore */ } } } } // ============================================================ // ADDM Run Now (create AWR snapshot + analyze) // ============================================================ async function runAddmNow(connParams) { let connection; try { const connectString = `${connParams.host}:${connParams.port || 1521}/${connParams.serviceName}`; connection = await oracledb.getConnection({ user: connParams.username, password: connParams.password, connectString, connectTimeout: 30 }); // License check let enterprise = false; try { const r = await connection.execute(`SELECT BANNER FROM V$VERSION WHERE ROWNUM = 1`); const banner = String(r.rows && r.rows[0] && r.rows[0][0] || '').toUpperCase(); enterprise = banner.includes('ENTERPRISE'); } catch (e) { enterprise = true; } if (!enterprise) { return { licensed: false, not_licensed_reason: 'Oracle Standard Edition detected. ADDM requires Oracle Enterprise Edition + Diagnostics Pack.', lookback_hours: 0, task_name: null, task_id: null, findings: [] }; } let diagnosticsLicensed = false; try { const r = await connection.execute(`SELECT DETECTED_USAGES, CURRENTLY_USED FROM DBA_FEATURE_USAGE_STATISTICS WHERE NAME = 'Diagnostic Pack' FETCH FIRST 1 ROWS ONLY`); const row = r.rows && r.rows[0]; if (row && (parseInt(row[0]) > 0 || row[1] === 'TRUE')) diagnosticsLicensed = true; } catch (e) { /* fall through */ } if (!diagnosticsLicensed) { try { const p = await connection.execute(`SELECT VALUE FROM v$parameter WHERE name = 'control_management_pack_access'`); const val = String(p.rows && p.rows[0] && p.rows[0][0] || '').toUpperCase(); diagnosticsLicensed = val === 'DIAGNOSTIC+TUNING' || val === 'DIAGNOSTIC'; } catch (e) { /* ignore */ } } if (!diagnosticsLicensed) { return { licensed: false, not_licensed_reason: 'Oracle Diagnostics Pack license not detected. ADDM run-now skipped.', lookback_hours: 0, task_name: null, task_id: null, findings: [] }; } const t0 = Date.now(); // Step 1: create fresh AWR snapshot let newSnapId = null; try { const snapResult = await connection.execute( `BEGIN :snap_id := DBMS_WORKLOAD_REPOSITORY.CREATE_SNAPSHOT(); END;`, { snap_id: { dir: oracledb.BIND_OUT, type: oracledb.NUMBER } } ); newSnapId = snapResult.outBinds && snapResult.outBinds.snap_id; } catch (e) { /* non-fatal */ } const t1 = Date.now(); // Step 2: get two most-recent snapshots let beginSnapId = null, endSnapId = null, dbId = null; try { const snapsResult = await connection.execute(`SELECT s.SNAP_ID, s.DBID FROM DBA_HIST_SNAPSHOT s WHERE s.STATUS = 'Done' ORDER BY s.SNAP_ID DESC FETCH FIRST 2 ROWS ONLY`); const rows = snapsResult.rows || []; if (rows.length >= 2) { endSnapId = parseInt(rows[0][0]); beginSnapId = parseInt(rows[1][0]); dbId = rows[0][1]; } else if (rows.length === 1) { return { licensed: true, lookback_hours: 0, task_name: null, task_id: null, findings: [], run_info: { snapshot_taken: !!newSnapId, snap_id: newSnapId, elapsed_ms: Date.now() - t0 }, info: 'AWR snapshot created but only one snapshot exists — ADDM requires at least two. Run again after the next automatic snapshot.' }; } } catch (e) { return { licensed: true, lookback_hours: 0, task_name: null, task_id: null, findings: [], run_info: { snapshot_taken: !!newSnapId, elapsed_ms: Date.now() - t0 }, info: 'DBA_HIST_SNAPSHOT not accessible — cannot determine snapshot IDs for ADDM analysis.' }; } if (!beginSnapId || !endSnapId) { return { licensed: true, lookback_hours: 0, task_name: null, task_id: null, findings: [], run_info: { snapshot_taken: !!newSnapId, elapsed_ms: Date.now() - t0 }, info: 'Not enough AWR snapshots to run ADDM analysis.' }; } // Step 3: run ADDM analysis let taskId = null, taskName = null; const addmTaskName = 'TUNEVAULT_ADDM_' + Date.now(); try { await connection.execute(`BEGIN DBMS_ADDM.ANALYZE_DB(:task_name, :begin_snap, :end_snap, :db_id); END;`, { task_name: addmTaskName, begin_snap: beginSnapId, end_snap: endSnapId, db_id: dbId }); taskName = addmTaskName; const tidResult = await connection.execute(`SELECT TASK_ID FROM DBA_ADVISOR_TASKS WHERE TASK_NAME = :task_name AND ADVISOR_NAME = 'ADDM' FETCH FIRST 1 ROWS ONLY`, { task_name: addmTaskName }); const tidRow = tidResult.rows && tidResult.rows[0]; if (tidRow) taskId = parseInt(tidRow[0]); } catch (addmErr) { return { licensed: true, lookback_hours: 0, task_name: null, task_id: null, findings: [], run_info: { snapshot_taken: !!newSnapId, begin_snap_id: beginSnapId, end_snap_id: endSnapId, elapsed_ms: Date.now() - t0 }, info: `ADDM analysis failed: ${addmErr.message || addmErr}. User may need EXECUTE on DBMS_ADDM or ADVISOR privilege.` }; } const t2 = Date.now(); if (!taskId) { return { licensed: true, lookback_hours: 0, task_name: taskName, task_id: null, findings: [], run_info: { snapshot_taken: !!newSnapId, elapsed_ms: t2 - t0 }, info: 'ADDM task created but task ID not found — may still be executing. Reload in a few seconds.' }; } // Step 4: fetch findings let rawFindings = []; try { const findResult = await connection.execute(`SELECT f.FINDING_ID, f.TYPE, f.NAME, f.MESSAGE, f.IMPACT_DB_PERCENT, f.SQL_ID FROM DBA_ADVISOR_FINDINGS f WHERE f.TASK_ID = :taskId ORDER BY NVL(f.IMPACT_DB_PERCENT, 0) DESC`, { taskId }); rawFindings = (findResult.rows || []).map(row => ({ finding_id: parseInt(row[0]), type: String(row[1] || 'INFORMATION'), name: String(row[2] || ''), message: String(row[3] || ''), impact_pct: parseFloat(row[4]) || 0, sql_id: row[5] ? String(row[5]) : null })); } catch (e) { /* ignore */ } const findingIds = rawFindings.map(f => f.finding_id); const recMap = {}; if (findingIds.length > 0) { try { const idPh = findingIds.map((_, i) => `:f${i}`).join(','); const bObj = { taskId }; findingIds.forEach((id, i) => { bObj[`f${i}`] = id; }); const recResult = await connection.execute(`SELECT r.FINDING_ID, r.REC_ID, r.TYPE, r.BENEFIT_DB_PERCENT, r.MESSAGE FROM DBA_ADVISOR_RECOMMENDATIONS r WHERE r.TASK_ID = :taskId AND r.FINDING_ID IN (${idPh}) ORDER BY r.FINDING_ID, r.REC_ID`, bObj); for (const row of (recResult.rows || [])) { const fid = parseInt(row[0]); if (!recMap[fid]) recMap[fid] = []; recMap[fid].push({ rec_id: parseInt(row[1]), type: String(row[2] || ''), benefit_pct: parseFloat(row[3]) || 0, message: String(row[4] || ''), actions: [] }); } } catch (e) { /* ignore */ } try { const idPh = findingIds.map((_, i) => `:f${i}`).join(','); const bObj = { taskId }; findingIds.forEach((id, i) => { bObj[`f${i}`] = id; }); const actResult = await connection.execute(`SELECT a.FINDING_ID, a.REC_ID, a.ACTION_ID, a.COMMAND, a.ATTR1, a.ATTR2, a.ATTR3, a.ATTR4 FROM DBA_ADVISOR_ACTIONS a WHERE a.TASK_ID = :taskId AND a.FINDING_ID IN (${idPh}) ORDER BY a.FINDING_ID, a.REC_ID, a.ACTION_ID`, bObj); for (const row of (actResult.rows || [])) { const fid = parseInt(row[0]), rid = parseInt(row[1]); const recs = recMap[fid] || []; const rec = recs.find(r => r.rec_id === rid); if (rec) rec.actions.push({ action_id: parseInt(row[2]), command: String(row[3] || ''), attr1: row[4] ? String(row[4]) : null, attr2: row[5] ? String(row[5]) : null, attr3: row[6] ? String(row[6]) : null, attr4: row[7] ? String(row[7]) : null }); } } catch (e) { /* ignore */ } } const findings = rawFindings.map(f => ({ ...f, severity: f.impact_pct >= 10 ? 'critical' : f.impact_pct >= 3 ? 'warning' : 'info', recommendations: recMap[f.finding_id] || [] })); return { licensed: true, lookback_hours: 0, task_name: taskName, task_id: taskId, findings, run_info: { snapshot_taken: !!newSnapId, snap_id: newSnapId, begin_snap_id: beginSnapId, end_snap_id: endSnapId, snapshot_ms: t1 - t0, analysis_ms: t2 - t1, total_ms: Date.now() - t0 }, ...(findings.length === 0 ? { no_findings_reason: 'Fresh ADDM analysis completed — no significant database activity detected in the snapshot interval.' } : {}) }; } finally { if (connection) { try { await connection.close(); } catch (e) { /* ignore */ } } } } // ============================================================ // Maintenance Windows (on-demand panel — all editions) // ============================================================ async function queryMaintenanceWindows(connParams) { let connection; try { const connectString = `${connParams.host}:${connParams.port || 1521}/${connParams.serviceName}`; connection = await oracledb.getConnection({ user: connParams.username, password: connParams.password, connectString, connectTimeout: 30 }); // 1. Autotask clients const clients = []; const CLIENT_NAMES = ['auto optimizer stats collection', 'sql tuning advisor', 'auto space advisor']; let clientRows = []; try { const cr = await connection.execute(` SELECT c.CLIENT_NAME, c.STATUS, h.JOB_START_TIME, h.JOB_STATUS, h.JOB_DURATION FROM DBA_AUTOTASK_CLIENT c LEFT JOIN ( SELECT CLIENT_NAME, JOB_START_TIME, JOB_STATUS, JOB_DURATION, ROW_NUMBER() OVER (PARTITION BY CLIENT_NAME ORDER BY JOB_START_TIME DESC) AS rn FROM DBA_AUTOTASK_JOB_HISTORY WHERE JOB_START_TIME >= SYSTIMESTAMP - INTERVAL '7' DAY ) h ON h.CLIENT_NAME = c.CLIENT_NAME AND h.rn = 1 WHERE c.CLIENT_NAME IN ('auto optimizer stats collection','sql tuning advisor','auto space advisor') ORDER BY c.CLIENT_NAME `); clientRows = cr.rows || []; } catch (e) { /* DBA_AUTOTASK_CLIENT not accessible */ } // Run stats per client in last 7 days const runStats = {}; try { const rs = await connection.execute(` SELECT CLIENT_NAME, COUNT(*) AS total_runs, SUM(CASE WHEN JOB_STATUS != 'SUCCEEDED' THEN 1 ELSE 0 END) AS failures FROM DBA_AUTOTASK_JOB_HISTORY WHERE JOB_START_TIME >= SYSTIMESTAMP - INTERVAL '7' DAY AND CLIENT_NAME IN ('auto optimizer stats collection','sql tuning advisor','auto space advisor') GROUP BY CLIENT_NAME `); for (const row of (rs.rows || [])) { runStats[String(row[0]).toLowerCase()] = { runs: parseInt(row[1]) || 0, failures: parseInt(row[2]) || 0 }; } } catch (e) { /* DBA_AUTOTASK_JOB_HISTORY not accessible */ } // Tuning Pack license check (for sql tuning advisor gating) let tuningLicensed = false; try { const r = await connection.execute(` SELECT DETECTED_USAGES, CURRENTLY_USED FROM DBA_FEATURE_USAGE_STATISTICS WHERE NAME = 'SQL Tuning Advisor' FETCH FIRST 1 ROWS ONLY `); const row = r.rows && r.rows[0]; if (row && (parseInt(row[0]) > 0 || row[1] === 'TRUE')) tuningLicensed = true; } catch (e) { try { const p = await connection.execute(`SELECT VALUE FROM v$parameter WHERE name = 'control_management_pack_access'`); const val = String(p.rows && p.rows[0] && p.rows[0][0] || '').toUpperCase(); tuningLicensed = val === 'DIAGNOSTIC+TUNING' || val === 'TUNING'; } catch (e2) { /* inaccessible */ } } for (const row of clientRows) { const name = String(row[0] || ''); const status = String(row[1] || ''); const lastRunDate = row[2] ? new Date(row[2]).toISOString() : null; const lastRunStatus = row[3] ? String(row[3]) : null; const durationRaw = row[4]; let durationSecs = null; if (durationRaw) { const m = String(durationRaw).match(/(\d+)\s+(\d+):(\d+):(\d+)/); if (m) durationSecs = parseInt(m[1]) * 86400 + parseInt(m[2]) * 3600 + parseInt(m[3]) * 60 + parseInt(m[4]); } const key = name.toLowerCase(); const stats = runStats[key] || { runs: 0, failures: 0 }; const isTuningAdvisor = key === 'sql tuning advisor'; let trafficLight; if (status !== 'ENABLED') trafficLight = 'red'; else if (stats.runs > 0 && stats.failures < stats.runs) trafficLight = 'green'; else trafficLight = 'amber'; clients.push({ client_name: name, status, last_run_date: lastRunDate, last_run_status: lastRunStatus, last_run_duration_secs: durationSecs, runs_7d: stats.runs, failures_7d: stats.failures, traffic_light: trafficLight, tuning_pack_required: isTuningAdvisor, tuning_pack_licensed: isTuningAdvisor ? tuningLicensed : null }); } // Fill missing clients for (const name of CLIENT_NAMES) { if (!clients.find(c => c.client_name === name)) { clients.push({ client_name: name, status: 'UNKNOWN', last_run_date: null, last_run_status: null, last_run_duration_secs: null, runs_7d: 0, failures_7d: 0, traffic_light: 'amber', tuning_pack_required: name === 'sql tuning advisor', tuning_pack_licensed: name === 'sql tuning advisor' ? tuningLicensed : null }); } } // 2. Maintenance windows const windows = []; try { const wr = await connection.execute(` SELECT WINDOW_NAME, TO_CHAR(NEXT_START_DATE, 'YYYY-MM-DD HH24:MI:SS') AS next_start, REPEAT_INTERVAL, EXTRACT(HOUR FROM DURATION) * 60 + EXTRACT(MINUTE FROM DURATION) AS duration_mins, ENABLED, TO_CHAR(LAST_START_DATE, 'YYYY-MM-DD HH24:MI:SS') AS last_start, TO_CHAR(LAST_END_DATE, 'YYYY-MM-DD HH24:MI:SS') AS last_end FROM DBA_SCHEDULER_WINDOWS WHERE WINDOW_NAME LIKE '%DAY_WINDOW' OR WINDOW_NAME LIKE '%WINDOW' ORDER BY WINDOW_NAME `); for (const row of (wr.rows || [])) { const name = String(row[0] || ''); if (!name.endsWith('WINDOW')) continue; const durationMins = parseInt(row[3]) || 0; windows.push({ window_name: name, next_start_date: row[1] ? String(row[1]) : null, repeat_interval: row[2] ? String(row[2]) : null, duration_hours: +(durationMins / 60).toFixed(2), enabled: row[4] === 'TRUE' || row[4] === true, last_start_date: row[5] ? String(row[5]) : null, last_end_date: row[6] ? String(row[6]) : null }); } } catch (e) { /* DBA_SCHEDULER_WINDOWS not accessible */ } // 3. Stale stats let staleCount = 0; let staleTop10 = []; try { const sc = await connection.execute(`SELECT COUNT(*) FROM DBA_TAB_STATISTICS WHERE STALE_STATS = 'YES'`); staleCount = parseInt(sc.rows && sc.rows[0] && sc.rows[0][0]) || 0; } catch (e) { /* not accessible */ } try { const st = await connection.execute(` SELECT OWNER, TABLE_NAME, NUM_ROWS, TO_CHAR(LAST_ANALYZED, 'YYYY-MM-DD HH24:MI:SS') AS last_analyzed, STALE_STATS FROM DBA_TAB_STATISTICS WHERE STALE_STATS = 'YES' AND OWNER NOT IN ('SYS','SYSTEM','OUTLN','DBSNMP','XDB','MDSYS','CTXSYS','OLAPSYS','WMSYS','ORDSYS') ORDER BY NUM_ROWS DESC NULLS LAST FETCH FIRST 10 ROWS ONLY `); staleTop10 = (st.rows || []).map(row => ({ owner: String(row[0] || ''), table_name: String(row[1] || ''), num_rows: parseInt(row[2]) || 0, last_analyzed: row[3] ? String(row[3]) : null, stale_stats: String(row[4] || '') })); } catch (e) { /* not accessible */ } const disabledClients = clients.filter(c => c.traffic_light === 'red').map(c => c.client_name); const disabledWindows = windows.filter(w => !w.enabled || w.duration_hours === 0).map(w => w.window_name); return { autotask_clients: clients, windows, stale_tables_count: staleCount, stale_tables_top10: staleTop10, disabled_clients: disabledClients, disabled_windows: disabledWindows }; } finally { if (connection) { try { await connection.close(); } catch (e) { /* ignore */ } } } } // ============================================================ // Oracle Parameters // ============================================================ async function queryOracleParameters({ host, port, serviceName, username, password }) { let connection; try { oracledb.outFormat = oracledb.OUT_FORMAT_ARRAY; connection = await oracledb.getConnection({ user: username, password, connectString: `${host}:${port}/${serviceName}`, connectTimeout: 30 }); async function runQuery(sql) { try { const r = await connection.execute(sql); return r.rows || []; } catch (e) { return []; } } const [paramRows, osRows, licRows, dfRows] = await Promise.all([ runQuery(`SELECT name, value, isdefault, isinstance_modifiable, issys_modifiable, description FROM v$parameter ORDER BY name`), runQuery(`SELECT stat_name, value FROM v$osstat WHERE stat_name IN ('PHYSICAL_MEMORY_BYTES','NUM_CPUS','NUM_CPU_CORES')`), runQuery(`SELECT sessions_highwater FROM v$license`), runQuery(`SELECT COUNT(*) FROM v$datafile`) ]); const osMap = {}; osRows.forEach(r => { osMap[String(r[0])] = Number(r[1]) || 0; }); const ramBytes = osMap['PHYSICAL_MEMORY_BYTES'] || 0; const ramGb = ramBytes > 0 ? Math.round(ramBytes / (1024 ** 3) * 10) / 10 : 0; const cpuCount = osMap['NUM_CPUS'] || osMap['NUM_CPU_CORES'] || 1; const sessHW = licRows.length > 0 ? (Number(licRows[0][0]) || 0) : 0; const datafileCount = dfRows.length > 0 ? (Number(dfRows[0][0]) || 0) : 0; const paramMap = {}; paramRows.forEach(r => { paramMap[String(r[0]).toLowerCase()] = String(r[1] || ''); }); const isEE = 'inmemory_size' in paramMap || Number(paramMap['cpu_count'] || 0) > 0; const edition = isEE ? 'EE' : 'SE'; function parseBytes(val) { if (!val || val === '') return 0; const s = String(val).trim().toUpperCase(); if (/^\d+$/.test(s)) return Number(s); const m = s.match(/^(\d+(?:\.\d+)?)\s*([KMGT]?)$/); if (!m) return 0; const n = parseFloat(m[1]); const mult = { K: 1024, M: 1024**2, G: 1024**3, T: 1024**4 }[m[2]] || 1; return Math.round(n * mult); } function fmtBytes(bytes) { if (bytes === 0) return '0'; if (bytes >= 1024**3) return (bytes / 1024**3).toFixed(1).replace(/\.0$/, '') + 'G'; if (bytes >= 1024**2) return (bytes / 1024**2).toFixed(0) + 'M'; if (bytes >= 1024) return (bytes / 1024).toFixed(0) + 'K'; return String(bytes); } function evalParam(name, current) { const n = name.toLowerCase(); const cur = (current || '').trim(); const curNum = parseBytes(cur); if (n === 'memory_target' || n === 'memory_max_target') { if (ramGb === 0) return { status: 'unknown', recommended: 'N/A', note: '' }; const rec = Math.round(ramGb * 0.7 * 1024**3); if (curNum === 0) return { status: 'green', recommended: 'AMM disabled (manual SGA/PGA)', note: 'Preferred on Linux' }; if (curNum < rec * 0.5) return { status: 'red', recommended: fmtBytes(rec), note: `Undersized for ${ramGb} GB RAM` }; if (curNum > ramGb * 0.9 * 1024**3) return { status: 'amber', recommended: fmtBytes(rec), note: 'Leaves too little RAM for OS' }; return { status: 'green', recommended: fmtBytes(rec), note: '' }; } if (n === 'sga_target') { if (ramGb === 0) return { status: 'unknown', recommended: 'N/A', note: '' }; const rec = Math.round(ramGb * 0.45 * 1024**3); if (curNum === 0) return { status: 'amber', recommended: fmtBytes(rec), note: 'SGA auto-tuning is off' }; if (curNum < rec * 0.5) return { status: 'amber', recommended: fmtBytes(rec), note: `Consider ~45% of RAM (${ramGb} GB)` }; return { status: 'green', recommended: fmtBytes(rec), note: '' }; } if (n === 'sga_max_size') { if (ramGb === 0) return { status: 'unknown', recommended: 'N/A', note: '' }; const rec = Math.round(ramGb * 0.6 * 1024**3); if (curNum < rec * 0.6) return { status: 'amber', recommended: fmtBytes(rec), note: 'Cap may restrict SGA growth' }; return { status: 'green', recommended: fmtBytes(rec), note: '' }; } if (n === 'pga_aggregate_target') { if (ramGb === 0) return { status: 'unknown', recommended: 'N/A', note: '' }; const rec = Math.round(ramGb * 0.25 * 1024**3); if (curNum === 0) return { status: 'amber', recommended: fmtBytes(rec), note: 'Auto PGA is off' }; if (curNum < rec * 0.4) return { status: 'amber', recommended: fmtBytes(rec), note: '~25% of RAM recommended' }; return { status: 'green', recommended: fmtBytes(rec), note: '' }; } if (n === 'pga_aggregate_limit') { if (ramGb === 0) return { status: 'unknown', recommended: 'N/A', note: '' }; const rec = Math.round(ramGb * 0.5 * 1024**3); if (curNum > 0 && curNum < rec * 0.4) return { status: 'amber', recommended: fmtBytes(rec), note: 'Hard PGA limit may kill large sorts' }; return { status: 'green', recommended: fmtBytes(rec), note: '' }; } if (n === 'db_cache_size') { if (paramMap['sga_target'] && parseBytes(paramMap['sga_target']) > 0) return { status: 'green', recommended: 'Auto-tuned by SGA_TARGET', note: '' }; const rec = Math.round((ramGb || 16) * 0.3 * 1024**3); if (curNum > 0 && curNum < rec * 0.4) return { status: 'amber', recommended: fmtBytes(rec), note: '~30% of RAM for buffer cache' }; return { status: 'green', recommended: fmtBytes(rec), note: '' }; } if (n === 'shared_pool_size') { if (paramMap['sga_target'] && parseBytes(paramMap['sga_target']) > 0) return { status: 'green', recommended: 'Auto-tuned by SGA_TARGET', note: '' }; if (curNum > 0 && curNum < 256 * 1024**2) return { status: 'amber', recommended: '256M+', note: 'Shared pool too small' }; return { status: 'green', recommended: '256M – 512M', note: '' }; } if (n === 'large_pool_size') { if (curNum < 64 * 1024**2) return { status: 'amber', recommended: '64M+', note: 'Required for RMAN and parallel execution' }; return { status: 'green', recommended: '64M+', note: '' }; } if (n === 'java_pool_size') return { status: 'green', recommended: '32M–128M', note: 'Only matters if Java stored procedures are used' }; if (n === 'streams_pool_size') return { status: 'green', recommended: '0 (auto) or 64M+ if replication used', note: '' }; if (n === 'processes') { const c = Number(cur) || 0; const hwRec = sessHW > 0 ? Math.max(300, Math.ceil(sessHW * 1.2)) : 300; if (c === 0) return { status: 'unknown', recommended: String(hwRec), note: '' }; if (sessHW > 0 && c < sessHW * 1.1) return { status: 'red', recommended: String(hwRec), note: `Process limit nearly exhausted (HW: ${sessHW})` }; if (sessHW > 0 && c < sessHW * 1.3) return { status: 'amber', recommended: String(hwRec), note: `Less than 30% headroom (HW: ${sessHW})` }; return { status: 'green', recommended: `~${hwRec}`, note: '' }; } if (n === 'sessions') { const proc = Number(paramMap['processes'] || '0'); const derived = Math.ceil(proc * 1.5) + 22; if (proc > 0 && Number(cur) < derived * 0.9) return { status: 'amber', recommended: String(derived), note: 'Should be CEIL(processes*1.5)+22' }; return { status: 'green', recommended: proc > 0 ? String(derived) : 'derived from PROCESSES', note: '' }; } if (n === 'open_cursors') { const c = Number(cur) || 0; if (c < 300) return { status: 'red', recommended: '300+', note: 'Risk of ORA-01000' }; if (c < 500) return { status: 'amber', recommended: '500–1000', note: 'Low — increase if ORA-01000 occurs' }; return { status: 'green', recommended: '300–1000', note: '' }; } if (n === 'undo_retention') { const c = Number(cur) || 0; if (c < 900) return { status: 'amber', recommended: '900+', note: 'ORA-01555 risk' }; if (c > 86400) return { status: 'amber', recommended: '3600–7200', note: 'May cause UNDO tablespace bloat' }; return { status: 'green', recommended: '900–3600 sec', note: '' }; } if (n === 'undo_tablespace') { if (!cur) return { status: 'amber', recommended: 'UNDOTBS1', note: 'No undo tablespace configured' }; return { status: 'green', recommended: cur, note: '' }; } if (n === 'db_recovery_file_dest_size') { if (curNum === 0) return { status: 'amber', recommended: '10G+', note: 'FRA not configured' }; return { status: 'green', recommended: fmtBytes(curNum), note: 'Check V$RECOVERY_FILE_DEST for usage %' }; } if (n === 'log_buffer') { if (curNum < 8 * 1024**2) return { status: 'amber', recommended: '8M–32M', note: 'Small log buffer may cause redo latch waits' }; return { status: 'green', recommended: '8M–32M', note: '' }; } if (n === 'optimizer_mode') { const v = cur.toUpperCase(); if (v === 'ALL_ROWS') return { status: 'green', recommended: 'ALL_ROWS', note: '' }; return { status: 'amber', recommended: 'ALL_ROWS', note: 'FIRST_ROWS degrades throughput — only for fetch-first OLTP' }; } if (n === 'cursor_sharing') { if (cur.toUpperCase() === 'EXACT') return { status: 'green', recommended: 'EXACT', note: '' }; if (cur.toUpperCase() === 'FORCE') return { status: 'amber', recommended: 'EXACT', note: 'FORCE is a workaround — fix the SQL instead' }; return { status: 'amber', recommended: 'EXACT', note: '' }; } if (n === 'parallel_max_servers') { if (cpuCount === 1) return { status: 'green', recommended: '0–2', note: 'Single-CPU instance' }; const rec = cpuCount * 2; if (Number(cur) > cpuCount * 8) return { status: 'amber', recommended: String(rec), note: 'Excessive parallelism may thrash CPU' }; return { status: 'green', recommended: `${cpuCount}–${rec}`, note: '' }; } if (n === 'result_cache_max_size') { if (edition !== 'EE') return { status: 'green', recommended: 'N/A (SE)', note: 'EE feature' }; if (curNum < 128 * 1024**2) return { status: 'amber', recommended: '128M+', note: 'Result cache too small' }; return { status: 'green', recommended: '128M–512M', note: '' }; } if (n === 'inmemory_size') { if (edition !== 'EE') return { status: 'green', recommended: 'N/A (SE)', note: 'EE only' }; if (curNum === 0) return { status: 'green', recommended: '0 (disabled)', note: 'Enable only if licensed' }; return { status: 'green', recommended: fmtBytes(curNum), note: '' }; } if (n === 'audit_trail') { const v = cur.toUpperCase(); if (v === 'NONE' || v === 'FALSE' || v === '0') return { status: 'red', recommended: 'DB or OS', note: 'No auditing — compliance risk' }; return { status: 'green', recommended: 'DB or OS', note: '' }; } if (n === 'sec_case_sensitive_logon') { if (cur.toUpperCase() === 'FALSE' || cur === '0') return { status: 'amber', recommended: 'TRUE', note: 'Case-insensitive passwords weaken security' }; return { status: 'green', recommended: 'TRUE', note: '' }; } if (n === 'remote_login_passwordfile') { const v = cur.toUpperCase(); if (v === 'NONE') return { status: 'amber', recommended: 'EXCLUSIVE', note: 'Required for remote SYSDBA' }; if (v === 'SHARED') return { status: 'amber', recommended: 'EXCLUSIVE', note: 'Use EXCLUSIVE' }; return { status: 'green', recommended: 'EXCLUSIVE', note: '' }; } if (n === 'os_authent_prefix') { if (cur !== '') return { status: 'amber', recommended: '""', note: 'Allows OS-authenticated logins' }; return { status: 'green', recommended: '""', note: '' }; } if (n === 'db_files') { const limit = Number(cur) || 200; if (datafileCount > 0 && datafileCount >= limit * 0.8) return { status: 'red', recommended: String(Math.max(limit * 2, 1000)), note: `${datafileCount}/${limit} datafiles used` }; if (datafileCount > 0 && datafileCount >= limit * 0.6) return { status: 'amber', recommended: String(limit), note: `${datafileCount}/${limit} datafiles used` }; return { status: 'green', recommended: '200+', note: datafileCount > 0 ? `${datafileCount}/${limit} used` : '' }; } if (n === 'db_block_size') { if (Number(cur) < 8192) return { status: 'amber', recommended: '8192', note: 'Sub-8k block size is uncommon' }; return { status: 'green', recommended: '8192 (OLTP) or 16384 (DW)', note: '' }; } if (n === 'filesystemio_options') { const v = cur.toUpperCase(); if (v === 'SETALL' || v === 'ASYNCH,DIRECTIO') return { status: 'green', recommended: 'SETALL', note: '' }; if (v === 'NONE' || v === '') return { status: 'amber', recommended: 'SETALL', note: 'Enable on Linux for async + directIO' }; return { status: 'amber', recommended: 'SETALL', note: `Current: ${cur}` }; } if (n === 'disk_asynch_io') { if (cur.toUpperCase() === 'TRUE') return { status: 'green', recommended: 'TRUE', note: '' }; return { status: 'amber', recommended: 'TRUE', note: 'Async I/O reduces wait time' }; } if (n === 'compatible') return { status: 'green', recommended: 'Match current version', note: 'Lowering prevents rolling back upgrades' }; if (n === 'control_file_record_keep_time') { if (Number(cur) < 7) return { status: 'amber', recommended: '30', note: 'Low retention — RMAN catalog may miss history' }; return { status: 'green', recommended: '30+', note: '' }; } return { status: 'green', recommended: cur || '(default)', note: '' }; } const TRACKED = { memory_target: 'Memory', memory_max_target: 'Memory', sga_target: 'Memory', sga_max_size: 'Memory', pga_aggregate_target: 'Memory', pga_aggregate_limit: 'Memory', db_cache_size: 'Memory', shared_pool_size: 'Memory', large_pool_size: 'Memory', java_pool_size: 'Memory', streams_pool_size: 'Memory', processes: 'Processes & Sessions', sessions: 'Processes & Sessions', open_cursors: 'Processes & Sessions', undo_tablespace: 'Undo & Recovery', undo_retention: 'Undo & Recovery', db_recovery_file_dest_size: 'Undo & Recovery', log_buffer: 'Undo & Recovery', optimizer_mode: 'Performance', cursor_sharing: 'Performance', parallel_max_servers: 'Performance', result_cache_max_size: 'Performance', inmemory_size: 'Performance', audit_trail: 'Security & Audit', sec_case_sensitive_logon: 'Security & Audit', remote_login_passwordfile: 'Security & Audit', os_authent_prefix: 'Security & Audit', db_files: 'Storage & I/O', db_block_size: 'Storage & I/O', filesystemio_options: 'Storage & I/O', disk_asynch_io: 'Storage & I/O', compatible: 'Misc', nls_characterset: 'Misc', nls_nchar_characterset: 'Misc', diagnostic_dest: 'Misc', control_file_record_keep_time: 'Misc' }; const parameters = []; for (const [pname, category] of Object.entries(TRACKED)) { const raw = paramMap[pname.toLowerCase()]; if (raw === undefined) continue; const { status, recommended, note } = evalParam(pname, raw); const row = paramRows.find(r => String(r[0]).toLowerCase() === pname.toLowerCase()); const isDynamic = row ? (String(row[3]) === 'TRUE') : false; parameters.push({ name: pname, category, current_value: raw || '(not set)', recommended, status, note, is_dynamic: isDynamic, scope: isDynamic ? 'SCOPE=BOTH' : 'SCOPE=SPFILE -- restart required' }); } return { parameters, hardware: { ram_gb: ramGb, cpu_count: cpuCount }, sessions_highwater: sessHW, datafile_count: datafileCount, edition }; } finally { if (connection) { try { await connection.close(); } catch (e) { /* ignore */ } } } } // ============================================================ // Error Formatting // ============================================================ function formatOracleError(err) { const msg = err.message || String(err); if (msg.includes('ORA-12154')) return 'TNS name could not be resolved. Check service name.'; if (msg.includes('ORA-12541')) return 'No listener at the specified host and port. Check host and port.'; if (msg.includes('ORA-12514')) return 'Service name not found. Check the service name or SID.'; if (msg.includes('ORA-12170')) return 'Connection timed out. Host may be unreachable.'; if (msg.includes('ORA-01017')) return 'Invalid username or password.'; if (msg.includes('ORA-28000')) return 'Account is locked.'; if (msg.includes('ORA-28001')) return 'Password has expired.'; if (msg.includes('ORA-01031')) return 'Insufficient privileges. User needs SELECT_CATALOG_ROLE or explicit grants.'; if (msg.includes('ORA-00942')) return 'Table or view does not exist. User may need additional grants.'; if (msg.includes('ENOTFOUND')) return 'Hostname not found. Check the hostname or IP address.'; if (msg.includes('ECONNREFUSED')) return 'Connection refused. Check host, port, and that the Oracle listener is running.'; if (msg.includes('ETIMEDOUT')) return 'Connection timed out. Host may be unreachable.'; if (msg.includes('NJS-500')) return 'oracledb thin client error. Ensure Oracle DB is version 12.1+.'; return msg; }