diff --git a/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js index 16b4f400..31e178cc 100644 --- a/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js +++ b/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js @@ -1,577 +1,359 @@ 'use strict'; 'require view'; -'require ui'; +'require dom'; +'require poll'; 'require rpc'; -var callStatus = rpc.declare({ - object: 'luci.ollama', - method: 'status', - expect: { } -}); +var api = { + status: rpc.declare({ object: 'luci.ollama', method: 'status' }), + models: rpc.declare({ object: 'luci.ollama', method: 'models' }), + health: rpc.declare({ object: 'luci.ollama', method: 'health' }), + start: rpc.declare({ object: 'luci.ollama', method: 'start' }), + stop: rpc.declare({ object: 'luci.ollama', method: 'stop' }), + restart: rpc.declare({ object: 'luci.ollama', method: 'restart' }), + pull: rpc.declare({ object: 'luci.ollama', method: 'model_pull', params: ['name'] }), + remove: rpc.declare({ object: 'luci.ollama', method: 'model_remove', params: ['name'] }), + chat: rpc.declare({ object: 'luci.ollama', method: 'chat', params: ['model', 'message'] }) +}; -var callModels = rpc.declare({ - object: 'luci.ollama', - method: 'models', - expect: { models: [] } -}); - -var callHealth = rpc.declare({ - object: 'luci.ollama', - method: 'health', - expect: { healthy: false } -}); - -var callStart = rpc.declare({ - object: 'luci.ollama', - method: 'start', - expect: { success: false } -}); - -var callStop = rpc.declare({ - object: 'luci.ollama', - method: 'stop', - expect: { success: false } -}); - -var callRestart = rpc.declare({ - object: 'luci.ollama', - method: 'restart', - expect: { success: false } -}); - -function formatBytes(bytes) { - if (!bytes || bytes === 0) return '0 B'; - var k = 1024; - var sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - var i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +function fmtBytes(b) { + if (!b) return '-'; + var u = ['B', 'KB', 'MB', 'GB']; + var i = 0; + while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } + return b.toFixed(1) + ' ' + u[i]; } -function formatUptime(seconds) { - if (!seconds) return 'N/A'; - var days = Math.floor(seconds / 86400); - var hours = Math.floor((seconds % 86400) / 3600); - var mins = Math.floor((seconds % 3600) / 60); - if (days > 0) return days + 'd ' + hours + 'h'; - if (hours > 0) return hours + 'h ' + mins + 'm'; - return mins + 'm'; +function fmtUptime(s) { + if (!s) return '-'; + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + return h > 0 ? h + 'h ' + m + 'm' : m + 'm'; } return view.extend({ - title: _('Ollama Dashboard'), - refreshInterval: 5000, - data: null, + css: ` + :root { --ol-bg: #0f172a; --ol-card: #1e293b; --ol-border: #334155; --ol-text: #f1f5f9; --ol-muted: #94a3b8; --ol-accent: #f97316; --ol-success: #22c55e; --ol-danger: #ef4444; } + .ol-wrap { font-family: system-ui, sans-serif; background: var(--ol-bg); color: var(--ol-text); min-height: 100vh; padding: 1rem; } + .ol-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--ol-border); } + .ol-title { font-size: 1.5rem; font-weight: 700; display: flex; align-items: center; gap: 0.5rem; } + .ol-title span { font-size: 1.75rem; } + .ol-badge { padding: 0.25rem 0.75rem; border-radius: 1rem; font-size: 0.75rem; font-weight: 600; } + .ol-badge.on { background: rgba(34,197,94,0.2); color: var(--ol-success); } + .ol-badge.off { background: rgba(239,68,68,0.2); color: var(--ol-danger); } + .ol-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } + .ol-stat { background: var(--ol-card); border: 1px solid var(--ol-border); border-radius: 0.5rem; padding: 1rem; text-align: center; } + .ol-stat-val { font-size: 1.5rem; font-weight: 700; color: var(--ol-accent); } + .ol-stat-lbl { font-size: 0.7rem; color: var(--ol-muted); text-transform: uppercase; margin-top: 0.25rem; } + .ol-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 1rem; } + .ol-card { background: var(--ol-card); border: 1px solid var(--ol-border); border-radius: 0.5rem; overflow: hidden; } + .ol-card-head { padding: 0.75rem 1rem; background: rgba(0,0,0,0.2); border-bottom: 1px solid var(--ol-border); font-weight: 600; display: flex; justify-content: space-between; align-items: center; } + .ol-card-body { padding: 1rem; } + .ol-btn { padding: 0.5rem 1rem; border: none; border-radius: 0.375rem; font-size: 0.8rem; font-weight: 500; cursor: pointer; transition: opacity 0.2s; } + .ol-btn:hover { opacity: 0.8; } + .ol-btn:disabled { opacity: 0.4; cursor: not-allowed; } + .ol-btn-primary { background: var(--ol-accent); color: #fff; } + .ol-btn-success { background: var(--ol-success); color: #fff; } + .ol-btn-danger { background: var(--ol-danger); color: #fff; } + .ol-btn-sm { padding: 0.25rem 0.5rem; font-size: 0.7rem; } + .ol-btns { display: flex; gap: 0.5rem; flex-wrap: wrap; } + .ol-input { width: 100%; padding: 0.5rem; border: 1px solid var(--ol-border); border-radius: 0.375rem; background: var(--ol-bg); color: var(--ol-text); font-size: 0.875rem; } + .ol-input:focus { outline: none; border-color: var(--ol-accent); } + .ol-row { display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid var(--ol-border); font-size: 0.875rem; } + .ol-row:last-child { border-bottom: none; } + .ol-row-lbl { color: var(--ol-muted); } + .ol-model { display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background: var(--ol-bg); border-radius: 0.375rem; margin-bottom: 0.5rem; } + .ol-model:last-child { margin-bottom: 0; } + .ol-model-name { font-weight: 500; } + .ol-model-size { font-size: 0.75rem; color: var(--ol-muted); } + .ol-empty { text-align: center; padding: 2rem; color: var(--ol-muted); } + .ol-chat-box { height: 200px; overflow-y: auto; background: var(--ol-bg); border: 1px solid var(--ol-border); border-radius: 0.375rem; padding: 0.75rem; margin-bottom: 0.75rem; font-size: 0.875rem; } + .ol-chat-msg { margin-bottom: 0.75rem; } + .ol-chat-msg.user { color: var(--ol-accent); } + .ol-chat-msg.ai { color: var(--ol-text); white-space: pre-wrap; } + .ol-chat-input { display: flex; gap: 0.5rem; } + .ol-chat-input input { flex: 1; } + .ol-select { padding: 0.5rem; border: 1px solid var(--ol-border); border-radius: 0.375rem; background: var(--ol-bg); color: var(--ol-text); font-size: 0.875rem; margin-bottom: 0.75rem; } + .ol-toast { position: fixed; bottom: 1rem; right: 1rem; padding: 0.75rem 1rem; border-radius: 0.375rem; font-size: 0.875rem; z-index: 9999; } + .ol-toast.success { background: var(--ol-success); color: #fff; } + .ol-toast.error { background: var(--ol-danger); color: #fff; } + `, load: function() { return Promise.all([ - callStatus(), - callModels(), - callHealth() - ]).then(function(results) { - var modelsData = Array.isArray(results[1]) ? results[1] : []; - return { - status: results[0] || {}, - models: modelsData, - health: results[2] || {} - }; - }); + api.status().catch(function() { return {}; }), + api.models().catch(function() { return { models: [] }; }) + ]); }, render: function(data) { var self = this; - this.data = data; + var status = data[0] || {}; + var models = (data[1] && data[1].models) || []; + + var view = E('div', { 'class': 'ol-wrap' }, [ + E('style', {}, this.css), - var container = E('div', { 'class': 'ollama-dashboard' }, [ // Header - E('div', { 'class': 'oll-header' }, [ - E('div', { 'class': 'oll-logo' }, [ - E('div', { 'class': 'oll-logo-icon' }, '🦙'), - E('div', { 'class': 'oll-logo-text' }, 'Ollama') + E('div', { 'class': 'ol-header' }, [ + E('div', { 'class': 'ol-title' }, [ + E('span', {}, '\uD83E\uDD99'), + 'Ollama' ]), - E('div', { 'class': 'oll-header-info' }, [ - E('div', { - 'class': 'oll-status-badge ' + (data.status.running ? '' : 'offline'), - 'id': 'oll-status-badge' - }, [ - E('span', { 'class': 'oll-status-dot' }), - data.status.running ? _('Running') : _('Stopped') - ]) - ]) + E('div', { 'class': 'ol-badge ' + (status.running ? 'on' : 'off'), 'id': 'ol-status' }, + status.running ? 'Running' : 'Stopped') ]), - // Quick Stats - E('div', { 'class': 'oll-quick-stats' }, [ - E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #f97316, #ea580c)' }, [ - E('div', { 'class': 'oll-quick-stat-header' }, [ - E('span', { 'class': 'oll-quick-stat-icon' }, '🧠'), - E('span', { 'class': 'oll-quick-stat-label' }, _('Models')) - ]), - E('div', { 'class': 'oll-quick-stat-value', 'id': 'models-count' }, - (data.models || []).length.toString() - ), - E('div', { 'class': 'oll-quick-stat-sub' }, _('Downloaded')) - ]), + // Stats + E('div', { 'class': 'ol-stats', 'id': 'ol-stats' }, this.renderStats(status, models)), - E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #10b981, #059669)' }, [ - E('div', { 'class': 'oll-quick-stat-header' }, [ - E('span', { 'class': 'oll-quick-stat-icon' }, '⏱️'), - E('span', { 'class': 'oll-quick-stat-label' }, _('Uptime')) - ]), - E('div', { 'class': 'oll-quick-stat-value', 'id': 'uptime' }, - data.status.running ? formatUptime(data.status.uptime) : '--' - ), - E('div', { 'class': 'oll-quick-stat-sub' }, _('Running')) - ]), - - E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #06b6d4, #0ea5e9)' }, [ - E('div', { 'class': 'oll-quick-stat-header' }, [ - E('span', { 'class': 'oll-quick-stat-icon' }, '🔌'), - E('span', { 'class': 'oll-quick-stat-label' }, _('API Port')) - ]), - E('div', { 'class': 'oll-quick-stat-value' }, data.status.api_port || '11434'), - E('div', { 'class': 'oll-quick-stat-sub' }, _('Endpoint')) - ]), - - E('div', { 'class': 'oll-quick-stat', 'style': '--stat-gradient: linear-gradient(135deg, #8b5cf6, #7c3aed)' }, [ - E('div', { 'class': 'oll-quick-stat-header' }, [ - E('span', { 'class': 'oll-quick-stat-icon' }, '🐋'), - E('span', { 'class': 'oll-quick-stat-label' }, _('Runtime')) - ]), - E('div', { 'class': 'oll-quick-stat-value' }, data.status.runtime || 'none'), - E('div', { 'class': 'oll-quick-stat-sub' }, _('Container')) - ]) - ]), - - // Main Cards Grid - E('div', { 'class': 'oll-cards-grid' }, [ - // Service Control Card - E('div', { 'class': 'oll-card' }, [ - E('div', { 'class': 'oll-card-header' }, [ - E('div', { 'class': 'oll-card-title' }, [ - E('span', { 'class': 'oll-card-title-icon' }, '⚙️'), - _('Service Control') - ]), - E('div', { - 'class': 'oll-card-badge ' + (data.status.running ? 'running' : 'stopped') - }, data.status.running ? _('Active') : _('Inactive')) - ]), - E('div', { 'class': 'oll-card-body' }, [ - E('div', { 'class': 'oll-service-info' }, [ - E('div', { 'class': 'oll-service-row' }, [ - E('span', { 'class': 'oll-service-label' }, _('Status')), - E('span', { - 'class': 'oll-service-value ' + (data.status.running ? 'running' : 'stopped'), - 'id': 'service-status' - }, data.status.running ? _('Running') : _('Stopped')) + // Cards Grid + E('div', { 'class': 'ol-grid' }, [ + // Service Control + E('div', { 'class': 'ol-card' }, [ + E('div', { 'class': 'ol-card-head' }, 'Service Control'), + E('div', { 'class': 'ol-card-body' }, [ + E('div', {}, [ + E('div', { 'class': 'ol-row' }, [ + E('span', { 'class': 'ol-row-lbl' }, 'Status'), + E('span', { 'id': 'svc-status' }, status.running ? 'Running' : 'Stopped') ]), - E('div', { 'class': 'oll-service-row' }, [ - E('span', { 'class': 'oll-service-label' }, _('Memory Limit')), - E('span', { 'class': 'oll-service-value' }, data.status.memory_limit || '2g') + E('div', { 'class': 'ol-row' }, [ + E('span', { 'class': 'ol-row-lbl' }, 'Runtime'), + E('span', {}, status.runtime || 'none') ]), - E('div', { 'class': 'oll-service-row' }, [ - E('span', { 'class': 'oll-service-label' }, _('Data Path')), - E('span', { 'class': 'oll-service-value' }, data.status.data_path || '/srv/ollama') + E('div', { 'class': 'ol-row' }, [ + E('span', { 'class': 'ol-row-lbl' }, 'Memory'), + E('span', {}, status.memory_limit || '2g') + ]), + E('div', { 'class': 'ol-row' }, [ + E('span', { 'class': 'ol-row-lbl' }, 'Data Path'), + E('span', {}, status.data_path || '/srv/ollama') ]) ]), - E('div', { 'class': 'oll-service-controls' }, [ - E('button', { - 'class': 'oll-btn oll-btn-success' + (data.status.running ? ' disabled' : ''), - 'click': function() { self.handleServiceAction('start'); }, - 'disabled': data.status.running - }, [E('span', {}, '▶'), _('Start')]), - E('button', { - 'class': 'oll-btn oll-btn-danger' + (!data.status.running ? ' disabled' : ''), - 'click': function() { self.handleServiceAction('stop'); }, - 'disabled': !data.status.running - }, [E('span', {}, '⏹'), _('Stop')]), - E('button', { - 'class': 'oll-btn oll-btn-warning', - 'click': function() { self.handleServiceAction('restart'); } - }, [E('span', {}, '🔄'), _('Restart')]) + E('div', { 'class': 'ol-btns', 'style': 'margin-top: 1rem;' }, [ + E('button', { 'class': 'ol-btn ol-btn-success', 'click': function() { self.svcAction('start'); } }, 'Start'), + E('button', { 'class': 'ol-btn ol-btn-danger', 'click': function() { self.svcAction('stop'); } }, 'Stop'), + E('button', { 'class': 'ol-btn ol-btn-primary', 'click': function() { self.svcAction('restart'); } }, 'Restart') ]) ]) ]), - // Models Card - E('div', { 'class': 'oll-card' }, [ - E('div', { 'class': 'oll-card-header' }, [ - E('div', { 'class': 'oll-card-title' }, [ - E('span', { 'class': 'oll-card-title-icon' }, '🦙'), - _('Downloaded Models') - ]), - E('div', { 'class': 'oll-card-badge' }, - (data.models || []).length + ' ' + _('models') - ) + // Models + E('div', { 'class': 'ol-card' }, [ + E('div', { 'class': 'ol-card-head' }, [ + 'Models', + E('span', { 'id': 'model-count' }, models.length + ' installed') ]), - E('div', { 'class': 'oll-card-body' }, [ - this.renderModelsList(data.models || []) - ]) - ]) - ]), - - // API Info Card - E('div', { 'class': 'oll-card', 'style': 'margin-top: 20px' }, [ - E('div', { 'class': 'oll-card-header' }, [ - E('div', { 'class': 'oll-card-title' }, [ - E('span', { 'class': 'oll-card-title-icon' }, '🔗'), - _('API Endpoints') + E('div', { 'class': 'ol-card-body' }, [ + E('div', { 'id': 'ol-models' }, this.renderModels(models)), + E('div', { 'style': 'margin-top: 1rem; display: flex; gap: 0.5rem;' }, [ + E('input', { 'type': 'text', 'class': 'ol-input', 'id': 'pull-model', 'placeholder': 'Model name (e.g. tinyllama, llama2)' }), + E('button', { 'class': 'ol-btn ol-btn-primary', 'click': function() { self.pullModel(); } }, 'Pull') + ]) ]) ]), - E('div', { 'class': 'oll-card-body' }, [ - E('div', { 'class': 'oll-api-info' }, [ - E('div', { 'class': 'oll-api-endpoint' }, [ - E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '11434') + '/api/chat'), - E('span', { 'class': 'oll-api-method' }, 'POST'), - E('span', { 'class': 'oll-api-desc' }, _('Chat completion')) - ]), - E('div', { 'class': 'oll-api-endpoint' }, [ - E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '11434') + '/api/generate'), - E('span', { 'class': 'oll-api-method' }, 'POST'), - E('span', { 'class': 'oll-api-desc' }, _('Text generation')) - ]), - E('div', { 'class': 'oll-api-endpoint' }, [ - E('code', {}, 'http://' + window.location.hostname + ':' + (data.status.api_port || '11434') + '/api/tags'), - E('span', { 'class': 'oll-api-method get' }, 'GET'), - E('span', { 'class': 'oll-api-desc' }, _('List models')) + + // Chat + E('div', { 'class': 'ol-card' }, [ + E('div', { 'class': 'ol-card-head' }, 'Chat'), + E('div', { 'class': 'ol-card-body' }, [ + E('select', { 'class': 'ol-select', 'id': 'chat-model', 'style': 'width: 100%;' }, + models.length === 0 + ? [E('option', {}, '-- No models --')] + : models.map(function(m) { return E('option', { 'value': m.name }, m.name); }) + ), + E('div', { 'class': 'ol-chat-box', 'id': 'chat-box' }), + E('div', { 'class': 'ol-chat-input' }, [ + E('input', { 'type': 'text', 'class': 'ol-input', 'id': 'chat-input', 'placeholder': 'Type a message...', + 'keypress': function(e) { if (e.key === 'Enter') self.sendChat(); } + }), + E('button', { 'class': 'ol-btn ol-btn-primary', 'id': 'chat-send', 'click': function() { self.sendChat(); } }, 'Send') ]) ]) ]) ]) ]); - var style = E('style', {}, this.getCSS()); - container.insertBefore(style, container.firstChild); - - return container; + poll.add(L.bind(this.refresh, this), 15); + return view; }, - renderModelsList: function(models) { - if (!models || models.length === 0) { - return E('div', { 'class': 'oll-empty' }, [ - E('div', { 'class': 'oll-empty-icon' }, '📦'), - E('div', { 'class': 'oll-empty-text' }, _('No models downloaded')), - E('div', { 'class': 'oll-empty-hint' }, [ - _('Download a model with: '), - E('code', {}, 'ollamactl pull tinyllama') - ]) - ]); - } - - return E('div', { 'class': 'oll-models-list' }, - models.map(function(model) { - return E('div', { 'class': 'oll-model-item' }, [ - E('div', { 'class': 'oll-model-icon' }, '🦙'), - E('div', { 'class': 'oll-model-info' }, [ - E('div', { 'class': 'oll-model-name' }, model.name), - E('div', { 'class': 'oll-model-meta' }, [ - model.size > 0 ? E('span', { 'class': 'oll-model-size' }, formatBytes(model.size)) : null - ].filter(Boolean)) - ]) - ]); - }) - ); + renderStats: function(status, models) { + return [ + E('div', { 'class': 'ol-stat' }, [ + E('div', { 'class': 'ol-stat-val' }, models.length.toString()), + E('div', { 'class': 'ol-stat-lbl' }, 'Models') + ]), + E('div', { 'class': 'ol-stat' }, [ + E('div', { 'class': 'ol-stat-val' }, status.running ? fmtUptime(status.uptime) : '-'), + E('div', { 'class': 'ol-stat-lbl' }, 'Uptime') + ]), + E('div', { 'class': 'ol-stat' }, [ + E('div', { 'class': 'ol-stat-val' }, (status.api_port || 11434).toString()), + E('div', { 'class': 'ol-stat-lbl' }, 'API Port') + ]), + E('div', { 'class': 'ol-stat' }, [ + E('div', { 'class': 'ol-stat-val' }, status.runtime || '-'), + E('div', { 'class': 'ol-stat-lbl' }, 'Runtime') + ]) + ]; }, - handleServiceAction: function(action) { + renderModels: function(models) { var self = this; - - ui.showModal(_('Service Control'), [ - E('p', {}, _('Processing...')), - E('div', { 'class': 'spinning' }) - ]); - - var actionFn; - switch(action) { - case 'start': actionFn = callStart(); break; - case 'stop': actionFn = callStop(); break; - case 'restart': actionFn = callRestart(); break; + if (!models || models.length === 0) { + return E('div', { 'class': 'ol-empty' }, 'No models installed'); } + return E('div', {}, models.map(function(m) { + return E('div', { 'class': 'ol-model' }, [ + E('div', {}, [ + E('div', { 'class': 'ol-model-name' }, m.name), + E('div', { 'class': 'ol-model-size' }, fmtBytes(m.size)) + ]), + E('button', { 'class': 'ol-btn ol-btn-danger ol-btn-sm', 'click': function() { self.removeModel(m.name); } }, 'Remove') + ]); + })); + }, - actionFn.then(function(result) { - ui.hideModal(); - if (result.success) { - ui.addNotification(null, E('p', _('Service ' + action + ' successful')), 'success'); - window.location.reload(); - } else { - ui.addNotification(null, E('p', result.error || _('Operation failed')), 'error'); + refresh: function() { + var self = this; + return Promise.all([ + api.status().catch(function() { return {}; }), + api.models().catch(function() { return { models: [] }; }) + ]).then(function(data) { + var status = data[0] || {}; + var models = (data[1] && data[1].models) || []; + + var badge = document.getElementById('ol-status'); + if (badge) { + badge.className = 'ol-badge ' + (status.running ? 'on' : 'off'); + badge.textContent = status.running ? 'Running' : 'Stopped'; + } + + var statsEl = document.getElementById('ol-stats'); + if (statsEl) dom.content(statsEl, self.renderStats(status, models)); + + var modelsEl = document.getElementById('ol-models'); + if (modelsEl) dom.content(modelsEl, self.renderModels(models)); + + var countEl = document.getElementById('model-count'); + if (countEl) countEl.textContent = models.length + ' installed'; + + var svcEl = document.getElementById('svc-status'); + if (svcEl) svcEl.textContent = status.running ? 'Running' : 'Stopped'; + + // Update chat model select + var sel = document.getElementById('chat-model'); + if (sel && models.length > 0) { + var current = sel.value; + sel.innerHTML = ''; + models.forEach(function(m) { + var opt = document.createElement('option'); + opt.value = m.name; + opt.textContent = m.name; + if (m.name === current) opt.selected = true; + sel.appendChild(opt); + }); } - }).catch(function(err) { - ui.hideModal(); - ui.addNotification(null, E('p', err.message), 'error'); }); }, - getCSS: function() { - return ` - .ollama-dashboard { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; - background: #030712; - color: #f8fafc; - min-height: 100vh; - padding: 16px; + svcAction: function(action) { + var self = this; + var fn = action === 'start' ? api.start : action === 'stop' ? api.stop : api.restart; + fn().then(function(r) { + self.toast(r && r.success ? action + ' OK' : 'Failed: ' + ((r && r.error) || 'Unknown'), r && r.success); + if (r && r.success) setTimeout(function() { self.refresh(); }, 2000); + }).catch(function(e) { self.toast('Error: ' + e.message, false); }); + }, + + pullModel: function() { + var self = this; + var input = document.getElementById('pull-model'); + var name = input ? input.value.trim() : ''; + if (!name) { self.toast('Enter model name', false); return; } + + self.toast('Pulling ' + name + '...', true); + api.pull(name).then(function(r) { + self.toast(r && r.success ? 'Pulled ' + name : 'Failed: ' + ((r && r.error) || 'Unknown'), r && r.success); + if (r && r.success) { + input.value = ''; + self.refresh(); } - .oll-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 0 20px; - border-bottom: 1px solid #334155; - margin-bottom: 20px; + }).catch(function(e) { self.toast('Error: ' + e.message, false); }); + }, + + removeModel: function(name) { + var self = this; + if (!confirm('Remove model "' + name + '"?')) return; + api.remove(name).then(function(r) { + self.toast(r && r.success ? 'Removed ' + name : 'Failed: ' + ((r && r.error) || 'Unknown'), r && r.success); + if (r && r.success) self.refresh(); + }).catch(function(e) { self.toast('Error: ' + e.message, false); }); + }, + + sendChat: function() { + var self = this; + var modelEl = document.getElementById('chat-model'); + var inputEl = document.getElementById('chat-input'); + var boxEl = document.getElementById('chat-box'); + var btnEl = document.getElementById('chat-send'); + + var model = modelEl ? modelEl.value : ''; + var msg = inputEl ? inputEl.value.trim() : ''; + + if (!model || model === '-- No models --') { self.toast('Select a model', false); return; } + if (!msg) return; + + // Add user message + var userDiv = document.createElement('div'); + userDiv.className = 'ol-chat-msg user'; + userDiv.textContent = '> ' + msg; + boxEl.appendChild(userDiv); + + inputEl.value = ''; + inputEl.disabled = true; + btnEl.disabled = true; + boxEl.scrollTop = boxEl.scrollHeight; + + api.chat(model, msg).then(function(r) { + var aiDiv = document.createElement('div'); + aiDiv.className = 'ol-chat-msg ai'; + if (r && r.response) { + aiDiv.textContent = r.response; + } else if (r && r.error) { + aiDiv.textContent = 'Error: ' + r.error; + aiDiv.style.color = '#ef4444'; + } else { + aiDiv.textContent = 'No response'; } - .oll-logo { - display: flex; - align-items: center; - gap: 14px; - } - .oll-logo-icon { - width: 46px; - height: 46px; - background: linear-gradient(135deg, #f97316, #ea580c); - border-radius: 12px; - display: flex; - align-items: center; - justify-content: center; - font-size: 24px; - } - .oll-logo-text { - font-size: 24px; - font-weight: 700; - background: linear-gradient(135deg, #f97316, #ea580c); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } - .oll-status-badge { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 16px; - border-radius: 24px; - background: rgba(16, 185, 129, 0.15); - color: #10b981; - border: 1px solid rgba(16, 185, 129, 0.3); - font-weight: 600; - } - .oll-status-badge.offline { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; - border-color: rgba(239, 68, 68, 0.3); - } - .oll-status-dot { - width: 10px; - height: 10px; - background: currentColor; - border-radius: 50%; - } - .oll-quick-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 14px; - margin-bottom: 24px; - } - .oll-quick-stat { - background: #0f172a; - border: 1px solid #334155; - border-radius: 12px; - padding: 20px; - position: relative; - overflow: hidden; - } - .oll-quick-stat::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - height: 3px; - background: var(--stat-gradient); - } - .oll-quick-stat-header { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 12px; - } - .oll-quick-stat-icon { font-size: 22px; } - .oll-quick-stat-label { - font-size: 11px; - text-transform: uppercase; - color: #64748b; - } - .oll-quick-stat-value { - font-size: 32px; - font-weight: 700; - background: var(--stat-gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - } - .oll-quick-stat-sub { - font-size: 11px; - color: #64748b; - margin-top: 6px; - } - .oll-cards-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 20px; - } - .oll-card { - background: #0f172a; - border: 1px solid #334155; - border-radius: 12px; - overflow: hidden; - } - .oll-card-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 16px 20px; - border-bottom: 1px solid #334155; - background: rgba(0, 0, 0, 0.3); - } - .oll-card-title { - display: flex; - align-items: center; - gap: 12px; - font-size: 15px; - font-weight: 600; - } - .oll-card-title-icon { font-size: 20px; } - .oll-card-badge { - font-size: 12px; - padding: 5px 12px; - border-radius: 16px; - background: linear-gradient(135deg, #f97316, #ea580c); - color: white; - } - .oll-card-badge.running { background: linear-gradient(135deg, #10b981, #059669); } - .oll-card-badge.stopped { background: rgba(100, 116, 139, 0.3); color: #94a3b8; } - .oll-card-body { padding: 20px; } - .oll-service-info { - display: flex; - flex-direction: column; - gap: 12px; - margin-bottom: 20px; - } - .oll-service-row { - display: flex; - justify-content: space-between; - padding: 8px 12px; - background: #030712; - border-radius: 8px; - } - .oll-service-label { color: #94a3b8; font-size: 13px; } - .oll-service-value { font-size: 13px; } - .oll-service-value.running { color: #10b981; } - .oll-service-value.stopped { color: #ef4444; } - .oll-service-controls { - display: flex; - gap: 10px; - } - .oll-btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 10px 16px; - border: none; - border-radius: 8px; - font-size: 13px; - font-weight: 500; - cursor: pointer; - } - .oll-btn-success { - background: linear-gradient(135deg, #10b981, #059669); - color: white; - } - .oll-btn-danger { - background: linear-gradient(135deg, #ef4444, #dc2626); - color: white; - } - .oll-btn-warning { - background: linear-gradient(135deg, #f59e0b, #d97706); - color: white; - } - .oll-btn.disabled { - opacity: 0.5; - cursor: not-allowed; - } - .oll-models-list { - display: flex; - flex-direction: column; - gap: 12px; - } - .oll-model-item { - display: flex; - align-items: center; - gap: 14px; - padding: 14px; - background: #1e293b; - border-radius: 10px; - } - .oll-model-icon { - width: 44px; - height: 44px; - background: linear-gradient(135deg, #f97316, #ea580c); - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - } - .oll-model-name { - font-weight: 600; - margin-bottom: 4px; - } - .oll-model-meta { - display: flex; - gap: 12px; - font-size: 12px; - color: #94a3b8; - } - .oll-empty { - text-align: center; - padding: 40px 20px; - color: #64748b; - } - .oll-empty-icon { font-size: 48px; margin-bottom: 12px; } - .oll-empty-text { font-size: 16px; margin-bottom: 8px; } - .oll-empty-hint { font-size: 13px; } - .oll-empty-hint code { - background: #1e293b; - padding: 4px 8px; - border-radius: 4px; - } - .oll-api-info { - display: flex; - flex-direction: column; - gap: 10px; - } - .oll-api-endpoint { - display: flex; - align-items: center; - gap: 12px; - padding: 12px; - background: #030712; - border-radius: 8px; - } - .oll-api-endpoint code { - font-size: 12px; - color: #f97316; - flex: 1; - } - .oll-api-method { - padding: 4px 8px; - background: #f97316; - color: #030712; - border-radius: 4px; - font-size: 10px; - font-weight: 700; - } - .oll-api-method.get { background: #10b981; } - .oll-api-desc { - font-size: 12px; - color: #94a3b8; - min-width: 120px; - } - `; - } + boxEl.appendChild(aiDiv); + boxEl.scrollTop = boxEl.scrollHeight; + }).catch(function(e) { + var errDiv = document.createElement('div'); + errDiv.className = 'ol-chat-msg ai'; + errDiv.textContent = 'Error: ' + e.message; + errDiv.style.color = '#ef4444'; + boxEl.appendChild(errDiv); + }).finally(function() { + inputEl.disabled = false; + btnEl.disabled = false; + inputEl.focus(); + }); + }, + + toast: function(msg, success) { + var t = document.querySelector('.ol-toast'); + if (t) t.remove(); + t = document.createElement('div'); + t.className = 'ol-toast ' + (success ? 'success' : 'error'); + t.textContent = msg; + document.body.appendChild(t); + setTimeout(function() { t.remove(); }, 4000); + }, + + handleSaveApply: null, + handleSave: null, + handleReset: null }); diff --git a/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css index b3d116a7..57134a79 100644 --- a/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css +++ b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/secubox-netdiag/netdiag.css @@ -83,6 +83,99 @@ color: var(--sb-text); } +/* Header right section */ +.netdiag-header-right { + display: flex; + align-items: center; + gap: 16px; +} + +/* Temperature display */ +.netdiag-temp-display { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: rgba(255,255,255,0.1); + border-radius: 8px; + font-weight: 600; +} + +.netdiag-temp-display .temp-icon { + font-size: 1.1rem; +} + +.netdiag-temp-display .temp-value { + font-size: 1.1rem; + font-family: 'JetBrains Mono', monospace; +} + +.netdiag-temp-display .temp-unit { + font-size: 0.85rem; + opacity: 0.8; +} + +.netdiag-temp-display.temp-normal { + background: rgba(87, 204, 153, 0.2); + color: var(--sb-success); +} + +.netdiag-temp-display.temp-warm { + background: rgba(255, 202, 58, 0.2); + color: var(--sb-warning); +} + +.netdiag-temp-display.temp-hot { + background: rgba(255, 89, 94, 0.2); + color: var(--sb-critical); + animation: pulse-temp 1.5s infinite; +} + +@keyframes pulse-temp { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + +/* Port mode controls */ +.netdiag-port-mode { + background: var(--sb-bg-dark); + border-radius: 8px; + padding: 16px; +} + +.netdiag-mode-controls { + display: flex; + gap: 16px; + align-items: flex-end; + flex-wrap: wrap; +} + +.netdiag-mode-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.netdiag-mode-group label { + font-size: 0.8rem; + color: var(--sb-text-muted); +} + +.netdiag-select { + padding: 8px 12px; + background: var(--sb-bg-card); + border: 1px solid var(--sb-border); + border-radius: 6px; + color: var(--sb-text); + font-size: 0.9rem; + min-width: 180px; +} + +.netdiag-select:focus { + outline: none; + border-color: var(--sb-primary); +} + /* Section containers */ .netdiag-section { background: var(--sb-bg-card); diff --git a/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js index 2aa9927f..31c00f04 100644 --- a/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js +++ b/package/secubox/luci-app-secubox-netdiag/htdocs/luci-static/resources/view/secubox-netdiag/overview.js @@ -38,6 +38,32 @@ var callClearCounters = rpc.declare({ expect: {} }); +var callCollectErrors = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'collect_errors', + expect: {} +}); + +var callGetPortModes = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'get_port_modes', + params: ['interface'], + expect: {} +}); + +var callSetPortMode = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'set_port_mode', + params: ['interface', 'speed', 'duplex', 'eee', 'autoneg'], + expect: {} +}); + +var callGetTemperature = rpc.declare({ + object: 'luci.secubox-netdiag', + method: 'get_temperature', + expect: {} +}); + return view.extend({ refreshInterval: 5000, pollHandle: null, @@ -79,27 +105,141 @@ return view.extend({ E('span', { 'class': 'netdiag-title-icon' }, '\uD83D\uDD0C'), _('Network Diagnostics') ]), - E('div', { 'class': 'netdiag-refresh-control' }, [ + E('div', { 'class': 'netdiag-header-right' }, [ + E('div', { 'class': 'netdiag-temp-display', 'id': 'temp-display' }, [ + E('span', { 'class': 'temp-icon' }, '\uD83C\uDF21\uFE0F'), + E('span', { 'class': 'temp-value', 'id': 'temp-value' }, '--'), + E('span', { 'class': 'temp-unit' }, '\u00B0C') + ]), E('button', { - 'class': 'netdiag-refresh-btn', - 'click': function() { self.refreshData(); } - }, '\u21BB ' + _('Refresh')), - E('select', { - 'class': 'netdiag-refresh-select', - 'change': function(ev) { - self.refreshInterval = parseInt(ev.target.value, 10); - self.restartPolling(); - } - }, [ - E('option', { 'value': '5000', 'selected': true }, _('5 seconds')), - E('option', { 'value': '10000' }, _('10 seconds')), - E('option', { 'value': '30000' }, _('30 seconds')), - E('option', { 'value': '0' }, _('Manual')) + 'class': 'netdiag-btn netdiag-btn-secondary', + 'click': function() { self.collectAndExportErrors(); }, + 'title': _('Collect all errors and export') + }, '\uD83D\uDCCB ' + _('Collect Errors')), + E('div', { 'class': 'netdiag-refresh-control' }, [ + E('button', { + 'class': 'netdiag-refresh-btn', + 'click': function() { self.refreshData(); } + }, '\u21BB ' + _('Refresh')), + E('select', { + 'class': 'netdiag-refresh-select', + 'change': function(ev) { + self.refreshInterval = parseInt(ev.target.value, 10); + self.restartPolling(); + } + }, [ + E('option', { 'value': '5000', 'selected': true }, _('5 seconds')), + E('option', { 'value': '10000' }, _('10 seconds')), + E('option', { 'value': '30000' }, _('30 seconds')), + E('option', { 'value': '0' }, _('Manual')) + ]) ]) ]) ]); }, + updateTemperature: function() { + callGetTemperature().then(function(data) { + var tempEl = document.getElementById('temp-value'); + if (!tempEl) return; + + var temp = '--'; + var zones = data.zones || []; + var hwmon = data.hwmon || []; + + // Prefer CPU/SoC temp + for (var i = 0; i < zones.length; i++) { + if (zones[i].temp_c > 0) { + temp = zones[i].temp_c; + break; + } + } + + // Fallback to hwmon + if (temp === '--' && hwmon.length > 0) { + for (var j = 0; j < hwmon.length; j++) { + if (hwmon[j].temp_c > 0) { + temp = hwmon[j].temp_c; + break; + } + } + } + + tempEl.textContent = temp; + + // Color based on temp + var display = document.getElementById('temp-display'); + if (display && temp !== '--') { + display.classList.remove('temp-normal', 'temp-warm', 'temp-hot'); + if (temp >= 70) display.classList.add('temp-hot'); + else if (temp >= 55) display.classList.add('temp-warm'); + else display.classList.add('temp-normal'); + } + }).catch(function() {}); + }, + + collectAndExportErrors: function() { + var self = this; + + ui.showModal(_('Collecting Errors'), [ + E('p', {}, _('Gathering error data from all interfaces...')), + E('div', { 'class': 'spinning' }) + ]); + + callCollectErrors().then(function(data) { + ui.hideModal(); + + var content = 'SecuBox Network Diagnostics - Error Collection\n'; + content += '==============================================\n'; + content += 'Timestamp: ' + (data.timestamp || new Date().toISOString()) + '\n'; + content += 'Hostname: ' + (data.hostname || 'unknown') + '\n'; + if (data.temperature) content += 'Temperature: ' + data.temperature + '\u00B0C\n'; + content += '\n'; + + var interfaces = data.interfaces || []; + if (interfaces.length === 0) { + content += 'No errors detected on any interface.\n'; + } else { + content += 'INTERFACE ERRORS\n'; + content += '----------------\n'; + interfaces.forEach(function(iface) { + content += '\n[' + iface.interface + '] Total: ' + iface.total_errors + '\n'; + content += ' rx_crc_errors: ' + iface.rx_crc_errors + '\n'; + content += ' rx_frame_errors: ' + iface.rx_frame_errors + '\n'; + content += ' rx_fifo_errors: ' + iface.rx_fifo_errors + '\n'; + content += ' rx_dropped: ' + iface.rx_dropped + '\n'; + content += ' tx_dropped: ' + iface.tx_dropped + '\n'; + content += ' collisions: ' + iface.collisions + '\n'; + }); + } + + var dmesg = data.dmesg_errors || []; + if (dmesg.length > 0) { + content += '\nKERNEL MESSAGES (errors/warnings)\n'; + content += '---------------------------------\n'; + dmesg.forEach(function(line) { + content += line + '\n'; + }); + } + + // Download file + var blob = new Blob([content], { type: 'text/plain' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'netdiag-errors-' + Date.now() + '.txt'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + ui.addNotification(null, E('p', _('Error report exported')), 'info'); + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Failed to collect errors: ') + err), 'error'); + }); + }, + renderSwitchSection: function(ports, topology) { var self = this; var switches = topology.switches || []; @@ -447,6 +587,43 @@ return view.extend({ ) ]) : E('div'), + // Port Mode Settings (for temperature control) + E('div', { 'class': 'netdiag-detail-section' }, [ + E('div', { 'class': 'netdiag-detail-title' }, [ + '\uD83C\uDF21\uFE0F ', + _('Port Mode (Temperature Control)') + ]), + E('div', { 'class': 'netdiag-port-mode', 'id': 'port-mode-' + iface }, [ + E('p', { 'style': 'color: #94a3b8; font-size: 0.85rem; margin-bottom: 1rem;' }, + _('Reducing port speed or enabling EEE can lower heat generation.')), + E('div', { 'class': 'netdiag-mode-controls' }, [ + E('div', { 'class': 'netdiag-mode-group' }, [ + E('label', {}, _('Speed/Duplex')), + E('select', { 'id': 'speed-select-' + iface, 'class': 'netdiag-select' }, [ + E('option', { 'value': 'auto' }, _('Auto-negotiate')), + E('option', { 'value': '1000-full' }, '1000 Mbps Full'), + E('option', { 'value': '100-full' }, '100 Mbps Full'), + E('option', { 'value': '100-half' }, '100 Mbps Half'), + E('option', { 'value': '10-full' }, '10 Mbps Full'), + E('option', { 'value': '10-half' }, '10 Mbps Half') + ]) + ]), + E('div', { 'class': 'netdiag-mode-group' }, [ + E('label', {}, _('Energy Efficient Ethernet (EEE)')), + E('select', { 'id': 'eee-select-' + iface, 'class': 'netdiag-select' }, [ + E('option', { 'value': '' }, _('No change')), + E('option', { 'value': 'on' }, _('Enable (saves power/heat)')), + E('option', { 'value': 'off' }, _('Disable')) + ]) + ]), + E('button', { + 'class': 'netdiag-btn netdiag-btn-primary', + 'click': function() { self.applyPortMode(iface); } + }, _('Apply')) + ]) + ]) + ]), + // Actions E('div', { 'class': 'netdiag-actions' }, [ E('button', { @@ -465,6 +642,45 @@ return view.extend({ ]); }, + applyPortMode: function(iface) { + var self = this; + var speedSelect = document.getElementById('speed-select-' + iface); + var eeeSelect = document.getElementById('eee-select-' + iface); + + if (!speedSelect) return; + + var speedVal = speedSelect.value; + var eeeVal = eeeSelect ? eeeSelect.value : ''; + + var speed = '', duplex = '', autoneg = ''; + if (speedVal === 'auto') { + autoneg = 'on'; + } else { + var parts = speedVal.split('-'); + speed = parts[0]; + duplex = parts[1]; + autoneg = 'off'; + } + + ui.showModal(_('Applying Port Mode'), [ + E('p', {}, _('Changing port settings for %s...').format(iface)), + E('div', { 'class': 'spinning' }) + ]); + + callSetPortMode(iface, speed, duplex, eeeVal, autoneg).then(function(result) { + ui.hideModal(); + if (result.success) { + ui.addNotification(null, E('p', result.message || _('Port mode updated')), 'info'); + self.refreshData(); + } else { + ui.addNotification(null, E('p', _('Failed: ') + (result.error || _('Unknown error'))), 'error'); + } + }).catch(function(err) { + ui.hideModal(); + ui.addNotification(null, E('p', _('Error: ') + err), 'error'); + }); + }, + renderDetailItem: function(label, value) { return E('div', { 'class': 'netdiag-detail-item' }, [ E('span', { 'class': 'netdiag-detail-label' }, label), @@ -560,9 +776,13 @@ return view.extend({ startPolling: function() { var self = this; + // Initial temperature update + this.updateTemperature(); + if (this.refreshInterval > 0) { this.pollHandle = poll.add(function() { self.refreshData(); + self.updateTemperature(); }, this.refreshInterval / 1000); } }, diff --git a/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag b/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag index ec622ab9..87934a96 100755 --- a/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag +++ b/package/secubox/luci-app-secubox-netdiag/root/usr/libexec/rpcd/luci.secubox-netdiag @@ -478,10 +478,216 @@ get_topology() { json_dump } +# Method: collect_errors +# Collect comprehensive error logs from all interfaces +collect_errors() { + json_init + json_add_string "timestamp" "$(date -Iseconds)" + json_add_string "hostname" "$(cat /proc/sys/kernel/hostname)" + + # System temperature if available + local temp="" + for t in /sys/class/thermal/thermal_zone*/temp; do + [ -f "$t" ] && temp=$(cat "$t" 2>/dev/null) + break + done + [ -n "$temp" ] && json_add_int "temperature" "$((temp / 1000))" + + # Collect errors per interface + json_add_array "interfaces" + for iface_path in /sys/class/net/*; do + [ ! -d "$iface_path" ] && continue + local iface=$(basename "$iface_path") + case "$iface" in lo|br-*|docker*|veth*|tun*|tap*) continue ;; esac + + local rx_crc=$(read_stat "$iface" rx_crc_errors) + local rx_frame=$(read_stat "$iface" rx_frame_errors) + local rx_fifo=$(read_stat "$iface" rx_fifo_errors) + local rx_dropped=$(read_stat "$iface" rx_dropped) + local tx_dropped=$(read_stat "$iface" tx_dropped) + local collisions=$(read_stat "$iface" collisions) + local total_err=$((rx_crc + rx_frame + rx_fifo + rx_dropped + tx_dropped + collisions)) + + [ "$total_err" -eq 0 ] && continue + + json_add_object "" + json_add_string "interface" "$iface" + json_add_int "rx_crc_errors" "$rx_crc" + json_add_int "rx_frame_errors" "$rx_frame" + json_add_int "rx_fifo_errors" "$rx_fifo" + json_add_int "rx_dropped" "$rx_dropped" + json_add_int "tx_dropped" "$tx_dropped" + json_add_int "collisions" "$collisions" + json_add_int "total_errors" "$total_err" + json_close_object + done + json_close_array + + # Recent dmesg errors + json_add_array "dmesg_errors" + dmesg 2>/dev/null | grep -iE "error|fail|timeout|reset|link" | tail -30 | while read -r line; do + json_add_string "" "$line" + done + json_close_array + + json_dump +} + +# Method: get_port_modes +# Get current and available speed/duplex modes for an interface +get_port_modes() { + local iface="$1" + + if [ ! -d "/sys/class/net/${iface}" ]; then + json_init + json_add_boolean "error" 1 + json_add_string "message" "Interface not found: $iface" + json_dump + return + fi + + json_init + json_add_string "interface" "$iface" + + # Current settings + local ethtool_out=$(ethtool "$iface" 2>/dev/null) + if [ -n "$ethtool_out" ]; then + local cur_speed=$(echo "$ethtool_out" | grep -i 'Speed:' | head -1 | awk '{print $2}') + local cur_duplex=$(echo "$ethtool_out" | grep -i 'Duplex:' | head -1 | awk '{print $2}') + local cur_autoneg=$(echo "$ethtool_out" | grep -E '^\s+Auto-negotiation:' | head -1 | awk '{print $2}') + local cur_link=$(echo "$ethtool_out" | grep -i 'Link detected:' | head -1 | awk '{print $3}') + json_add_string "current_speed" "${cur_speed:-unknown}" + json_add_string "current_duplex" "${cur_duplex:-unknown}" + json_add_string "autoneg" "${cur_autoneg:-unknown}" + json_add_string "link" "${cur_link:-unknown}" + + # Parse supported modes + json_add_array "supported_speeds" + echo "$ethtool_out" | grep -A50 'Supported link modes:' | grep -oE '[0-9]+base[A-Za-z/]+' | sort -u | while read -r mode; do + json_add_string "" "$mode" + done + json_close_array + fi + + # EEE status (Energy Efficient Ethernet) - reduces heat + local eee_out=$(ethtool --show-eee "$iface" 2>/dev/null) + if [ -n "$eee_out" ]; then + local eee_enabled=$(echo "$eee_out" | grep -i "EEE status:" | grep -q "enabled" && echo "true" || echo "false") + local eee_active=$(echo "$eee_out" | grep -i "Link partner" | grep -q "Yes" && echo "true" || echo "false") + json_add_boolean "eee_enabled" "$eee_enabled" + json_add_boolean "eee_active" "$eee_active" + json_add_boolean "eee_supported" "true" + else + json_add_boolean "eee_supported" "false" + fi + + # Wake-on-LAN status + local wol_out=$(ethtool "$iface" 2>/dev/null | grep -E '^\s+Wake-on:' | head -1 | awk '{print $2}') + json_add_string "wake_on_lan" "${wol_out:-d}" + + json_dump +} + +# Method: set_port_mode +# Set interface speed/duplex/EEE for temperature control +set_port_mode() { + local iface="$1" + local speed="$2" + local duplex="$3" + local eee="$4" + local autoneg="$5" + + if [ ! -d "/sys/class/net/${iface}" ]; then + json_init + json_add_boolean "success" 0 + json_add_string "error" "Interface not found: $iface" + json_dump + return + fi + + local result="" + local error="" + + # Set speed/duplex + if [ -n "$speed" ] && [ -n "$duplex" ]; then + if [ "$autoneg" = "on" ]; then + result=$(ethtool -s "$iface" autoneg on 2>&1) + else + result=$(ethtool -s "$iface" speed "$speed" duplex "$duplex" autoneg off 2>&1) + fi + [ $? -ne 0 ] && error="$result" + fi + + # Set EEE (Energy Efficient Ethernet) - key for temperature + if [ -n "$eee" ]; then + if [ "$eee" = "on" ]; then + result=$(ethtool --set-eee "$iface" eee on 2>&1) + else + result=$(ethtool --set-eee "$iface" eee off 2>&1) + fi + [ $? -ne 0 ] && [ -z "$error" ] && error="EEE: $result" + fi + + json_init + if [ -z "$error" ]; then + json_add_boolean "success" 1 + json_add_string "message" "Port mode updated for $iface" + + # Log the change + logger -t secubox-netdiag "Port mode changed: $iface speed=$speed duplex=$duplex eee=$eee" + else + json_add_boolean "success" 0 + json_add_string "error" "$error" + fi + json_dump +} + +# Method: get_temperature +# Get system and interface temperatures +get_temperature() { + json_init + + # CPU/SoC temperature + json_add_array "zones" + for zone in /sys/class/thermal/thermal_zone*; do + [ ! -d "$zone" ] && continue + local name=$(cat "$zone/type" 2>/dev/null || basename "$zone") + local temp=$(cat "$zone/temp" 2>/dev/null || echo 0) + json_add_object "" + json_add_string "name" "$name" + json_add_int "temp_mc" "$temp" + json_add_int "temp_c" "$((temp / 1000))" + json_close_object + done + json_close_array + + # hwmon temperatures + json_add_array "hwmon" + for hwmon in /sys/class/hwmon/hwmon*; do + [ ! -d "$hwmon" ] && continue + local name=$(cat "$hwmon/name" 2>/dev/null || basename "$hwmon") + for temp_file in "$hwmon"/temp*_input; do + [ ! -f "$temp_file" ] && continue + local temp=$(cat "$temp_file" 2>/dev/null || echo 0) + local label_file="${temp_file%_input}_label" + local label=$(cat "$label_file" 2>/dev/null || basename "$temp_file" .input) + json_add_object "" + json_add_string "sensor" "$name" + json_add_string "label" "$label" + json_add_int "temp_mc" "$temp" + json_add_int "temp_c" "$((temp / 1000))" + json_close_object + done + done + json_close_array + + json_dump +} + # RPCD list handler case "$1" in list) - echo '{"get_switch_status":{},"get_interface_details":{"interface":"string"},"get_error_history":{"interface":"string","minutes":5},"clear_counters":{"interface":"string"},"get_topology":{}}' + echo '{"get_switch_status":{},"get_interface_details":{"interface":"string"},"get_error_history":{"interface":"string","minutes":5},"clear_counters":{"interface":"string"},"get_topology":{},"collect_errors":{},"get_port_modes":{"interface":"string"},"set_port_mode":{"interface":"string","speed":"string","duplex":"string","eee":"string","autoneg":"string"},"get_temperature":{}}' ;; call) case "$2" in @@ -507,6 +713,26 @@ case "$1" in get_topology) get_topology ;; + collect_errors) + collect_errors + ;; + get_port_modes) + read -r input + iface=$(echo "$input" | jsonfilter -e '@.interface' 2>/dev/null) + get_port_modes "$iface" + ;; + set_port_mode) + read -r input + iface=$(echo "$input" | jsonfilter -e '@.interface' 2>/dev/null) + speed=$(echo "$input" | jsonfilter -e '@.speed' 2>/dev/null) + duplex=$(echo "$input" | jsonfilter -e '@.duplex' 2>/dev/null) + eee=$(echo "$input" | jsonfilter -e '@.eee' 2>/dev/null) + autoneg=$(echo "$input" | jsonfilter -e '@.autoneg' 2>/dev/null) + set_port_mode "$iface" "$speed" "$duplex" "$eee" "$autoneg" + ;; + get_temperature) + get_temperature + ;; esac ;; esac diff --git a/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json b/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json index 06482a89..f327b3af 100644 --- a/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json +++ b/package/secubox/luci-app-secubox-netdiag/root/usr/share/rpcd/acl.d/luci-app-secubox-netdiag.json @@ -3,19 +3,23 @@ "description": "Grant access to SecuBox Network Diagnostics", "read": { "ubus": { - "luci.secubox-netdiag": ["get_switch_status", "get_interface_details", "get_error_history", "get_topology"] + "luci.secubox-netdiag": ["get_switch_status", "get_interface_details", "get_error_history", "get_topology", "collect_errors", "get_port_modes", "get_temperature"] }, "file": { "/sys/class/net/*/statistics/*": ["read"], "/sys/class/net/*/carrier": ["read"], "/sys/class/net/*/operstate": ["read"], "/sys/class/net/*/address": ["read"], - "/sys/class/net/*/mtu": ["read"] + "/sys/class/net/*/mtu": ["read"], + "/sys/class/thermal/thermal_zone*/temp": ["read"], + "/sys/class/thermal/thermal_zone*/type": ["read"], + "/sys/class/hwmon/hwmon*/temp*": ["read"], + "/sys/class/hwmon/hwmon*/name": ["read"] } }, "write": { "ubus": { - "luci.secubox-netdiag": ["clear_counters"] + "luci.secubox-netdiag": ["clear_counters", "set_port_mode"] } } }