Merge pull request #10 from gkerma/release/v0.15.0

Release/v0.15.0
This commit is contained in:
CyberMind 2026-01-25 06:31:46 +01:00 committed by GitHub
commit 63c1321446
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1032 additions and 305 deletions

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-gitea PKG_NAME:=luci-app-gitea
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=1 PKG_RELEASE:=2
PKG_ARCH:=all PKG_ARCH:=all
PKG_LICENSE:=Apache-2.0 PKG_LICENSE:=Apache-2.0

View File

@ -362,7 +362,12 @@ list_repos() {
local repo_root="$data_path/git/repositories" local repo_root="$data_path/git/repositories"
if [ -d "$repo_root" ]; then if [ -d "$repo_root" ]; then
find "$repo_root" -name "*.git" -type d 2>/dev/null | while read -r repo; do # Use temp file to avoid subshell issue with piped while loop
local tmpfile="/tmp/gitea-repos.$$"
find "$repo_root" -name "*.git" -type d 2>/dev/null > "$tmpfile"
while read -r repo; do
[ -z "$repo" ] && continue
local rel_path="${repo#$repo_root/}" local rel_path="${repo#$repo_root/}"
local name=$(basename "$repo" .git) local name=$(basename "$repo" .git)
local owner=$(dirname "$rel_path") local owner=$(dirname "$rel_path")
@ -383,7 +388,9 @@ list_repos() {
json_add_string "size" "$size" json_add_string "size" "$size"
[ -n "$mtime" ] && json_add_int "mtime" "$mtime" [ -n "$mtime" ] && json_add_int "mtime" "$mtime"
json_close_object json_close_object
done done < "$tmpfile"
rm -f "$tmpfile"
fi fi
json_close_array json_close_array
@ -528,7 +535,7 @@ list_backups() {
local backup_dir="$data_path/backups" local backup_dir="$data_path/backups"
if [ -d "$backup_dir" ]; then if [ -d "$backup_dir" ]; then
ls -1 "$backup_dir"/*.tar.gz 2>/dev/null | while read -r backup; do for backup in "$backup_dir"/*.tar.gz; do
[ -f "$backup" ] || continue [ -f "$backup" ] || continue
local name=$(basename "$backup") local name=$(basename "$backup")
local size=$(ls -lh "$backup" 2>/dev/null | awk '{print $5}') local size=$(ls -lh "$backup" 2>/dev/null | awk '{print $5}')

View File

@ -11,7 +11,7 @@ LUCI_PKGARCH:=all
PKG_NAME:=luci-app-haproxy PKG_NAME:=luci-app-haproxy
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=4 PKG_RELEASE:=6
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr> PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT PKG_LICENSE:=MIT

View File

@ -115,14 +115,14 @@ var callCreateServer = rpc.declare({
var callUpdateServer = rpc.declare({ var callUpdateServer = rpc.declare({
object: 'luci.haproxy', object: 'luci.haproxy',
method: 'update_server', method: 'update_server',
params: ['id', 'backend', 'name', 'address', 'port', 'weight', 'check', 'enabled'], params: ['id', 'backend', 'name', 'address', 'port', 'weight', 'check', 'enabled', 'inline'],
expect: {} expect: {}
}); });
var callDeleteServer = rpc.declare({ var callDeleteServer = rpc.declare({
object: 'luci.haproxy', object: 'luci.haproxy',
method: 'delete_server', method: 'delete_server',
params: ['id'], params: ['id', 'inline'],
expect: {} expect: {}
}); });
@ -293,11 +293,16 @@ function getDashboardData() {
callListBackends(), callListBackends(),
callListCertificates() callListCertificates()
]).then(function(results) { ]).then(function(results) {
// Handle both array and object responses from RPC
var vhosts = Array.isArray(results[1]) ? results[1] : (results[1] && results[1].vhosts) || [];
var backends = Array.isArray(results[2]) ? results[2] : (results[2] && results[2].backends) || [];
var certificates = Array.isArray(results[3]) ? results[3] : (results[3] && results[3].certificates) || [];
return { return {
status: results[0], status: results[0],
vhosts: results[1].vhosts || [], vhosts: vhosts,
backends: results[2].backends || [], backends: backends,
certificates: results[3].certificates || [] certificates: certificates
}; };
}); });
} }

View File

@ -588,6 +588,12 @@ code,
gap: 8px; gap: 8px;
} }
.hp-server-actions {
display: flex;
align-items: center;
gap: 6px;
}
.hp-server-weight { .hp-server-weight {
font-size: 12px; font-size: 12px;
padding: 4px 8px; padding: 4px 8px;
@ -596,6 +602,21 @@ code,
color: var(--hp-text-secondary); color: var(--hp-text-secondary);
} }
.hp-backend-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: var(--hp-bg-secondary);
border-top: 1px solid var(--hp-border);
}
.hp-badge-secondary {
background: var(--hp-bg-tertiary);
color: var(--hp-text-secondary);
border: 1px solid var(--hp-border);
}
/* === Certificate List === */ /* === Certificate List === */
.hp-cert-list { .hp-cert-list {
display: flex; display: flex;

View File

@ -4,9 +4,23 @@
'require ui'; 'require ui';
'require haproxy.api as api'; 'require haproxy.api as api';
/**
* HAProxy Backends Management
* Copyright (C) 2025 CyberMind.fr
*/
return view.extend({ return view.extend({
title: _('Backends'),
load: function() { load: function() {
return api.listBackends().then(function(backends) { // Load CSS
var cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = L.resource('haproxy/dashboard.css');
document.head.appendChild(cssLink);
return api.listBackends().then(function(result) {
var backends = (result && result.backends) || result || [];
return Promise.all([ return Promise.all([
Promise.resolve(backends), Promise.resolve(backends),
api.listServers('') api.listServers('')
@ -17,7 +31,8 @@ return view.extend({
render: function(data) { render: function(data) {
var self = this; var self = this;
var backends = data[0] || []; var backends = data[0] || [];
var servers = data[1] || []; var serversResult = data[1] || {};
var servers = (serversResult && serversResult.servers) || serversResult || [];
// Group servers by backend // Group servers by backend
var serversByBackend = {}; var serversByBackend = {};
@ -28,42 +43,258 @@ return view.extend({
serversByBackend[s.backend].push(s); serversByBackend[s.backend].push(s);
}); });
var view = E('div', { 'class': 'cbi-map' }, [ return E('div', { 'class': 'haproxy-dashboard' }, [
E('h2', {}, 'Backends'), // Page Header
E('p', {}, 'Manage backend server pools and load balancing settings.'), E('div', { 'class': 'hp-page-header' }, [
E('div', {}, [
E('h1', { 'class': 'hp-page-title' }, [
E('span', { 'class': 'hp-page-title-icon' }, '\u{1F5C4}'),
'Backends'
]),
E('p', { 'class': 'hp-page-subtitle' }, 'Manage backend server pools and load balancing settings')
]),
E('a', {
'href': L.url('admin/services/haproxy/overview'),
'class': 'hp-btn hp-btn-secondary'
}, ['\u2190', ' Back to Overview'])
]),
// Add backend form // Add Backend Card
E('div', { 'class': 'haproxy-form-section' }, [ E('div', { 'class': 'hp-card' }, [
E('h3', {}, 'Add Backend'), E('div', { 'class': 'hp-card-header' }, [
E('div', { 'class': 'hp-card-title' }, [
E('span', { 'class': 'hp-card-title-icon' }, '\u2795'),
'Add Backend'
])
]),
E('div', { 'class': 'hp-card-body' }, [
E('div', { 'class': 'hp-grid hp-grid-2', 'style': 'gap: 16px;' }, [
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Name'),
E('input', {
'type': 'text',
'id': 'new-backend-name',
'class': 'hp-form-input',
'placeholder': 'web-servers'
})
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Mode'),
E('select', { 'id': 'new-backend-mode', 'class': 'hp-form-input' }, [
E('option', { 'value': 'http', 'selected': true }, 'HTTP'),
E('option', { 'value': 'tcp' }, 'TCP')
])
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Balance Algorithm'),
E('select', { 'id': 'new-backend-balance', 'class': 'hp-form-input' }, [
E('option', { 'value': 'roundrobin', 'selected': true }, 'Round Robin'),
E('option', { 'value': 'leastconn' }, 'Least Connections'),
E('option', { 'value': 'source' }, 'Source IP Hash'),
E('option', { 'value': 'uri' }, 'URI Hash'),
E('option', { 'value': 'first' }, 'First Available')
])
]),
E('div', { 'class': 'hp-form-group' }, [
E('label', { 'class': 'hp-form-label' }, 'Health Check (optional)'),
E('input', {
'type': 'text',
'id': 'new-backend-health',
'class': 'hp-form-input',
'placeholder': 'httpchk GET /health'
})
])
]),
E('button', {
'class': 'hp-btn hp-btn-primary',
'style': 'margin-top: 16px;',
'click': function() { self.handleAddBackend(); }
}, ['\u2795', ' Add Backend'])
])
]),
// Backends List
E('div', { 'class': 'hp-card' }, [
E('div', { 'class': 'hp-card-header' }, [
E('div', { 'class': 'hp-card-title' }, [
E('span', { 'class': 'hp-card-title-icon' }, '\u{1F4CB}'),
'Configured Backends (' + backends.length + ')'
])
]),
E('div', { 'class': 'hp-card-body' },
backends.length === 0 ? [
E('div', { 'class': 'hp-empty' }, [
E('div', { 'class': 'hp-empty-icon' }, '\u{1F5C4}'),
E('div', { 'class': 'hp-empty-text' }, 'No backends configured'),
E('div', { 'class': 'hp-empty-hint' }, 'Add a backend above to create a server pool')
])
] : [
E('div', { 'class': 'hp-backends-grid' },
backends.map(function(backend) {
return self.renderBackendCard(backend, serversByBackend[backend.id] || []);
})
)
]
)
])
]);
},
renderBackendCard: function(backend, servers) {
var self = this;
return E('div', { 'class': 'hp-backend-card', 'data-id': backend.id }, [
// Header
E('div', { 'class': 'hp-backend-header' }, [
E('div', {}, [
E('h4', { 'style': 'margin: 0 0 4px 0;' }, backend.name),
E('small', { 'style': 'color: var(--hp-text-muted);' }, [
backend.mode.toUpperCase(),
' \u2022 ',
this.getBalanceLabel(backend.balance)
])
]),
E('div', { 'style': 'display: flex; gap: 8px; align-items: center;' }, [
E('span', {
'class': 'hp-badge ' + (backend.enabled ? 'hp-badge-success' : 'hp-badge-danger')
}, backend.enabled ? 'Enabled' : 'Disabled'),
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-primary',
'click': function() { self.showEditBackendModal(backend); }
}, '\u270F')
])
]),
// Health check info
backend.health_check ? E('div', { 'style': 'padding: 8px 16px; background: var(--hp-bg-tertiary, #f5f5f5); font-size: 12px; color: var(--hp-text-muted);' }, [
'\u{1F3E5} Health Check: ',
E('code', {}, backend.health_check)
]) : null,
// Servers
E('div', { 'class': 'hp-backend-servers' },
servers.length === 0 ? [
E('div', { 'style': 'padding: 20px; text-align: center; color: var(--hp-text-muted);' }, [
E('div', {}, '\u{1F4E6} No servers configured'),
E('small', {}, 'Add a server to this backend')
])
] : servers.map(function(server) {
return E('div', { 'class': 'hp-server-item' }, [
E('div', { 'class': 'hp-server-info' }, [
E('span', { 'class': 'hp-server-name' }, server.name),
E('span', { 'class': 'hp-server-address' }, server.address + ':' + server.port)
]),
E('div', { 'class': 'hp-server-actions' }, [
E('span', { 'class': 'hp-badge hp-badge-secondary', 'style': 'font-size: 11px;' }, 'W:' + server.weight),
server.check ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'font-size: 11px;' }, '\u2713 Check') : null,
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-secondary',
'style': 'padding: 2px 6px;',
'click': function() { self.showEditServerModal(server, backend); }
}, '\u270F'),
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-danger',
'style': 'padding: 2px 6px;',
'click': function() { self.handleDeleteServer(server); }
}, '\u2715')
])
]);
})
),
// Footer Actions
E('div', { 'class': 'hp-backend-footer' }, [
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-primary',
'click': function() { self.showAddServerModal(backend); }
}, ['\u2795', ' Add Server']),
E('div', { 'style': 'display: flex; gap: 8px;' }, [
E('button', {
'class': 'hp-btn hp-btn-sm ' + (backend.enabled ? 'hp-btn-secondary' : 'hp-btn-success'),
'click': function() { self.handleToggleBackend(backend); }
}, backend.enabled ? 'Disable' : 'Enable'),
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-danger',
'click': function() { self.handleDeleteBackend(backend); }
}, 'Delete')
])
])
]);
},
getBalanceLabel: function(balance) {
var labels = {
'roundrobin': 'Round Robin',
'leastconn': 'Least Connections',
'source': 'Source IP',
'uri': 'URI Hash',
'first': 'First Available'
};
return labels[balance] || balance;
},
handleAddBackend: function() {
var self = this;
var name = document.getElementById('new-backend-name').value.trim();
var mode = document.getElementById('new-backend-mode').value;
var balance = document.getElementById('new-backend-balance').value;
var healthCheck = document.getElementById('new-backend-health').value.trim();
if (!name) {
self.showToast('Backend name is required', 'error');
return;
}
if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
self.showToast('Invalid backend name format', 'error');
return;
}
return api.createBackend(name, mode, balance, healthCheck, 1).then(function(res) {
if (res.success) {
self.showToast('Backend "' + name + '" created', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
},
showEditBackendModal: function(backend) {
var self = this;
ui.showModal('Edit Backend: ' + backend.name, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Name'), E('label', { 'class': 'cbi-value-title' }, 'Name'),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('input', { E('input', {
'type': 'text', 'type': 'text',
'id': 'new-backend-name', 'id': 'edit-backend-name',
'class': 'cbi-input-text', 'class': 'cbi-input-text',
'placeholder': 'web-servers' 'value': backend.name,
'style': 'width: 100%;'
}) })
]) ])
]), ]),
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Mode'), E('label', { 'class': 'cbi-value-title' }, 'Mode'),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'new-backend-mode', 'class': 'cbi-input-select' }, [ E('select', { 'id': 'edit-backend-mode', 'class': 'cbi-input-select', 'style': 'width: 100%;' }, [
E('option', { 'value': 'http', 'selected': true }, 'HTTP'), E('option', { 'value': 'http', 'selected': backend.mode === 'http' }, 'HTTP'),
E('option', { 'value': 'tcp' }, 'TCP') E('option', { 'value': 'tcp', 'selected': backend.mode === 'tcp' }, 'TCP')
]) ])
]) ])
]), ]),
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Balance'), E('label', { 'class': 'cbi-value-title' }, 'Balance Algorithm'),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'new-backend-balance', 'class': 'cbi-input-select' }, [ E('select', { 'id': 'edit-backend-balance', 'class': 'cbi-input-select', 'style': 'width: 100%;' }, [
E('option', { 'value': 'roundrobin', 'selected': true }, 'Round Robin'), E('option', { 'value': 'roundrobin', 'selected': backend.balance === 'roundrobin' }, 'Round Robin'),
E('option', { 'value': 'leastconn' }, 'Least Connections'), E('option', { 'value': 'leastconn', 'selected': backend.balance === 'leastconn' }, 'Least Connections'),
E('option', { 'value': 'source' }, 'Source IP Hash'), E('option', { 'value': 'source', 'selected': backend.balance === 'source' }, 'Source IP Hash'),
E('option', { 'value': 'uri' }, 'URI Hash'), E('option', { 'value': 'uri', 'selected': backend.balance === 'uri' }, 'URI Hash'),
E('option', { 'value': 'first' }, 'First Available') E('option', { 'value': 'first', 'selected': backend.balance === 'first' }, 'First Available')
]) ])
]) ])
]), ]),
@ -72,135 +303,98 @@ return view.extend({
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('input', { E('input', {
'type': 'text', 'type': 'text',
'id': 'new-backend-health', 'id': 'edit-backend-health',
'class': 'cbi-input-text', 'class': 'cbi-input-text',
'placeholder': 'httpchk GET /health (optional)' 'value': backend.health_check || '',
'placeholder': 'httpchk GET /health',
'style': 'width: 100%;'
}) })
]) ])
]), ]),
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, ''), E('label', { 'class': 'cbi-value-title' }, 'Status'),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('button', { E('label', {}, [
'class': 'cbi-button cbi-button-add', E('input', { 'type': 'checkbox', 'id': 'edit-backend-enabled', 'checked': backend.enabled }),
'click': function() { self.handleAddBackend(); } ' Enabled'
}, 'Add Backend') ])
]) ])
]) ])
]), ]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
// Backends list
E('div', { 'class': 'haproxy-form-section' }, [
E('h3', {}, 'Configured Backends (' + backends.length + ')'),
E('div', { 'class': 'haproxy-backends-grid' },
backends.length === 0
? E('p', { 'style': 'color: var(--text-color-medium, #666)' }, 'No backends configured.')
: backends.map(function(backend) {
return self.renderBackendCard(backend, serversByBackend[backend.id] || []);
})
)
])
]);
// Add CSS
var style = E('style', {}, `
@import url('/luci-static/resources/haproxy/dashboard.css');
`);
view.insertBefore(style, view.firstChild);
return view;
},
renderBackendCard: function(backend, servers) {
var self = this;
return E('div', { 'class': 'haproxy-backend-card', 'data-id': backend.id }, [
E('div', { 'class': 'haproxy-backend-header' }, [
E('div', {}, [
E('h4', {}, backend.name),
E('small', { 'style': 'color: #666' },
backend.mode.toUpperCase() + ' / ' + backend.balance)
]),
E('div', {}, [
E('span', {
'class': 'haproxy-badge ' + (backend.enabled ? 'enabled' : 'disabled')
}, backend.enabled ? 'Enabled' : 'Disabled')
])
]),
E('div', { 'class': 'haproxy-backend-servers' },
servers.length === 0
? E('div', { 'style': 'padding: 1rem; color: #666; text-align: center' }, 'No servers configured')
: servers.map(function(server) {
return E('div', { 'class': 'haproxy-server-item' }, [
E('div', { 'class': 'haproxy-server-info' }, [
E('span', { 'class': 'haproxy-server-name' }, server.name),
E('span', { 'class': 'haproxy-server-address' },
server.address + ':' + server.port)
]),
E('div', { 'class': 'haproxy-server-status' }, [
E('span', { 'class': 'haproxy-server-weight' }, 'W:' + server.weight),
E('button', { E('button', {
'class': 'cbi-button cbi-button-remove', 'class': 'hp-btn hp-btn-secondary',
'style': 'padding: 2px 8px; font-size: 12px', 'click': ui.hideModal
'click': function() { self.handleDeleteServer(server); } }, 'Cancel'),
}, 'X')
])
]);
})
),
E('div', { 'style': 'padding: 0.75rem; border-top: 1px solid #eee; display: flex; gap: 0.5rem' }, [
E('button', { E('button', {
'class': 'cbi-button cbi-button-action', 'class': 'hp-btn hp-btn-primary',
'style': 'flex: 1', 'click': function() {
'click': function() { self.showAddServerModal(backend); } var name = document.getElementById('edit-backend-name').value.trim();
}, 'Add Server'), var mode = document.getElementById('edit-backend-mode').value;
E('button', { var balance = document.getElementById('edit-backend-balance').value;
'class': 'cbi-button cbi-button-remove', var healthCheck = document.getElementById('edit-backend-health').value.trim();
'click': function() { self.handleDeleteBackend(backend); } var enabled = document.getElementById('edit-backend-enabled').checked ? 1 : 0;
}, 'Delete')
])
]);
},
handleAddBackend: function() {
var name = document.getElementById('new-backend-name').value.trim();
var mode = document.getElementById('new-backend-mode').value;
var balance = document.getElementById('new-backend-balance').value;
var healthCheck = document.getElementById('new-backend-health').value.trim();
if (!name) { if (!name) {
ui.addNotification(null, E('p', {}, 'Backend name is required'), 'error'); self.showToast('Backend name is required', 'error');
return; return;
} }
return api.createBackend(name, mode, balance, healthCheck, 1).then(function(res) { ui.hideModal();
api.updateBackend(backend.id, name, mode, balance, healthCheck, enabled).then(function(res) {
if (res.success) { if (res.success) {
ui.addNotification(null, E('p', {}, 'Backend created')); self.showToast('Backend updated', 'success');
window.location.reload(); window.location.reload();
} else { } else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
}, 'Save Changes')
])
]);
},
handleToggleBackend: function(backend) {
var self = this;
var newEnabled = backend.enabled ? 0 : 1;
var action = newEnabled ? 'enabled' : 'disabled';
return api.updateBackend(backend.id, null, null, null, null, newEnabled).then(function(res) {
if (res.success) {
self.showToast('Backend ' + action, 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
} }
}); });
}, },
handleDeleteBackend: function(backend) { handleDeleteBackend: function(backend) {
var self = this;
ui.showModal('Delete Backend', [ ui.showModal('Delete Backend', [
E('p', {}, 'Are you sure you want to delete backend "' + backend.name + '" and all its servers?'), E('div', { 'style': 'margin-bottom: 16px;' }, [
E('div', { 'class': 'right' }, [ E('p', { 'style': 'margin: 0;' }, 'Are you sure you want to delete this backend and all its servers?'),
E('div', {
'style': 'margin-top: 12px; padding: 12px; background: var(--hp-bg-tertiary, #f5f5f5); border-radius: 8px; font-family: monospace;'
}, backend.name)
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [
E('button', { E('button', {
'class': 'cbi-button', 'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal 'click': ui.hideModal
}, 'Cancel'), }, 'Cancel'),
E('button', { E('button', {
'class': 'cbi-button cbi-button-negative', 'class': 'hp-btn hp-btn-danger',
'click': function() { 'click': function() {
ui.hideModal(); ui.hideModal();
api.deleteBackend(backend.id).then(function(res) { api.deleteBackend(backend.id).then(function(res) {
if (res.success) { if (res.success) {
ui.addNotification(null, E('p', {}, 'Backend deleted')); self.showToast('Backend deleted', 'success');
window.location.reload(); window.location.reload();
} else { } else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
} }
}); });
} }
@ -213,6 +407,7 @@ return view.extend({
var self = this; var self = this;
ui.showModal('Add Server to ' + backend.name, [ ui.showModal('Add Server to ' + backend.name, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Name'), E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
@ -220,7 +415,8 @@ return view.extend({
'type': 'text', 'type': 'text',
'id': 'modal-server-name', 'id': 'modal-server-name',
'class': 'cbi-input-text', 'class': 'cbi-input-text',
'placeholder': 'server1' 'placeholder': 'server1',
'style': 'width: 100%;'
}) })
]) ])
]), ]),
@ -231,7 +427,8 @@ return view.extend({
'type': 'text', 'type': 'text',
'id': 'modal-server-address', 'id': 'modal-server-address',
'class': 'cbi-input-text', 'class': 'cbi-input-text',
'placeholder': '192.168.1.10' 'placeholder': '192.168.1.10',
'style': 'width: 100%;'
}) })
]) ])
]), ]),
@ -243,7 +440,10 @@ return view.extend({
'id': 'modal-server-port', 'id': 'modal-server-port',
'class': 'cbi-input-text', 'class': 'cbi-input-text',
'placeholder': '8080', 'placeholder': '8080',
'value': '80' 'value': '80',
'min': '1',
'max': '65535',
'style': 'width: 100%;'
}) })
]) ])
]), ]),
@ -254,29 +454,31 @@ return view.extend({
'type': 'number', 'type': 'number',
'id': 'modal-server-weight', 'id': 'modal-server-weight',
'class': 'cbi-input-text', 'class': 'cbi-input-text',
'placeholder': '100',
'value': '100', 'value': '100',
'min': '0', 'min': '0',
'max': '256' 'max': '256',
}) 'style': 'width: 100%;'
}),
E('small', { 'style': 'color: var(--hp-text-muted);' }, 'Higher weight = more traffic (0-256)')
]) ])
]), ]),
E('div', { 'class': 'cbi-value' }, [ E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Health Check'), E('label', { 'class': 'cbi-value-title' }, 'Options'),
E('div', { 'class': 'cbi-value-field' }, [ E('div', { 'class': 'cbi-value-field' }, [
E('label', {}, [ E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'modal-server-check', 'checked': true }), E('input', { 'type': 'checkbox', 'id': 'modal-server-check', 'checked': true }),
' Enable health check' ' Enable health check'
]) ])
]) ])
])
]), ]),
E('div', { 'class': 'right' }, [ E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', { E('button', {
'class': 'cbi-button', 'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal 'click': ui.hideModal
}, 'Cancel'), }, 'Cancel'),
E('button', { E('button', {
'class': 'cbi-button cbi-button-positive', 'class': 'hp-btn hp-btn-primary',
'click': function() { 'click': function() {
var name = document.getElementById('modal-server-name').value.trim(); var name = document.getElementById('modal-server-name').value.trim();
var address = document.getElementById('modal-server-address').value.trim(); var address = document.getElementById('modal-server-address').value.trim();
@ -285,17 +487,17 @@ return view.extend({
var check = document.getElementById('modal-server-check').checked ? 1 : 0; var check = document.getElementById('modal-server-check').checked ? 1 : 0;
if (!name || !address) { if (!name || !address) {
ui.addNotification(null, E('p', {}, 'Name and address are required'), 'error'); self.showToast('Name and address are required', 'error');
return; return;
} }
ui.hideModal(); ui.hideModal();
api.createServer(backend.id, name, address, port, weight, check, 1).then(function(res) { api.createServer(backend.id, name, address, port, weight, check, 1).then(function(res) {
if (res.success) { if (res.success) {
ui.addNotification(null, E('p', {}, 'Server added')); self.showToast('Server added', 'success');
window.location.reload(); window.location.reload();
} else { } else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
} }
}); });
} }
@ -304,24 +506,141 @@ return view.extend({
]); ]);
}, },
handleDeleteServer: function(server) { showEditServerModal: function(server, backend) {
ui.showModal('Delete Server', [ var self = this;
E('p', {}, 'Are you sure you want to delete server "' + server.name + '"?'),
E('div', { 'class': 'right' }, [ ui.showModal('Edit Server: ' + server.name, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Server Name'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'edit-server-name',
'class': 'cbi-input-text',
'value': server.name,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Address'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'edit-server-address',
'class': 'cbi-input-text',
'value': server.address,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Port'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'edit-server-port',
'class': 'cbi-input-text',
'value': server.port,
'min': '1',
'max': '65535',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Weight'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'number',
'id': 'edit-server-weight',
'class': 'cbi-input-text',
'value': server.weight,
'min': '0',
'max': '256',
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-server-check', 'checked': server.check }),
' Enable health check'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-server-enabled', 'checked': server.enabled }),
' Enabled'
])
])
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', { E('button', {
'class': 'cbi-button', 'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal 'click': ui.hideModal
}, 'Cancel'), }, 'Cancel'),
E('button', { E('button', {
'class': 'cbi-button cbi-button-negative', 'class': 'hp-btn hp-btn-primary',
'click': function() { 'click': function() {
var name = document.getElementById('edit-server-name').value.trim();
var address = document.getElementById('edit-server-address').value.trim();
var port = parseInt(document.getElementById('edit-server-port').value) || 80;
var weight = parseInt(document.getElementById('edit-server-weight').value) || 100;
var check = document.getElementById('edit-server-check').checked ? 1 : 0;
var enabled = document.getElementById('edit-server-enabled').checked ? 1 : 0;
if (!name || !address) {
self.showToast('Name and address are required', 'error');
return;
}
ui.hideModal(); ui.hideModal();
api.deleteServer(server.id).then(function(res) { var inline = server.inline ? 1 : 0;
api.updateServer(server.id, backend.id, name, address, port, weight, check, enabled, inline).then(function(res) {
if (res.success) { if (res.success) {
ui.addNotification(null, E('p', {}, 'Server deleted')); self.showToast('Server updated', 'success');
window.location.reload(); window.location.reload();
} else { } else {
ui.addNotification(null, E('p', {}, 'Failed: ' + (res.error || 'Unknown error')), 'error'); self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
}, 'Save Changes')
])
]);
},
handleDeleteServer: function(server) {
var self = this;
ui.showModal('Delete Server', [
E('div', { 'style': 'margin-bottom: 16px;' }, [
E('p', { 'style': 'margin: 0;' }, 'Are you sure you want to delete this server?'),
E('div', {
'style': 'margin-top: 12px; padding: 12px; background: var(--hp-bg-tertiary, #f5f5f5); border-radius: 8px; font-family: monospace;'
}, server.name + ' (' + server.address + ':' + server.port + ')')
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px;' }, [
E('button', {
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'hp-btn hp-btn-danger',
'click': function() {
ui.hideModal();
var inline = server.inline ? 1 : 0;
api.deleteServer(server.id, inline).then(function(res) {
if (res.success) {
self.showToast('Server deleted', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
} }
}); });
} }
@ -330,6 +649,27 @@ return view.extend({
]); ]);
}, },
showToast: function(message, type) {
var existing = document.querySelector('.hp-toast');
if (existing) existing.remove();
var iconMap = {
'success': '\u2705',
'error': '\u274C',
'warning': '\u26A0\uFE0F'
};
var toast = E('div', { 'class': 'hp-toast ' + (type || '') }, [
E('span', {}, iconMap[type] || '\u2139\uFE0F'),
message
]);
document.body.appendChild(toast);
setTimeout(function() {
toast.remove();
}, 4000);
},
handleSaveApply: null, handleSaveApply: null,
handleSave: null, handleSave: null,
handleReset: null handleReset: null

View File

@ -135,7 +135,7 @@ return view.extend({
E('th', {}, 'Backend'), E('th', {}, 'Backend'),
E('th', {}, 'SSL Configuration'), E('th', {}, 'SSL Configuration'),
E('th', {}, 'Status'), E('th', {}, 'Status'),
E('th', { 'style': 'width: 180px; text-align: right;' }, 'Actions') E('th', { 'style': 'width: 220px; text-align: right;' }, 'Actions')
]) ])
]), ]),
E('tbody', {}, vhosts.map(function(vh) { E('tbody', {}, vhosts.map(function(vh) {
@ -152,11 +152,16 @@ return view.extend({
vh.ssl ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'margin-right: 6px;' }, '\u{1F512} SSL') : null, vh.ssl ? E('span', { 'class': 'hp-badge hp-badge-info', 'style': 'margin-right: 6px;' }, '\u{1F512} SSL') : null,
vh.acme ? E('span', { 'class': 'hp-badge hp-badge-success' }, '\u{1F504} ACME') : null, vh.acme ? E('span', { 'class': 'hp-badge hp-badge-success' }, '\u{1F504} ACME') : null,
!vh.ssl && !vh.acme ? E('span', { 'class': 'hp-badge hp-badge-warning' }, 'No SSL') : null !vh.ssl && !vh.acme ? E('span', { 'class': 'hp-badge hp-badge-warning' }, 'No SSL') : null
]), ].filter(function(e) { return e !== null; })),
E('td', {}, E('span', { E('td', {}, E('span', {
'class': 'hp-badge ' + (vh.enabled ? 'hp-badge-success' : 'hp-badge-danger') 'class': 'hp-badge ' + (vh.enabled ? 'hp-badge-success' : 'hp-badge-danger')
}, vh.enabled ? '\u2705 Active' : '\u26D4 Disabled')), }, vh.enabled ? '\u2705 Active' : '\u26D4 Disabled')),
E('td', { 'style': 'text-align: right;' }, [ E('td', { 'style': 'text-align: right;' }, [
E('button', {
'class': 'hp-btn hp-btn-sm hp-btn-primary',
'style': 'margin-right: 8px;',
'click': function() { self.showEditVhostModal(vh, backends); }
}, '\u270F Edit'),
E('button', { E('button', {
'class': 'hp-btn hp-btn-sm ' + (vh.enabled ? 'hp-btn-secondary' : 'hp-btn-success'), 'class': 'hp-btn hp-btn-sm ' + (vh.enabled ? 'hp-btn-secondary' : 'hp-btn-success'),
'style': 'margin-right: 8px;', 'style': 'margin-right: 8px;',
@ -172,6 +177,100 @@ return view.extend({
]); ]);
}, },
showEditVhostModal: function(vh, backends) {
var self = this;
ui.showModal('Edit Virtual Host: ' + vh.domain, [
E('div', { 'style': 'max-width: 500px;' }, [
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Domain'),
E('div', { 'class': 'cbi-value-field' }, [
E('input', {
'type': 'text',
'id': 'edit-domain',
'class': 'cbi-input-text',
'value': vh.domain,
'style': 'width: 100%;'
})
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Backend'),
E('div', { 'class': 'cbi-value-field' }, [
E('select', { 'id': 'edit-backend', 'class': 'cbi-input-select', 'style': 'width: 100%;' },
[E('option', { 'value': '' }, '-- Select Backend --')].concat(
backends.map(function(b) {
var selected = (vh.backend === (b.id || b.name)) ? { 'selected': true } : {};
return E('option', Object.assign({ 'value': b.id || b.name }, selected), b.name);
})
)
)
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'SSL Options'),
E('div', { 'class': 'cbi-value-field' }, [
E('div', { 'style': 'display: flex; flex-direction: column; gap: 8px;' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-ssl', 'checked': vh.ssl }),
' Enable SSL/TLS'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-ssl-redirect', 'checked': vh.ssl_redirect }),
' Force HTTPS redirect'
]),
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-acme', 'checked': vh.acme }),
' Auto-renew with ACME (Let\'s Encrypt)'
])
])
])
]),
E('div', { 'class': 'cbi-value' }, [
E('label', { 'class': 'cbi-value-title' }, 'Status'),
E('div', { 'class': 'cbi-value-field' }, [
E('label', {}, [
E('input', { 'type': 'checkbox', 'id': 'edit-enabled', 'checked': vh.enabled }),
' Enabled'
])
])
])
]),
E('div', { 'style': 'display: flex; justify-content: flex-end; gap: 12px; margin-top: 16px;' }, [
E('button', {
'class': 'hp-btn hp-btn-secondary',
'click': ui.hideModal
}, 'Cancel'),
E('button', {
'class': 'hp-btn hp-btn-primary',
'click': function() {
var domain = document.getElementById('edit-domain').value.trim();
var backend = document.getElementById('edit-backend').value;
var ssl = document.getElementById('edit-ssl').checked ? 1 : 0;
var sslRedirect = document.getElementById('edit-ssl-redirect').checked ? 1 : 0;
var acme = document.getElementById('edit-acme').checked ? 1 : 0;
var enabled = document.getElementById('edit-enabled').checked ? 1 : 0;
if (!domain) {
self.showToast('Domain is required', 'error');
return;
}
ui.hideModal();
api.updateVhost(vh.id, domain, backend, ssl, sslRedirect, acme, enabled).then(function(res) {
if (res.success) {
self.showToast('Virtual host updated', 'success');
window.location.reload();
} else {
self.showToast('Failed: ' + (res.error || 'Unknown error'), 'error');
}
});
}
}, 'Save Changes')
])
]);
},
handleAddVhost: function(backends) { handleAddVhost: function(backends) {
var self = this; var self = this;
var domain = document.getElementById('new-domain').value.trim(); var domain = document.getElementById('new-domain').value.trim();

View File

@ -55,15 +55,15 @@ method_status() {
stats_enabled=$(get_uci main stats_enabled 1) stats_enabled=$(get_uci main stats_enabled 1)
# Check container status # Check container status
if lxc-info -n haproxy-lxc >/dev/null 2>&1; then if lxc-info -n haproxy >/dev/null 2>&1; then
container_running=$(lxc-info -n haproxy-lxc -s 2>/dev/null | grep -q "RUNNING" && echo "1" || echo "0") container_running=$(lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING" && echo "1" || echo "0")
else else
container_running="0" container_running="0"
fi fi
# Check HAProxy process in container # Check HAProxy process in container
if [ "$container_running" = "1" ]; then if [ "$container_running" = "1" ]; then
haproxy_running=$(lxc-attach -n haproxy-lxc -- pgrep haproxy >/dev/null 2>&1 && echo "1" || echo "0") haproxy_running=$(lxc-attach -n haproxy -- pgrep haproxy >/dev/null 2>&1 && echo "1" || echo "0")
else else
haproxy_running="0" haproxy_running="0"
fi fi
@ -83,7 +83,7 @@ method_status() {
method_get_stats() { method_get_stats() {
local stats_output local stats_output
if lxc-info -n haproxy-lxc -s 2>/dev/null | grep -q "RUNNING"; then if lxc-info -n haproxy -s 2>/dev/null | grep -q "RUNNING"; then
# Get stats via HAProxy socket # Get stats via HAProxy socket
stats_output=$(run_ctl stats 2>/dev/null) stats_output=$(run_ctl stats 2>/dev/null)
if [ -n "$stats_output" ]; then if [ -n "$stats_output" ]; then
@ -291,13 +291,14 @@ method_list_backends() {
_add_backend() { _add_backend() {
local section="$1" local section="$1"
local name mode balance health_check enabled local name mode balance health_check enabled server_line
config_get name "$section" name "$section" config_get name "$section" name "$section"
config_get mode "$section" mode "http" config_get mode "$section" mode "http"
config_get balance "$section" balance "roundrobin" config_get balance "$section" balance "roundrobin"
config_get health_check "$section" health_check "" config_get health_check "$section" health_check ""
config_get enabled "$section" enabled "1" config_get enabled "$section" enabled "1"
config_get server_line "$section" server ""
json_add_object json_add_object
json_add_string "id" "$section" json_add_string "id" "$section"
@ -306,6 +307,59 @@ _add_backend() {
json_add_string "balance" "$balance" json_add_string "balance" "$balance"
json_add_string "health_check" "$health_check" json_add_string "health_check" "$health_check"
json_add_boolean "enabled" "$enabled" json_add_boolean "enabled" "$enabled"
# Include servers array - parse inline server option if present
json_add_array "servers"
if [ -n "$server_line" ]; then
# Parse inline format: "name address:port [options]"
local srv_name srv_addr_port srv_addr srv_port srv_check
srv_name=$(echo "$server_line" | awk '{print $1}')
srv_addr_port=$(echo "$server_line" | awk '{print $2}')
srv_addr=$(echo "$srv_addr_port" | cut -d: -f1)
srv_port=$(echo "$srv_addr_port" | cut -d: -f2)
srv_check=$(echo "$server_line" | grep -q "check" && echo "1" || echo "0")
json_add_object
json_add_string "id" "${section}_${srv_name}"
json_add_string "name" "$srv_name"
json_add_string "address" "$srv_addr"
json_add_int "port" "${srv_port:-80}"
json_add_int "weight" "100"
json_add_boolean "check" "$srv_check"
json_add_boolean "enabled" "1"
json_add_boolean "inline" "1"
json_close_object
fi
# Also check for separate server sections
config_foreach _add_server_for_backend_inline server "$section"
json_close_array
json_close_object
}
_add_server_for_backend_inline() {
local srv_section="$1"
local backend_filter="$2"
local backend srv_name srv_address srv_port srv_weight srv_check srv_enabled
config_get backend "$srv_section" backend ""
[ "$backend" != "$backend_filter" ] && return
config_get srv_name "$srv_section" name "$srv_section"
config_get srv_address "$srv_section" address ""
config_get srv_port "$srv_section" port ""
config_get srv_weight "$srv_section" weight "100"
config_get srv_check "$srv_section" check "1"
config_get srv_enabled "$srv_section" enabled "1"
json_add_object
json_add_string "id" "$srv_section"
json_add_string "name" "$srv_name"
json_add_string "address" "$srv_address"
json_add_int "port" "${srv_port:-80}"
json_add_int "weight" "$srv_weight"
json_add_boolean "check" "$srv_check"
json_add_boolean "enabled" "$srv_enabled"
json_close_object json_close_object
} }
@ -580,7 +634,7 @@ method_create_server() {
# Update server # Update server
method_update_server() { method_update_server() {
local id backend name address port weight check enabled local id backend name address port weight check enabled inline
read -r input read -r input
json_load "$input" json_load "$input"
@ -592,6 +646,7 @@ method_update_server() {
json_get_var weight weight json_get_var weight weight
json_get_var check check json_get_var check check
json_get_var enabled enabled json_get_var enabled enabled
json_get_var inline inline ""
if [ -z "$id" ]; then if [ -z "$id" ]; then
json_init json_init
@ -601,6 +656,28 @@ method_update_server() {
return return
fi fi
# Check if this is an inline server (id format: backendname_servername)
# If so, we need to convert it to a proper server section
if [ "$inline" = "1" ] || ! uci -q get "$UCI_CONFIG.$id" >/dev/null 2>&1; then
# This is an inline server - extract backend from id
local backend_id
backend_id=$(echo "$id" | sed 's/_[^_]*$//')
# Remove inline server option from backend
uci -q delete "$UCI_CONFIG.$backend_id.server"
# Create new server section
local section_id="${backend_id}_${name}"
uci set "$UCI_CONFIG.$section_id=server"
uci set "$UCI_CONFIG.$section_id.backend=$backend_id"
uci set "$UCI_CONFIG.$section_id.name=$name"
uci set "$UCI_CONFIG.$section_id.address=$address"
uci set "$UCI_CONFIG.$section_id.port=$port"
[ -n "$weight" ] && uci set "$UCI_CONFIG.$section_id.weight=$weight"
[ -n "$check" ] && uci set "$UCI_CONFIG.$section_id.check=$check"
[ -n "$enabled" ] && uci set "$UCI_CONFIG.$section_id.enabled=$enabled"
else
# Regular server section - update in place
[ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend" [ -n "$backend" ] && uci set "$UCI_CONFIG.$id.backend=$backend"
[ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name" [ -n "$name" ] && uci set "$UCI_CONFIG.$id.name=$name"
[ -n "$address" ] && uci set "$UCI_CONFIG.$id.address=$address" [ -n "$address" ] && uci set "$UCI_CONFIG.$id.address=$address"
@ -608,6 +685,7 @@ method_update_server() {
[ -n "$weight" ] && uci set "$UCI_CONFIG.$id.weight=$weight" [ -n "$weight" ] && uci set "$UCI_CONFIG.$id.weight=$weight"
[ -n "$check" ] && uci set "$UCI_CONFIG.$id.check=$check" [ -n "$check" ] && uci set "$UCI_CONFIG.$id.check=$check"
[ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled" [ -n "$enabled" ] && uci set "$UCI_CONFIG.$id.enabled=$enabled"
fi
uci commit "$UCI_CONFIG" uci commit "$UCI_CONFIG"
run_ctl generate >/dev/null 2>&1 run_ctl generate >/dev/null 2>&1
@ -619,11 +697,12 @@ method_update_server() {
# Delete server # Delete server
method_delete_server() { method_delete_server() {
local id local id inline
read -r input read -r input
json_load "$input" json_load "$input"
json_get_var id id json_get_var id id
json_get_var inline inline ""
if [ -z "$id" ]; then if [ -z "$id" ]; then
json_init json_init
@ -633,7 +712,16 @@ method_delete_server() {
return return
fi fi
# Check if this is an inline server or regular server section
if [ "$inline" = "1" ] || ! uci -q get "$UCI_CONFIG.$id" >/dev/null 2>&1; then
# Inline server - extract backend id and delete the server option
local backend_id
backend_id=$(echo "$id" | sed 's/_[^_]*$//')
uci -q delete "$UCI_CONFIG.$backend_id.server"
else
# Regular server section
uci delete "$UCI_CONFIG.$id" uci delete "$UCI_CONFIG.$id"
fi
uci commit "$UCI_CONFIG" uci commit "$UCI_CONFIG"
run_ctl generate >/dev/null 2>&1 run_ctl generate >/dev/null 2>&1
@ -688,7 +776,7 @@ method_request_certificate() {
fi fi
local result local result
result=$(run_ctl cert-issue "$domain" 2>&1) result=$(run_ctl cert add "$domain" 2>&1)
local rc=$? local rc=$?
json_init json_init
@ -721,7 +809,7 @@ method_import_certificate() {
fi fi
local result local result
result=$(run_ctl cert-import "$domain" "$cert_data" "$key_data" 2>&1) result=$(run_ctl cert import "$domain" "$cert_data" "$key_data" 2>&1)
local rc=$? local rc=$?
json_init json_init
@ -755,7 +843,7 @@ method_delete_certificate() {
domain=$(get_uci "$id" domain "") domain=$(get_uci "$id" domain "")
# Remove certificate files # Remove certificate files
run_ctl cert-delete "$domain" >/dev/null 2>&1 run_ctl cert remove "$domain" >/dev/null 2>&1
uci delete "$UCI_CONFIG.$id" uci delete "$UCI_CONFIG.$id"
uci commit "$UCI_CONFIG" uci commit "$UCI_CONFIG"
@ -1216,8 +1304,8 @@ case "$1" in
"delete_backend": { "id": "string" }, "delete_backend": { "id": "string" },
"list_servers": { "backend": "string" }, "list_servers": { "backend": "string" },
"create_server": { "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" }, "create_server": { "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" },
"update_server": { "id": "string", "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean" }, "update_server": { "id": "string", "backend": "string", "name": "string", "address": "string", "port": "integer", "weight": "integer", "check": "boolean", "enabled": "boolean", "inline": "boolean" },
"delete_server": { "id": "string" }, "delete_server": { "id": "string", "inline": "boolean" },
"list_certificates": {}, "list_certificates": {},
"request_certificate": { "domain": "string" }, "request_certificate": { "domain": "string" },
"import_certificate": { "domain": "string", "cert": "string", "key": "string" }, "import_certificate": { "domain": "string", "cert": "string", "key": "string" },

View File

@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-haproxy PKG_NAME:=secubox-app-haproxy
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=1 PKG_RELEASE:=13
PKG_MAINTAINER:=CyberMind <contact@cybermind.fr> PKG_MAINTAINER:=CyberMind <contact@cybermind.fr>
PKG_LICENSE:=MIT PKG_LICENSE:=MIT
@ -18,7 +18,7 @@ define Package/secubox-app-haproxy
CATEGORY:=SecuBox CATEGORY:=SecuBox
SUBMENU:=Services SUBMENU:=Services
TITLE:=HAProxy Load Balancer & Reverse Proxy TITLE:=HAProxy Load Balancer & Reverse Proxy
DEPENDS:=+lxc +lxc-common +openssl-util +wget-ssl +tar +jsonfilter +acme +socat DEPENDS:=+lxc +lxc-common +openssl-util +wget-ssl +tar +jsonfilter +acme +acme-acmesh +socat
PKGARCH:=all PKGARCH:=all
endef endef

View File

@ -21,7 +21,7 @@ start_service() {
procd_set_param respawn 3600 5 0 procd_set_param respawn 3600 5 0
procd_set_param stdout 1 procd_set_param stdout 1
procd_set_param stderr 1 procd_set_param stderr 1
procd_set_param pidfile /var/run/haproxy-lxc.pid procd_set_param pidfile /var/run/haproxy.pid
procd_close_instance procd_close_instance
} }

View File

@ -2,6 +2,9 @@
# SecuBox HAProxy Controller # SecuBox HAProxy Controller
# Copyright (C) 2025 CyberMind.fr # Copyright (C) 2025 CyberMind.fr
# Source OpenWrt functions for UCI iteration
. /lib/functions.sh
CONFIG="haproxy" CONFIG="haproxy"
LXC_NAME="haproxy" LXC_NAME="haproxy"
@ -196,7 +199,7 @@ lxc.net.0.type = none
# Mount points # Mount points
lxc.mount.auto = proc:mixed sys:ro cgroup:mixed lxc.mount.auto = proc:mixed sys:ro cgroup:mixed
lxc.mount.entry = $data_path /opt/haproxy none bind,create=dir 0 0 lxc.mount.entry = $data_path opt/haproxy none bind,create=dir 0 0
# Environment # Environment
lxc.environment = HTTP_PORT=$http_port lxc.environment = HTTP_PORT=$http_port
@ -206,8 +209,8 @@ lxc.environment = STATS_PORT=$stats_port
# Security # Security
lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time lxc.cap.drop = sys_admin sys_module mac_admin mac_override sys_time
# Resource limits # Resource limits (cgroup2)
lxc.cgroup.memory.limit_in_bytes = $mem_bytes lxc.cgroup2.memory.max = $mem_bytes
# Init command # Init command
lxc.init.cmd = /opt/start-haproxy.sh lxc.init.cmd = /opt/start-haproxy.sh
@ -234,7 +237,14 @@ lxc_run() {
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
CONFIG_FILE="/opt/haproxy/config/haproxy.cfg" CONFIG_FILE="/opt/haproxy/config/haproxy.cfg"
PID_FILE="/var/run/haproxy.pid" LOG_FILE="/opt/haproxy/startup.log"
# Log all output
exec >>"$LOG_FILE" 2>&1
echo "=== HAProxy startup: $(date) ==="
echo "Config: $CONFIG_FILE"
ls -la /opt/haproxy/
ls -la /opt/haproxy/certs/ 2>/dev/null || echo "No certs dir"
# Wait for config # Wait for config
if [ ! -f "$CONFIG_FILE" ]; then if [ ! -f "$CONFIG_FILE" ]; then
@ -275,6 +285,16 @@ backend fallback
CFGEOF CFGEOF
fi fi
# Validate config first
echo "[haproxy] Validating config..."
haproxy -c -f "$CONFIG_FILE"
RC=$?
echo "[haproxy] Validation exit code: $RC"
if [ $RC -ne 0 ]; then
echo "[haproxy] Config validation failed!"
exit 1
fi
echo "[haproxy] Starting HAProxy..." echo "[haproxy] Starting HAProxy..."
exec haproxy -f "$CONFIG_FILE" -W -db exec haproxy -f "$CONFIG_FILE" -W -db
STARTEOF STARTEOF
@ -388,10 +408,12 @@ EOF
echo "" echo ""
# HTTPS Frontend (if certificates exist) # HTTPS Frontend (if certificates exist)
# Use container path /opt/haproxy/certs/ (not host path)
local CONTAINER_CERTS_PATH="/opt/haproxy/certs"
if [ -d "$CERTS_PATH" ] && ls "$CERTS_PATH"/*.pem >/dev/null 2>&1; then if [ -d "$CERTS_PATH" ] && ls "$CERTS_PATH"/*.pem >/dev/null 2>&1; then
cat << EOF cat << EOF
frontend https-in frontend https-in
bind *:$https_port ssl crt $CERTS_PATH/ alpn h2,http/1.1 bind *:$https_port ssl crt $CONTAINER_CERTS_PATH/ alpn h2,http/1.1
mode http mode http
http-request set-header X-Forwarded-Proto https http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src] http-request set-header X-Real-IP %[src]
@ -448,15 +470,18 @@ _add_vhost_acl() {
_generate_backends() { _generate_backends() {
config_load haproxy config_load haproxy
# Generate each backend # Generate each backend from UCI
config_foreach _generate_backend backend config_foreach _generate_backend backend
# Fallback backend # Only add default fallback if no "fallback" backend exists in UCI
if ! uci -q get haproxy.fallback >/dev/null 2>&1; then
cat << EOF cat << EOF
backend fallback backend fallback
mode http mode http
http-request deny deny_status 503 http-request deny deny_status 503
EOF EOF
fi
} }
_generate_backend() { _generate_backend() {
@ -478,7 +503,12 @@ _generate_backend() {
[ -n "$health_check" ] && echo " option $health_check" [ -n "$health_check" ] && echo " option $health_check"
# Add servers for this backend # Add servers defined in backend section (handles both single and list)
local server_line
config_get server_line "$section" server ""
[ -n "$server_line" ] && echo " server $server_line"
# Add servers from separate server UCI sections
config_foreach _add_server_to_backend server "$name" config_foreach _add_server_to_backend server "$name"
} }
@ -538,41 +568,108 @@ cmd_cert_add() {
local email=$(uci_get acme.email) local email=$(uci_get acme.email)
local staging=$(uci_get acme.staging) local staging=$(uci_get acme.staging)
local key_type=$(uci_get acme.key_type) || key_type="ec-256" local key_type_raw=$(uci_get acme.key_type) || key_type_raw="ec-256"
[ -z "$email" ] && { log_error "ACME email not configured"; return 1; } # Convert key type for acme.sh (rsa-4096 → 4096, ec-256 stays ec-256)
local key_type="$key_type_raw"
case "$key_type_raw" in
rsa-*) key_type="${key_type_raw#rsa-}" ;; # rsa-4096 → 4096
RSA-*) key_type="${key_type_raw#RSA-}" ;;
esac
[ -z "$email" ] && { log_error "ACME email not configured. Set in LuCI > Services > HAProxy > Settings"; return 1; }
log_info "Requesting certificate for $domain..." log_info "Requesting certificate for $domain..."
local staging_flag="" local staging_flag=""
[ "$staging" = "1" ] && staging_flag="--staging" [ "$staging" = "1" ] && staging_flag="--staging"
# Use acme.sh or certbot if available # Find acme.sh - check OpenWrt location first, then PATH
if command -v acme.sh >/dev/null 2>&1; then local ACME_SH=""
acme.sh --issue -d "$domain" --standalone --httpport $http_port \ if [ -x "/usr/lib/acme/client/acme.sh" ]; then
--keylength $key_type $staging_flag \ ACME_SH="/usr/lib/acme/client/acme.sh"
elif command -v acme.sh >/dev/null 2>&1; then
ACME_SH="acme.sh"
fi
if [ -n "$ACME_SH" ]; then
# Set acme.sh home directory
export LE_WORKING_DIR="/etc/acme"
export LE_CONFIG_HOME="/etc/acme"
ensure_dir "$LE_WORKING_DIR"
# Register account if needed
if [ ! -f "$LE_WORKING_DIR/account.conf" ]; then
log_info "Registering ACME account..."
"$ACME_SH" --register-account -m "$email" $staging_flag --home "$LE_WORKING_DIR" || true
fi
# Check if HAProxy is using the port
local haproxy_was_running=0
if lxc_running; then
log_info "Temporarily stopping HAProxy for certificate issuance..."
haproxy_was_running=1
/etc/init.d/haproxy stop 2>/dev/null || true
sleep 2
fi
# Issue certificate using standalone mode
log_info "Issuing certificate (standalone mode on port $http_port)..."
local acme_result=0
"$ACME_SH" --issue -d "$domain" \
--standalone --httpport "$http_port" \
--keylength "$key_type" \
$staging_flag \
--home "$LE_WORKING_DIR" || acme_result=$?
# acme.sh returns 0 on success, 2 on "skip/already valid" - both are OK
# Install the certificate to our certs path
if [ "$acme_result" -eq 0 ] || [ "$acme_result" -eq 2 ]; then
log_info "Installing certificate..."
"$ACME_SH" --install-cert -d "$domain" \
--home "$LE_WORKING_DIR" \
--cert-file "$CERTS_PATH/$domain.crt" \ --cert-file "$CERTS_PATH/$domain.crt" \
--key-file "$CERTS_PATH/$domain.key" \ --key-file "$CERTS_PATH/$domain.key" \
--fullchain-file "$CERTS_PATH/$domain.pem" \ --fullchain-file "$CERTS_PATH/$domain.pem" \
--reloadcmd "haproxyctl reload" --reloadcmd "/etc/init.d/haproxy reload" 2>/dev/null || true
fi
# Restart HAProxy if it was running
if [ "$haproxy_was_running" = "1" ]; then
log_info "Restarting HAProxy..."
/etc/init.d/haproxy start 2>/dev/null || true
fi
# Check if certificate was created
if [ ! -f "$CERTS_PATH/$domain.pem" ]; then
log_error "Certificate issuance failed. Ensure port $http_port is accessible from internet and domain points to this IP."
return 1
fi
log_info "Certificate ready: $CERTS_PATH/$domain.pem"
elif command -v certbot >/dev/null 2>&1; then elif command -v certbot >/dev/null 2>&1; then
certbot certonly --standalone -d "$domain" \ certbot certonly --standalone -d "$domain" \
--email "$email" --agree-tos -n \ --email "$email" --agree-tos -n \
--http-01-port $http_port $staging_flag --http-01-port "$http_port" $staging_flag || {
log_error "Certbot failed"
return 1
}
# Copy to HAProxy certs dir # Copy to HAProxy certs dir
local le_path="/etc/letsencrypt/live/$domain" local le_path="/etc/letsencrypt/live/$domain"
cat "$le_path/fullchain.pem" "$le_path/privkey.pem" > "$CERTS_PATH/$domain.pem" cat "$le_path/fullchain.pem" "$le_path/privkey.pem" > "$CERTS_PATH/$domain.pem"
else else
log_error "No ACME client found. Install acme.sh or certbot" log_error "No ACME client found. Install: opkg install acme acme-acmesh"
return 1 return 1
fi fi
chmod 600 "$CERTS_PATH/$domain.pem"
# Add to UCI # Add to UCI
uci set haproxy.cert_${domain//[.-]/_}=certificate local section="cert_$(echo "$domain" | tr '.-' '__')"
uci set haproxy.cert_${domain//[.-]/_}.domain="$domain" uci set haproxy.$section=certificate
uci set haproxy.cert_${domain//[.-]/_}.type="acme" uci set haproxy.$section.domain="$domain"
uci set haproxy.cert_${domain//[.-]/_}.enabled="1" uci set haproxy.$section.type="acme"
uci set haproxy.$section.enabled="1"
uci commit haproxy uci commit haproxy
log_info "Certificate installed for $domain" log_info "Certificate installed for $domain"
@ -818,8 +915,11 @@ cmd_reload() {
generate_config generate_config
log_info "Reloading HAProxy configuration..." log_info "Reloading HAProxy configuration..."
lxc_exec sh -c "echo 'reload' | socat stdio /var/run/haproxy.sock" || \ # HAProxy in master-worker mode (-W) reloads gracefully on SIGUSR2
lxc_exec killall -HUP haproxy # Fallback to SIGHUP if USR2 fails
lxc_exec killall -USR2 haproxy 2>/dev/null || \
lxc_exec killall -HUP haproxy 2>/dev/null || \
log_error "Could not signal HAProxy for reload"
log_info "Reload complete" log_info "Reload complete"
} }

View File

@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=secubox-app-hexojs PKG_NAME:=secubox-app-hexojs
PKG_VERSION:=1.0.0 PKG_VERSION:=1.0.0
PKG_RELEASE:=2 PKG_RELEASE:=6
PKG_ARCH:=all PKG_ARCH:=all
PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr> PKG_MAINTAINER:=CyberMind Studio <contact@cybermind.fr>

View File

@ -38,3 +38,7 @@ config theme_config 'theme'
option accent_color '#f97316' option accent_color '#f97316'
option logo_symbol '>' option logo_symbol '>'
option logo_text 'Blog_' option logo_text 'Blog_'
config portal 'portal'
option enabled '1'
option path '/www'

View File

@ -93,15 +93,16 @@ Content Commands:
new post "Title" Create new blog post new post "Title" Create new blog post
new page "Title" Create new page new page "Title" Create new page
new draft "Title" Create new draft new draft "Title" Create new draft
publish <slug> Publish a draft publish draft <slug> Publish a draft
list posts List all posts list posts List all posts
list drafts List all drafts list drafts List all drafts
Build Commands: Build Commands:
serve Start preview server (port $http_port) serve Start preview server (port $http_port)
build Generate static files build (generate) Generate static files
clean Clean generated files clean Clean generated files
deploy Deploy to configured target deploy Deploy to configured git target
publish Copy static files to /www/blog/
Service Commands: Service Commands:
service-run Run in foreground (for init) service-run Run in foreground (for init)
@ -293,7 +294,6 @@ lxc_run() {
# Ensure start script exists in container # Ensure start script exists in container
local start_script="$LXC_ROOTFS/opt/start-hexo.sh" local start_script="$LXC_ROOTFS/opt/start-hexo.sh"
if [ ! -f "$start_script" ]; then
cat > "$start_script" << 'STARTEOF' cat > "$start_script" << 'STARTEOF'
#!/bin/sh #!/bin/sh
export PATH=/usr/local/bin:/usr/bin:/bin:$PATH export PATH=/usr/local/bin:/usr/bin:/bin:$PATH
@ -302,11 +302,11 @@ export NODE_ENV=production
HEXO_PORT="${HEXO_PORT:-4000}" HEXO_PORT="${HEXO_PORT:-4000}"
SITE_DIR="/opt/hexojs/site" SITE_DIR="/opt/hexojs/site"
cd "$SITE_DIR" 2>/dev/null || exec tail -f /dev/null cd "$SITE_DIR" 2>/dev/null || exec tail -f /dev/null
[ -d "node_modules" ] || npm install
[ -d "$SITE_DIR/public" ] || hexo generate [ -d "$SITE_DIR/public" ] || hexo generate
exec hexo server -p "$HEXO_PORT" exec hexo server -p "$HEXO_PORT" -i 0.0.0.0
STARTEOF STARTEOF
chmod +x "$start_script" chmod +x "$start_script"
fi
log_info "Starting Hexo container on port $http_port..." log_info "Starting Hexo container on port $http_port..."
exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG" exec lxc-start -n "$LXC_NAME" -F -f "$LXC_CONFIG"
@ -668,14 +668,14 @@ cmd_new_draft() {
lxc_exec sh -c "cd /opt/hexojs/site && hexo new draft \"$title\"" lxc_exec sh -c "cd /opt/hexojs/site && hexo new draft \"$title\""
} }
cmd_publish() { cmd_publish_draft() {
require_root require_root
load_config load_config
local slug="$1" local slug="$1"
if [ -z "$slug" ]; then if [ -z "$slug" ]; then
log_error "Slug required" log_error "Slug required"
echo "Usage: hexoctl publish <slug>" echo "Usage: hexoctl publish draft <slug>"
return 1 return 1
fi fi
@ -762,7 +762,7 @@ cmd_serve() {
fi fi
log_info "Starting preview server on port $http_port..." log_info "Starting preview server on port $http_port..."
lxc_exec sh -c "cd /opt/hexojs/site && hexo server -p $http_port" lxc_exec sh -c "cd /opt/hexojs/site && hexo server -p $http_port -i 0.0.0.0"
} }
cmd_build() { cmd_build() {
@ -813,6 +813,64 @@ cmd_deploy() {
log_info "Deploy complete!" log_info "Deploy complete!"
} }
cmd_publish() {
require_root
load_config
local public_dir="$data_path/site/public"
local portal_path="/www"
local config_file="$data_path/site/_config.yml"
# Allow custom portal path from config
local custom_path=$(uci_get portal.path)
[ -n "$custom_path" ] && portal_path="$custom_path"
# Calculate web root from portal path (strip /www prefix)
local web_root="${portal_path#/www}"
[ -z "$web_root" ] && web_root="/"
# Ensure trailing slash
[ "${web_root%/}" = "$web_root" ] && web_root="$web_root/"
if ! lxc_running; then
log_error "Container not running"
return 1
fi
if [ ! -f "$config_file" ]; then
log_error "No Hexo config found at $config_file"
return 1
fi
log_info "Setting Hexo root to: $web_root"
# Update root in _config.yml (use sed to replace existing root line)
if grep -q "^root:" "$config_file"; then
sed -i "s|^root:.*|root: $web_root|" "$config_file"
else
# Add root config if not present
echo "root: $web_root" >> "$config_file"
fi
log_info "Regenerating static files for $web_root..."
lxc_exec sh -c "cd /opt/hexojs/site && hexo clean && hexo generate"
if [ ! -d "$public_dir" ]; then
log_error "Build failed - no public directory"
return 1
fi
log_info "Publishing to $portal_path..."
# Create portal directory
ensure_dir "$portal_path"
# Sync files
rsync -av --delete "$public_dir/" "$portal_path/"
log_info "Published $(find "$portal_path" -type f | wc -l) files to $portal_path"
log_info "Access at: http://$(uci -q get network.lan.ipaddr || echo 'router')$web_root"
}
cmd_logs() { cmd_logs() {
load_config load_config
@ -1074,8 +1132,6 @@ case "${1:-}" in
esac esac
;; ;;
publish) shift; cmd_publish "$@" ;;
list) list)
shift shift
case "${1:-}" in case "${1:-}" in
@ -1086,9 +1142,16 @@ case "${1:-}" in
;; ;;
serve) shift; cmd_serve "$@" ;; serve) shift; cmd_serve "$@" ;;
build) shift; cmd_build "$@" ;; build|generate) shift; cmd_build "$@" ;;
clean) shift; cmd_clean "$@" ;; clean) shift; cmd_clean "$@" ;;
deploy) shift; cmd_deploy "$@" ;; deploy) shift; cmd_deploy "$@" ;;
publish)
shift
case "${1:-}" in
draft) shift; cmd_publish_draft "$@" ;;
*) cmd_publish "$@" ;;
esac
;;
logs) shift; cmd_logs "$@" ;; logs) shift; cmd_logs "$@" ;;
shell) shift; cmd_shell "$@" ;; shell) shift; cmd_shell "$@" ;;

View File

@ -19,7 +19,7 @@ menu:
Home: / Home: /
Projects: /portfolio/ Projects: /portfolio/
Services: /services/ Services: /services/
Blog: /blog/ Blog: /categories/
Contact: /contact/ Contact: /contact/
sections: sections:

View File

@ -18,11 +18,11 @@ branding:
menu: menu:
Home: / Home: /
Blog: Blog:
_path: /blog/ _path: /categories/
Security: /blog/security/ Security: /security/
Linux: /blog/linux/ Linux: /linux/
Development: /blog/dev/ Development: /dev/
Tutorials: /blog/tutorials/ Tutorials: /tutorials/
Projects: /portfolio/ Projects: /portfolio/
About: /about/ About: /about/

View File

@ -33,13 +33,13 @@ branding:
menu: menu:
Accueil: / Accueil: /
Blog: Blog:
_path: /blog/ _path: /categories/
🛡️ Cybersécurité: /blog/cybersecurity/ 🛡️ Cybersécurité: /cybersecurity/
⚙️ Embarqué: /blog/embedded/ ⚙️ Embarqué: /embedded/
🐧 Linux: /blog/linux/ 🐧 Linux: /linux/
🎨 Créativité: /blog/creative/ 🎨 Créativité: /creative/
🧘 Philosophie: /blog/philosophy/ 🧘 Philosophie: /philosophy/
📖 Tutoriels: /blog/tutorials/ 📖 Tutoriels: /tutorials/
Apps: /apps/ Apps: /apps/
Services: /services/ Services: /services/
Portfolio: /portfolio/ Portfolio: /portfolio/

View File

@ -155,8 +155,8 @@ allApps.sort(function(a, b) {
<% } %> <% } %>
<div class="apps-nav"> <div class="apps-nav">
<a href="/" class="btn btn-secondary">← Accueil</a> <a href="<%= url_for('/') %>" class="btn btn-secondary">← Accueil</a>
<a href="/categories/" class="btn btn-secondary">Catégories</a> <a href="<%= url_for('categories/') %>" class="btn btn-secondary">Catégories</a>
</div> </div>
</section> </section>

View File

@ -68,7 +68,7 @@ if (typeof get_blog_categories === 'function') {
<% if (catDesc) { %><p class="cat-desc"><%= catDesc %></p><% } %> <% if (catDesc) { %><p class="cat-desc"><%= catDesc %></p><% } %>
<p class="cat-count"><%= postCount %> article<%= postCount > 1 ? 's' : '' %></p> <p class="cat-count"><%= postCount %> article<%= postCount > 1 ? 's' : '' %></p>
<nav class="breadcrumb"> <nav class="breadcrumb">
<a href="/">Accueil</a> / <a href="/blog/">Blog</a> / <span><%= catName %></span> <a href="<%= url_for('/') %>">Accueil</a> / <a href="<%= url_for('/categories/') %>">Blog</a> / <span><%= catName %></span>
</nav> </nav>
</header> </header>
@ -78,7 +78,7 @@ if (typeof get_blog_categories === 'function') {
<div class="res-group"> <div class="res-group">
<h3>🚀 Apps</h3> <h3>🚀 Apps</h3>
<% page.featured_apps.forEach(function(app) { %> <% page.featured_apps.forEach(function(app) { %>
<a href="/apps/<%= app %>/" class="res-link"><%= app %></a> <a href="<%= url_for('apps/' + app + '/') %>" class="res-link"><%= app %></a>
<% }); %> <% }); %>
</div> </div>
<% } %> <% } %>
@ -86,7 +86,7 @@ if (typeof get_blog_categories === 'function') {
<div class="res-group"> <div class="res-group">
<h3>🛡️ Services</h3> <h3>🛡️ Services</h3>
<% page.featured_services.forEach(function(svc) { %> <% page.featured_services.forEach(function(svc) { %>
<a href="/services/<%= svc %>/" class="res-link"><%= svc %></a> <a href="<%= url_for('services/' + svc + '/') %>" class="res-link"><%= svc %></a>
<% }); %> <% }); %>
</div> </div>
<% } %> <% } %>
@ -127,7 +127,7 @@ if (typeof get_blog_categories === 'function') {
<% } else { %> <% } else { %>
<div class="no-posts"> <div class="no-posts">
<p>🚧 Aucun article dans cette catégorie pour le moment.</p> <p>🚧 Aucun article dans cette catégorie pour le moment.</p>
<p><a href="/blog/">← Retour au blog</a></p> <p><a href="<%= url_for('/categories/') %>">← Retour au blog</a></p>
</div> </div>
<% } %> <% } %>
</section> </section>

View File

@ -478,7 +478,7 @@ if (typeof get_blog_categories === 'function') {
</div> </div>
<div class="section-footer"> <div class="section-footer">
<a href="/blog/" class="btn btn-secondary">Voir le blog →</a> <a href="<%= url_for('/categories/') %>" class="btn btn-secondary">Voir le blog →</a>
</div> </div>
</section> </section>
<% } %> <% } %>

View File

@ -83,7 +83,7 @@ if (page.tags && page.tags.length > 0) {
<header class="post-header"> <header class="post-header">
<% if (hasCategory) { %> <% if (hasCategory) { %>
<div class="post-category"> <div class="post-category">
<a href="/blog/<%= categorySlug %>/" style="color: <%= categoryColor %>"> <a href="<%= url_for('/' + categorySlug + '/') %>" style="color: <%= categoryColor %>">
<%= categoryIcon %> <%= categoryName %> <%= categoryIcon %> <%= categoryName %>
</a> </a>
</div> </div>

View File

@ -90,7 +90,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
<div class="hero-cta"> <div class="hero-cta">
<a href="#sites-clients" class="btn btn-primary">🏢 Sites Clients</a> <a href="#sites-clients" class="btn btn-primary">🏢 Sites Clients</a>
<a href="#applications" class="btn btn-secondary">📱 Applications</a> <a href="#applications" class="btn btn-secondary">📱 Applications</a>
<a href="/contact/" class="btn btn-secondary">💬 Contact</a> <a href="<%= url_for('contact/') %>" class="btn btn-secondary">💬 Contact</a>
</div> </div>
</div> </div>
</section> </section>
@ -159,7 +159,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div> </div>
<div class="section-footer"> <div class="section-footer">
<a href="/portfolio/clients/" class="btn btn-secondary">Voir tous les sites clients →</a> <a href="<%= url_for('portfolio/clients/') %>" class="btn btn-secondary">Voir tous les sites clients →</a>
</div> </div>
</section> </section>
@ -225,7 +225,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div> </div>
<div class="section-footer"> <div class="section-footer">
<a href="/portfolio/projects/" class="btn btn-secondary">Tous les projets →</a> <a href="<%= url_for('portfolio/projects/') %>" class="btn btn-secondary">Tous les projets →</a>
</div> </div>
</section> </section>
@ -262,7 +262,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div> </div>
<div class="section-footer"> <div class="section-footer">
<a href="/apps/" class="btn btn-secondary">Toutes les applications →</a> <a href="<%= url_for('apps/') %>" class="btn btn-secondary">Toutes les applications →</a>
</div> </div>
</section> </section>
@ -314,22 +314,22 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div> </div>
<div class="services-showcase-grid"> <div class="services-showcase-grid">
<a href="/services/pentest/" class="service-showcase-card"> <a href="<%= url_for('services/pentest/') %>" class="service-showcase-card">
<span class="service-icon">🛡️</span> <span class="service-icon">🛡️</span>
<h3>Pentest & Sécurité</h3> <h3>Pentest & Sécurité</h3>
<p>Audits, tests d'intrusion</p> <p>Audits, tests d'intrusion</p>
</a> </a>
<a href="/services/dev/" class="service-showcase-card"> <a href="<%= url_for('services/dev/') %>" class="service-showcase-card">
<span class="service-icon">⚙️</span> <span class="service-icon">⚙️</span>
<h3>Développement</h3> <h3>Développement</h3>
<p>Embarqué, Linux, IoT</p> <p>Embarqué, Linux, IoT</p>
</a> </a>
<a href="/services/formation/" class="service-showcase-card"> <a href="<%= url_for('services/formation/') %>" class="service-showcase-card">
<span class="service-icon">🎓</span> <span class="service-icon">🎓</span>
<h3>Formation</h3> <h3>Formation</h3>
<p>Cybersécurité, Creative Thinking</p> <p>Cybersécurité, Creative Thinking</p>
</a> </a>
<a href="/services/gk2net/" class="service-showcase-card"> <a href="<%= url_for('services/gk2net/') %>" class="service-showcase-card">
<span class="service-icon">🌐</span> <span class="service-icon">🌐</span>
<h3>Création Web</h3> <h3>Création Web</h3>
<p>Sites & Hébergement</p> <p>Sites & Hébergement</p>
@ -337,7 +337,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
</div> </div>
<div class="section-footer"> <div class="section-footer">
<a href="/services/" class="btn btn-primary">Découvrir tous les services →</a> <a href="<%= url_for('services/') %>" class="btn btn-primary">Découvrir tous les services →</a>
</div> </div>
</section> </section>
@ -347,7 +347,7 @@ var opensource = portfolio.filter(function(p) { return p.type === 'opensource';
<h2>💬 Un projet en tête ?</h2> <h2>💬 Un projet en tête ?</h2>
<p>Discutons de vos besoins et trouvons la meilleure solution ensemble.</p> <p>Discutons de vos besoins et trouvons la meilleure solution ensemble.</p>
<div class="cta-buttons"> <div class="cta-buttons">
<a href="/contact/" class="btn btn-primary btn-large">Demander un devis</a> <a href="<%= url_for('contact/') %>" class="btn btn-primary btn-large">Demander un devis</a>
<a href="mailto:contact@cybermind.fr" class="btn btn-secondary btn-large">📧 contact@cybermind.fr</a> <a href="mailto:contact@cybermind.fr" class="btn btn-secondary btn-large">📧 contact@cybermind.fr</a>
</div> </div>
</div> </div>

View File

@ -126,7 +126,7 @@ function scanCategories(hexo) {
color: DEFAULT_COLORS[orderIndex % DEFAULT_COLORS.length], color: DEFAULT_COLORS[orderIndex % DEFAULT_COLORS.length],
description: '', description: '',
order: 100 + orderIndex, order: 100 + orderIndex,
path: `/blog/${slug}/` path: `/${slug}/`
}; };
// Lire les métadonnées depuis index.md si présent // Lire les métadonnées depuis index.md si présent
@ -360,7 +360,7 @@ hexo.extend.helper.register('get_dynamic_menu', function() {
const blogMenu = { const blogMenu = {
name: 'Blog', name: 'Blog',
icon: '📚', icon: '📚',
path: '/blog/', path: '/categories/',
children: categories.map(cat => ({ children: categories.map(cat => ({
name: cat.name, name: cat.name,
icon: cat.icon, icon: cat.icon,

View File

@ -18,10 +18,10 @@ config cms 'cms'
config hexo 'hexo' config hexo 'hexo'
option source_path '/srv/hexojs/site/source/_posts' option source_path '/srv/hexojs/site/source/_posts'
option public_path '/srv/hexojs/site/public' option public_path '/srv/hexojs/site/public'
option portal_path '/www/blog' option portal_path '/www'
option auto_publish '1' option auto_publish '1'
config portal 'portal' config portal 'portal'
option enabled '1' option enabled '1'
option url_path '/blog' option url_path '/'
option title 'SecuBox Blog' option title 'SecuBox Blog'