feat: Service-Editor mit vollem CRUD und Server-Speichern
All checks were successful
Build & Push Docker Image to Gitea Registry / build-and-push (push) Successful in 16s

This commit is contained in:
Kroonk
2026-05-22 00:43:25 +02:00
parent 072fc9b9f9
commit fb3c7a99f1
4 changed files with 304 additions and 39 deletions

View File

@@ -12,7 +12,7 @@
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png"> <link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48-v4.png">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png"> <link rel="icon" type="image/png" sizes="192x192" href="/icons/favicon-192-v4.png">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png"> <link rel="apple-touch-icon" href="/icons/apple-touch-icon-v4.png">
<link rel="stylesheet" href="/style.css?v=34"> <link rel="stylesheet" href="/style.css?v=35">
</head> </head>
<body> <body>
<header class="shell hero admin-hero"> <header class="shell hero admin-hero">
@@ -147,13 +147,15 @@
<div class="ops-card-head"> <div class="ops-card-head">
<span>Dienste &amp; Kategorien (Konfiguration)</span> <span>Dienste &amp; Kategorien (Konfiguration)</span>
</div> </div>
<div class="admin-actions-bar"> <div class="admin-actions-bar" style="display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px;">
<button class="ghost-button mini" id="checkAllButton" type="button">Alle Status pruefen</button> <button class="ghost-button mini" id="checkAllButton" type="button">Alle Status pruefen</button>
<button class="ghost-button mini" id="saveLocalButton" type="button">Lokal speichern</button> <button class="ghost-button mini" id="addServiceButton" type="button">Neuen Dienst hinzufügen</button>
<button class="ghost-button mini" id="saveServerButton" type="button" style="background: rgba(56, 189, 248, 0.12); border-color: rgba(56, 189, 248, 0.35); color: #38bdf8;">Auf Server speichern</button>
<button class="ghost-button mini" id="resetLocalButton" type="button">Aenderungen verwerfen</button> <button class="ghost-button mini" id="resetLocalButton" type="button">Aenderungen verwerfen</button>
<button class="ghost-button mini" id="downloadConfigButton" type="button">JSON exportieren</button> <button class="ghost-button mini" id="downloadConfigButton" type="button">JSON exportieren</button>
</div> </div>
<div class="admin-table" id="serviceEditor"></div> <div id="editorFeedback" class="notice" style="display: none; margin-bottom: 16px;"></div>
<div class="admin-table" id="serviceEditor" style="display: flex; flex-direction: column; gap: 16px;"></div>
</section> </section>
</section> </section>
</main> </main>
@@ -242,6 +244,6 @@
</div> </div>
</div> </div>
<script src="/admin.js?v=30" defer></script> <script src="/admin.js?v=31" defer></script>
</body> </body>
</html> </html>

242
admin.js
View File

@@ -17,7 +17,9 @@ const loginMessage = document.querySelector('#loginMessage');
const adminUser = document.querySelector('#adminUser'); const adminUser = document.querySelector('#adminUser');
const serviceEditor = document.querySelector('#serviceEditor'); const serviceEditor = document.querySelector('#serviceEditor');
const checkAllButton = document.querySelector('#checkAllButton'); const checkAllButton = document.querySelector('#checkAllButton');
const saveLocalButton = document.querySelector('#saveLocalButton'); const addServiceButton = document.querySelector('#addServiceButton');
const saveServerButton = document.querySelector('#saveServerButton');
const editorFeedback = document.querySelector('#editorFeedback');
const resetLocalButton = document.querySelector('#resetLocalButton'); const resetLocalButton = document.querySelector('#resetLocalButton');
const downloadConfigButton = document.querySelector('#downloadConfigButton'); const downloadConfigButton = document.querySelector('#downloadConfigButton');
const refreshOpsButton = document.querySelector('#refreshOpsButton'); const refreshOpsButton = document.querySelector('#refreshOpsButton');
@@ -193,41 +195,128 @@ function categoryOptions(selected) {
)).join(''); )).join('');
} }
function iconOptions(selected) {
const icons = ['shield', 'folder', 'lock', 'film', 'headphones', 'book', 'git', 'chess', 'user'];
return icons.map((icon) => (
`<option value="${icon}" ${icon === selected ? 'selected' : ''}>${icon}</option>`
)).join('');
}
function renderEditor() { function renderEditor() {
serviceEditor.innerHTML = config.services.map((service, index) => ` serviceEditor.innerHTML = config.services.map((service, index) => `
<article class="admin-row" data-index="${index}"> <article class="admin-row" data-index="${index}">
<div> <!-- Group 1: Identitaet & Kategorie -->
<strong>${service.name}</strong> <div class="admin-form-group-row">
<span>${service.domain}</span> <label style="flex: 1 1 120px;">
Dienst ID (Eindeutig)
<input data-field="id" value="${service.id || ''}" placeholder="z.B. passwort-manager">
</label>
<label style="flex: 2 2 180px;">
Anzeigename
<input data-field="name" value="${service.name || ''}" placeholder="z.B. Passwords">
</label>
<label style="flex: 2 2 200px;">
Subdomain / Domain
<input data-field="domain" value="${service.domain || ''}" placeholder="z.B. password.mischlabs.de">
</label>
<label style="flex: 1.5 1.5 150px;">
Kategorie
<select data-field="category">${categoryOptions(service.category)}</select>
</label>
</div>
<!-- Group 2: URLs, Aesthetics & Icon -->
<div class="admin-form-group-row">
<label style="flex: 3 3 250px;">
Dienst-URL (Zieladresse)
<input data-field="url" value="${service.url || ''}" placeholder="z.B. https://password.mischlabs.de">
</label>
<label style="flex: 2 2 180px;">
Mobile URL Scheme (Optional)
<input data-field="mobileUrl" value="${service.mobileUrl || ''}" placeholder="z.B. bitwarden://">
</label>
<label style="flex: 1.5 1.5 140px;">
Accent-Farbe
<div style="display: flex; gap: 6px; align-items: center;">
<input type="color" class="accent-color-picker" value="${service.accent || '#38bdf8'}" style="width: 42px; min-width: 42px; height: 38px; padding: 2px; cursor: pointer; border: 1px solid var(--border); border-radius: var(--radius); background: rgba(17,22,36,0.92);">
<input data-field="accent" value="${service.accent || '#38bdf8'}" placeholder="#ffffff" style="font-family: monospace;">
</div>
</label>
<label style="flex: 1.2 1.2 120px;">
Icon
<select data-field="icon">${iconOptions(service.icon)}</select>
</label>
</div>
<!-- Group 3: Description, Keywords & Actions -->
<div class="admin-form-group-row">
<label style="flex: 2 2 250px;">
Beschreibung
<input data-field="description" value="${service.description || ''}" placeholder="Kurze Dienstbeschreibung...">
</label>
<label style="flex: 2 2 250px;">
Keywords (Suche)
<input data-field="keywords" value="${service.keywords || ''}" placeholder="Schluesselwoerter (durch Leerzeichen getrennt)...">
</label>
</div>
<div class="admin-row-actions">
<button class="ghost-button check-one mini" type="button">Verbindung Pruefen</button>
<button class="ghost-button danger-button delete-one mini" type="button" style="border-color: rgba(251, 113, 133, 0.3); color: #fb7185;">Loeschen</button>
</div> </div>
<label>
Kategorie
<select data-field="category">${categoryOptions(service.category)}</select>
</label>
<label>
URL
<input data-field="url" value="${service.url}">
</label>
<label>
Mobile URL (App Scheme)
<input data-field="mobileUrl" value="${service.mobileUrl || ''}" placeholder="z.B. nextcloud://">
</label>
<button class="ghost-button check-one" type="button">Pruefen</button>
<div class="diagnostic">Noch nicht geprueft.</div> <div class="diagnostic">Noch nicht geprueft.</div>
</article> </article>
`).join(''); `).join('');
serviceEditor.querySelectorAll('select,input').forEach((input) => { // Wire up two-way sync for color pickers and text inputs, and event listeners
input.addEventListener('change', () => { serviceEditor.querySelectorAll('.admin-row').forEach((row) => {
const row = input.closest('.admin-row'); const index = Number(row.dataset.index);
const service = config.services[Number(row.dataset.index)]; const service = config.services[index];
service[input.dataset.field] = input.value;
// Wire standard input changes
row.querySelectorAll('select, input:not(.accent-color-picker)').forEach((input) => {
input.addEventListener('input', () => {
service[input.dataset.field] = input.value;
});
input.addEventListener('change', () => {
service[input.dataset.field] = input.value;
});
}); });
// Wire color picker sync
const textInput = row.querySelector('input[data-field="accent"]');
const colorPicker = row.querySelector('.accent-color-picker');
if (textInput && colorPicker) {
colorPicker.addEventListener('input', () => {
textInput.value = colorPicker.value;
service.accent = colorPicker.value;
});
textInput.addEventListener('input', () => {
const val = textInput.value;
if (/^#[0-9A-Fa-f]{6}$/.test(val)) {
colorPicker.value = val;
}
service.accent = val;
});
}
}); });
serviceEditor.querySelectorAll('.check-one').forEach((button) => { serviceEditor.querySelectorAll('.check-one').forEach((button) => {
button.addEventListener('click', () => checkRow(button.closest('.admin-row'))); button.addEventListener('click', () => checkRow(button.closest('.admin-row')));
}); });
serviceEditor.querySelectorAll('.delete-one').forEach((button) => {
button.addEventListener('click', () => {
const row = button.closest('.admin-row');
const index = Number(row.dataset.index);
const service = config.services[index];
const confirmed = window.confirm(`Dienst "${service.name || service.id || 'unbekannt'}" wirklich loeschen?`);
if (!confirmed) return;
config.services.splice(index, 1);
renderEditor();
});
});
} }
async function probe(url) { async function probe(url) {
@@ -282,8 +371,102 @@ async function checkRow(row) {
diagnostic.textContent = `${label} - ${result.ms} ms - ${result.detail}`; diagnostic.textContent = `${label} - ${result.ms} ms - ${result.detail}`;
} }
function saveLocal() { function addService() {
localStorage.setItem(LOCAL_CONFIG_KEY, JSON.stringify(config, null, 2)); const newService = {
id: `dienst-${Date.now()}`,
name: 'Neuer Dienst',
description: 'Beschreibung des Dienstes',
domain: 'neu.mischlabs.de',
url: 'https://neu.mischlabs.de',
category: config.categories[0]?.id || 'access',
accent: '#38bdf8',
icon: 'shield',
keywords: 'neu dienst'
};
config.services.push(newService);
renderEditor();
// Scroll new row into view smoothly
const rows = serviceEditor.querySelectorAll('.admin-row');
const lastRow = rows[rows.length - 1];
if (lastRow) {
lastRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
lastRow.classList.add('is-new');
}
}
function showFeedback(type, message) {
if (!editorFeedback) return;
editorFeedback.style.display = 'block';
editorFeedback.textContent = message;
if (type === 'success') {
editorFeedback.style.background = 'rgba(52, 211, 153, 0.12)';
editorFeedback.style.borderColor = 'rgba(52, 211, 153, 0.35)';
editorFeedback.style.color = '#34d399';
} else {
editorFeedback.style.background = 'rgba(251, 113, 133, 0.12)';
editorFeedback.style.borderColor = 'rgba(251, 113, 133, 0.35)';
editorFeedback.style.color = '#fb7185';
}
if (type === 'success') {
setTimeout(() => {
editorFeedback.style.display = 'none';
}, 5000);
}
}
async function saveServer() {
if (!tokenSet?.id_token) {
showFeedback('error', 'Nicht angemeldet oder Sitzung abgelaufen.');
return;
}
if (!config || !Array.isArray(config.services) || !Array.isArray(config.categories)) {
showFeedback('error', 'Ungueltiges Konfigurationsformat.');
return;
}
for (const service of config.services) {
if (!service.id || !service.name || !service.domain || !service.url || !service.category) {
showFeedback('error', 'Bitte füllen Sie ID, Name, Domain, URL und Kategorie für alle Dienste aus.');
return;
}
}
const originalText = saveServerButton.textContent;
saveServerButton.disabled = true;
saveServerButton.textContent = 'Speichere...';
try {
const response = await fetch('/api/services/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${tokenSet.id_token}`
},
body: JSON.stringify(config)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.error || `HTTP ${response.status}`);
}
showFeedback('success', 'Konfiguration erfolgreich auf dem Server gespeichert!');
localStorage.removeItem(LOCAL_CONFIG_KEY);
window.setTimeout(() => {
refreshOps();
}, 500);
} catch (error) {
showFeedback('error', `Speichern fehlgeschlagen: ${error.message}`);
} finally {
saveServerButton.disabled = false;
saveServerButton.textContent = originalText;
}
} }
function downloadConfig() { function downloadConfig() {
@@ -1278,10 +1461,13 @@ checkAllButton.addEventListener('click', () => {
[...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row)); [...serviceEditor.querySelectorAll('.admin-row')].forEach((row) => checkRow(row));
}); });
saveLocalButton.addEventListener('click', () => { if (addServiceButton) {
saveLocal(); addServiceButton.addEventListener('click', addService);
window.alert('Lokal gespeichert. Das Dashboard in diesem Browser nutzt diese Aenderungen.'); }
});
if (saveServerButton) {
saveServerButton.addEventListener('click', saveServer);
}
resetLocalButton.addEventListener('click', () => { resetLocalButton.addEventListener('click', () => {
localStorage.removeItem(LOCAL_CONFIG_KEY); localStorage.removeItem(LOCAL_CONFIG_KEY);

View File

@@ -1218,6 +1218,43 @@ const server = http.createServer(async (req, res) => {
return; return;
} }
if (req.url?.startsWith('/api/services/save')) {
if (req.method !== 'POST') {
json(res, 405, { error: 'Method not allowed' });
return;
}
try {
await verifyAdminRequest(req);
const body = await new Promise((resolve, reject) => {
let data = '';
req.on('data', chunk => data += chunk);
req.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Invalid JSON'));
}
});
req.on('error', reject);
});
if (!body || !Array.isArray(body.categories) || !Array.isArray(body.services)) {
json(res, 400, { error: 'Invalid configuration format' });
return;
}
const servicesPath = path.join(PUBLIC_DIR, 'services.json');
await fsp.writeFile(servicesPath, JSON.stringify(body, null, 2), 'utf-8');
json(res, 200, { success: true, message: 'Configuration saved successfully' });
} catch (error) {
json(res, error.statusCode || 500, { error: error.message });
}
return;
}
if (req.url?.startsWith('/api/disks/scan-folder')) { if (req.url?.startsWith('/api/disks/scan-folder')) {
if (req.method !== 'GET') { if (req.method !== 'GET') {
json(res, 405, { error: 'Method not allowed' }); json(res, 405, { error: 'Method not allowed' });

View File

@@ -1016,14 +1016,15 @@ main {
} }
.admin-row { .admin-row {
display: grid; display: flex;
grid-template-columns: minmax(140px, 1fr) minmax(120px, 0.6fr) minmax(180px, 1.1fr) minmax(130px, 0.9fr) auto; flex-direction: column;
gap: 10px; gap: 12px;
align-items: end; padding: 16px;
padding: 12px; background: rgba(17, 22, 36, 0.42);
background: rgba(7, 9, 20, 0.42);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
margin-bottom: 16px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
} }
.admin-row > div:first-child { .admin-row > div:first-child {
@@ -1945,3 +1946,42 @@ main {
background: rgba(255, 255, 255, 0.18) !important; background: rgba(255, 255, 255, 0.18) !important;
transform: scale(1.15); transform: scale(1.15);
} }
/* Service Editor Advanced Styling */
.admin-form-group-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.admin-form-group-row > label {
flex: 1 1 200px;
display: grid;
gap: 6px;
}
.admin-row-actions {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
margin-top: 4px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.admin-row.is-new {
animation: new-row-pulse 2s ease;
}
@keyframes new-row-pulse {
0% {
border-color: #38bdf8;
box-shadow: 0 0 12px rgba(56, 189, 248, 0.4);
}
100% {
border-color: var(--border);
box-shadow: none;
}
}