feat(ollama,netdiag): KISS UI rewrite and thermal monitoring
Ollama: - Complete KISS UI rewrite with simplified dashboard - RPC declarations without expect clauses for reliability - Service controls, model management, and chat interface Network Diagnostics: - Add temperature display with color-coded thresholds - Add error collection and export functionality - Add port mode switching (speed/duplex/EEE) - Add collect_errors, get_port_modes, get_temperature RPC methods - Add set_port_mode RPC method for port configuration - Fix ACL permissions for new methods Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
948b8776d8
commit
9435cc6282
@ -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
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user