secubox-openwrt/package/secubox/luci-app-ollama/htdocs/luci-static/resources/view/ollama/dashboard.js
CyberMind-FR 48deeccb99 feat(luci-app-ollama): Add LuCI dashboard for Ollama LLM
New LuCI application for Ollama management:
- Dashboard with service status and controls
- Model management (pull, remove, list)
- Chat interface with model selection
- Settings page for configuration

Files:
- RPCD backend (luci.ollama)
- Dashboard, Models, Chat, Settings views
- ACL and menu definitions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:02:12 +01:00

578 lines
16 KiB
JavaScript

'use strict';
'require view';
'require ui';
'require rpc';
var callStatus = rpc.declare({
object: 'luci.ollama',
method: 'status',
expect: { }
});
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 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';
}
return view.extend({
title: _('Ollama Dashboard'),
refreshInterval: 5000,
data: null,
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] || {}
};
});
},
render: function(data) {
var self = this;
this.data = data;
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': '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')
])
])
]),
// 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'))
]),
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'))
]),
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': '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': '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')])
])
])
]),
// 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')
)
]),
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': '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'))
])
])
])
])
]);
var style = E('style', {}, this.getCSS());
container.insertBefore(style, container.firstChild);
return container;
},
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))
])
]);
})
);
},
handleServiceAction: function(action) {
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;
}
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');
}
}).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;
}
.oll-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 20px;
border-bottom: 1px solid #334155;
margin-bottom: 20px;
}
.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;
}
`;
}
});