Files
zmb-webui/backend/static/index.html
Claude Code 6d74d874b6 ZMB Webui: Complete Project – Rebrand & Initial Clean Commit
ARCHITECTURE
============
Backend: FastAPI + uvicorn (port 8000)
  - JWT authentication with PAM system users
  - ZFS CLI wrapper with caching (30-60s TTL)
  - WebSocket pool status broadcaster (30s interval)
  - Services: auth, zfs_runner, file_manager, shares, identities, system_info
  - Routers: pools, datasets, snapshots, shares, identities, navigator, system

Frontend: Next.js 15 + TypeScript (static export)
  - Incremental Static Regeneration (ISR) for weak hardware
  - Type-safe API client (lib/api.ts)
  - Dark mode + custom Tailwind theme
  - Pages: Dashboard, Login, Snapshots, Datasets, Shares, etc.

DEPLOYMENT
==========
Test Target: 192.168.1.179:8090 (Debian LXC)
Production: 10.66.120.3:9090 (Raspberry Pi 4GB ARM64)
Updater: Automated Gitea-based deployment (update-test.sh, update-pi.sh)

FEATURES COMPLETED
==================
Phase 3a: Dashboard Quick Stats (System, CPU, Memory, Storage)
  - Real-time stats with color-coded progress bars
  - Responsive grid layout (mobile: 1, tablet: 2, desktop: 4 columns)
  - ISR-optimized for fast loads on weak hardware

REBRANDING
==========
Renamed throughout:
  - Project: 'ZFS Manager' → 'ZMB Webui'
  - Services: 'zfs-manager' → 'zmb-webui'
  - Systemd units: zfs-manager-backend → zmb-webui-backend
  - Configuration files and documentation

Co-Authored-By: Patrick <patrick@perlbach24.de>
2026-04-22 00:43:05 +02:00

1374 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="de" class="pf-v6-theme-dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ZFS Manager</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #1b1b1f;
--bg-dark: #0f0f11;
--border: #3e3e42;
--text: #e0e0e0;
--text-muted: #9a9a9a;
--primary: #0066ff;
--primary-hover: #0052cc;
--success: #4caf50;
--warning: #ff9800;
--danger: #f44336;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
.container {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 250px;
background: var(--bg-dark);
border-right: 1px solid var(--border);
overflow-y: auto;
position: fixed;
height: 100vh;
left: 0;
top: 0;
}
.sidebar-header {
padding: 16px;
border-bottom: 1px solid var(--border);
font-size: 16px;
font-weight: bold;
}
.sidebar-nav {
list-style: none;
}
.sidebar-nav a {
display: block;
padding: 12px 16px;
color: var(--text-muted);
text-decoration: none;
border-left: 3px solid transparent;
transition: all 0.2s;
cursor: pointer;
}
.sidebar-nav a:hover,
.sidebar-nav a.active {
color: var(--text);
background: rgba(0, 102, 255, 0.1);
border-left-color: var(--primary);
}
/* Main content */
.main {
margin-left: 250px;
flex: 1;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: var(--bg-dark);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-title {
font-size: 16px;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* Content */
.content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* Cards */
.card {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
margin-bottom: 16px;
}
.card-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--text);
}
/* Tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
gap: 0;
}
.tab {
padding: 12px 16px;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--text-muted);
transition: all 0.2s;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--primary);
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
th {
background: var(--bg-dark);
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
font-weight: 600;
color: var(--text-muted);
}
td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
tr:hover {
background: rgba(0, 102, 255, 0.05);
}
/* Buttons */
button, .btn {
padding: 8px 12px;
border: 1px solid var(--border);
background: transparent;
color: var(--text);
border-radius: 3px;
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
button:hover, .btn:hover {
background: rgba(0, 102, 255, 0.15);
border-color: var(--primary);
}
button.primary, .btn.primary {
background: var(--primary);
border-color: var(--primary);
color: white;
}
button.primary:hover, .btn.primary:hover {
background: var(--primary-hover);
}
button.danger {
color: var(--danger);
}
button.danger:hover {
background: rgba(244, 67, 54, 0.15);
border-color: var(--danger);
}
button.secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
button.secondary:hover {
background: rgba(255, 255, 255, 0.05);
border-color: var(--primary);
}
/* Status badges */
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
}
.badge.success { background: rgba(76, 175, 80, 0.2); color: #4caf50; }
.badge.warning { background: rgba(255, 152, 0, 0.2); color: #ff9800; }
.badge.danger { background: rgba(244, 67, 54, 0.2); color: #f44336; }
/* Loading */
.loading {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Dialog */
.dialog-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.dialog-overlay.active {
display: flex;
}
.dialog-box {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 4px;
padding: 20px;
width: 90%;
max-width: 500px;
}
.dialog-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 16px;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 12px;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-muted);
}
.form-group input {
width: 100%;
padding: 8px;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 3px;
font-size: 13px;
}
.form-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.dialog-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
/* Input */
input, textarea {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 8px;
border-radius: 3px;
font-size: 13px;
width: 100%;
}
input:focus, textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
/* Helpers */
.text-muted { color: var(--text-muted); }
.text-center { text-align: center; }
.mt-1 { margin-top: 8px; }
.mt-2 { margin-top: 16px; }
.mb-1 { margin-bottom: 8px; }
.mb-2 { margin-bottom: 16px; }
.gap { display: flex; gap: 8px; align-items: center; }
/* Login page */
.login-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
background: var(--bg);
}
.login-box {
background: var(--bg-dark);
border: 1px solid var(--border);
padding: 32px;
border-radius: 4px;
width: 100%;
max-width: 400px;
}
.login-box h1 {
font-size: 24px;
margin-bottom: 24px;
text-align: center;
}
.login-box .form-group {
margin-bottom: 16px;
}
.login-box label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 13px;
}
</style>
</head>
<body class="pf-v6-m-tabular-nums">
<div id="app"></div>
<script>
const API_URL = '';
const app = document.getElementById('app');
// State
let state = {
page: 'dashboard',
user: localStorage.getItem('username'),
token: localStorage.getItem('access_token'),
pools: [],
loading: true,
error: null,
zfsAvailable: false,
sambaShares: [],
nfsShares: [],
sambaConfig: '',
sharesTab: 'samba',
showCreateDialog: false,
createType: 'samba',
// Navigator state
navigatorPath: '',
navigatorFiles: [],
navigatorParent: null,
spaceInfo: {},
// Identities state
users: [],
groups: [],
loginHistory: [],
identitiesTab: 'users',
// Permissions dialog state
permissionsDialog: null,
// For permission dropdowns
availableUsers: [],
availableGroups: []
};
// API calls
async function apiCall(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (state.token) {
headers['Authorization'] = `Bearer ${state.token}`;
}
const response = await fetch(`${API_URL}/api${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
logout();
return null;
}
return response.json();
}
async function login(username, password) {
const data = await apiCall('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password })
});
if (data && data.access_token) {
state.token = data.access_token;
localStorage.setItem('access_token', state.token);
state.user = username;
localStorage.setItem('username', username);
loadDashboard();
return true;
}
return false;
}
function logout() {
localStorage.removeItem('access_token');
localStorage.removeItem('username');
state.token = null;
state.user = null;
render();
}
async function checkZfsAvailability() {
try {
const statusData = await apiCall('/status');
state.zfsAvailable = statusData && statusData.zfs_available;
} catch (e) {
state.zfsAvailable = false;
}
}
async function loadDashboard() {
state.page = 'dashboard';
state.loading = true;
await checkZfsAvailability();
if (state.zfsAvailable) {
const pools = await apiCall('/pools');
state.pools = pools || [];
} else {
state.pools = [];
}
state.loading = false;
render();
}
async function loadShares() {
state.page = 'shares';
state.loading = true;
try {
const [samba, nfs, config] = await Promise.all([
apiCall('/shares/samba'),
apiCall('/shares/nfs'),
apiCall('/shares/samba/config')
]);
state.sambaShares = samba?.shares || [];
state.nfsShares = nfs?.shares || [];
state.sambaConfig = config?.raw || '';
} catch (e) {
console.error('Failed to load shares:', e);
state.sambaShares = [];
state.nfsShares = [];
state.sambaConfig = '';
}
state.loading = false;
render();
}
async function createSambaShare(name, path, comment) {
try {
await apiCall('/shares/samba', {
method: 'POST',
body: JSON.stringify({ name, path, comment: comment || null })
});
loadShares();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function deleteSambaShare(name) {
if (!confirm(`Share "${name}" löschen?`)) return;
try {
await apiCall(`/shares/samba/${name}`, { method: 'DELETE' });
loadShares();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createNfsShare(path, clients, options) {
try {
await apiCall('/shares/nfs', {
method: 'POST',
body: JSON.stringify({ path, clients, options: options || null })
});
loadShares();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function deleteNfsShare(path) {
if (!confirm(`Share "${path}" löschen?`)) return;
try {
await apiCall(`/shares/nfs?path=${encodeURIComponent(path)}`, { method: 'DELETE' });
loadShares();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function saveSambaConfig() {
try {
const textarea = document.getElementById('samba-config-text');
const config = textarea.value;
await apiCall('/shares/samba/config', {
method: 'PUT',
body: JSON.stringify({ config })
});
alert('Konfiguration gespeichert');
loadShares();
} catch (e) {
alert('Fehler beim Speichern: ' + e);
}
}
async function importSambaConfig() {
const configFile = prompt('Konfigurationsdatei eingeben:', '/etc/samba/import.template');
if (!configFile) return;
try {
await apiCall('/shares/samba/config/import', {
method: 'POST',
body: JSON.stringify({ config_file: configFile })
});
alert('Konfiguration importiert');
loadShares();
} catch (e) {
alert('Fehler beim Import: ' + e);
}
}
// Navigator (File Manager) functions
async function loadNavigator(path = '') {
state.page = 'navigator';
state.navigatorPath = path;
state.loading = true;
try {
const [files, space] = await Promise.all([
apiCall(`/files/browse?path=${encodeURIComponent(path)}`),
apiCall('/files/space')
]);
state.navigatorFiles = files?.entries || [];
state.spaceInfo = space || {};
// Extract parent directory
if (path) {
state.navigatorParent = path.substring(0, path.lastIndexOf('/')) || '';
} else {
state.navigatorParent = null;
}
} catch (e) {
console.error('Failed to load navigator:', e);
state.navigatorFiles = [];
state.spaceInfo = {};
}
state.loading = false;
render();
}
async function navigateToPath(path) {
await loadNavigator(path);
}
async function deleteFileOrDir(path) {
if (!confirm(`Löschen: ${path}?`)) return;
try {
const isDir = state.navigatorFiles.find(f => f.path === path && f.is_dir);
await apiCall(`/files/delete?path=${encodeURIComponent(path)}&recursive=${isDir ? 'true' : 'false'}`, {
method: 'DELETE'
});
loadNavigator(state.navigatorPath);
} catch (e) {
alert('Fehler: ' + e);
}
}
async function downloadFile(path) {
const token = state.token;
window.open(`/api/files/download?path=${encodeURIComponent(path)}&access_token=${token}`, '_blank');
}
async function createNewFolder() {
const name = prompt('Ordnername:');
if (!name) return;
try {
const newPath = state.navigatorPath ? `${state.navigatorPath}/${name}` : name;
await apiCall('/files/mkdir', {
method: 'POST',
body: JSON.stringify({ path: newPath })
});
loadNavigator(state.navigatorPath);
} catch (e) {
alert('Fehler: ' + e);
}
}
async function uploadFiles() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = async (e) => {
const files = e.target.files;
for (let file of files) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`/api/files/upload?path=${encodeURIComponent(state.navigatorPath)}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${state.token}` },
body: formData
});
if (!response.ok) throw new Error(await response.text());
} catch (e) {
alert(`Fehler bei ${file.name}: ${e}`);
}
}
loadNavigator(state.navigatorPath);
};
input.click();
}
async function openPermissionsDialog(path) {
const file = state.navigatorFiles.find(f => f.path === path);
if (!file) return;
// Load available users and groups if not already loaded
if (!state.availableUsers.length) {
try {
const users = await apiCall('/identities/users');
const groups = await apiCall('/identities/groups');
state.availableUsers = users?.users || [];
state.availableGroups = groups?.groups || [];
} catch (e) {
console.error('Error loading users/groups:', e);
}
}
state.permissionsDialog = {
path: path,
name: file.name,
currentMode: file.permissions,
owner: file.uid.toString(),
group: file.gid.toString(),
newOwner: null,
newGroup: null,
ownerChanged: false,
groupChanged: false,
recursive: false
};
render();
}
async function savePermissions() {
try {
const mode = state.permissionsDialog.currentMode.slice(-3); // Get last 3 chars (e.g. "755")
await apiCall('/files/permissions', {
method: 'POST',
body: JSON.stringify({
path: state.permissionsDialog.path,
mode: mode,
recursive: state.permissionsDialog.recursive
})
});
// Also save owner/group if changed
if (state.permissionsDialog.ownerChanged || state.permissionsDialog.groupChanged) {
const owner = state.permissionsDialog.ownerChanged ? state.permissionsDialog.newOwner : null;
const group = state.permissionsDialog.groupChanged ? state.permissionsDialog.newGroup : null;
if (owner || group) {
await apiCall('/files/owner', {
method: 'POST',
body: JSON.stringify({
path: state.permissionsDialog.path,
owner: owner || state.permissionsDialog.owner,
group: group || null
})
});
}
}
state.permissionsDialog = null;
loadNavigator(state.navigatorPath);
} catch (e) {
alert('Fehler beim Speichern: ' + e);
}
}
function closePermissionsDialog() {
state.permissionsDialog = null;
render();
}
// Identities (Users & Groups) functions
async function loadIdentities() {
state.page = 'identities';
state.loading = true;
try {
const [users, groups, logins] = await Promise.all([
apiCall('/identities/users'),
apiCall('/identities/groups'),
apiCall('/identities/login-history?limit=30')
]);
state.users = users?.users || [];
state.groups = groups?.groups || [];
state.loginHistory = logins?.logins || [];
} catch (e) {
console.error('Failed to load identities:', e);
state.users = [];
state.groups = [];
state.loginHistory = [];
}
state.loading = false;
render();
}
async function deleteUser(username) {
if (!confirm(`Benutzer "${username}" löschen?`)) return;
try {
await apiCall(`/identities/users/${username}`, { method: 'DELETE' });
loadIdentities();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function deleteGroup(groupname) {
if (!confirm(`Gruppe "${groupname}" löschen?`)) return;
try {
await apiCall(`/identities/groups/${groupname}`, { method: 'DELETE' });
loadIdentities();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createNewUser() {
const username = prompt('Benutzername:');
if (!username) return;
try {
await apiCall('/identities/users', {
method: 'POST',
body: JSON.stringify({ username, shell: '/bin/bash' })
});
alert('Benutzer erstellt - verwende Passwort sicher setzen!');
loadIdentities();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createNewGroup() {
const groupname = prompt('Gruppenname:');
if (!groupname) return;
try {
await apiCall('/identities/groups', {
method: 'POST',
body: JSON.stringify({ groupname })
});
loadIdentities();
} catch (e) {
alert('Fehler: ' + e);
}
}
// Render functions
function getPageTitle() {
const titles = {
'dashboard': 'Dashboard',
'pools': 'Pools',
'datasets': 'Datasets',
'snapshots': 'Snapshots',
'shares': 'Shares',
'navigator': 'Navigator',
'identities': 'Identitäten'
};
return titles[state.page] || 'Dashboard';
}
function getPageContent() {
if (state.page === 'dashboard') return renderDashboard();
if (state.page === 'shares') return renderShares();
if (state.page === 'navigator') return renderNavigator();
if (state.page === 'identities') return renderIdentities();
return '<p class="text-muted">Seite wird noch implementiert</p>';
}
function renderDashboard() {
let poolsHtml;
if (state.loading) {
poolsHtml = '<tr><td colspan="4" class="text-center"><span class="loading"></span> Lade...</td></tr>';
} else if (!state.zfsAvailable) {
poolsHtml = '<tr><td colspan="4" class="text-center text-muted"><strong style="color: #ff9800;">⚠️ ZFS nicht verfügbar</strong><br><span style="font-size: 12px;">ZFS ist auf diesem System nicht konfiguriert</span></td></tr>';
} else if (state.pools.length) {
poolsHtml = state.pools.map(pool => `
<tr>
<td><strong>${pool.name}</strong></td>
<td class="text-muted">${(pool.size / 1024 / 1024 / 1024 / 1024).toFixed(1)} TB</td>
<td class="text-muted">${pool.capacity}%</td>
<td><span class="badge ${pool.health === 'ONLINE' ? 'success' : 'danger'}">${pool.health}</span></td>
</tr>
`).join('');
} else {
poolsHtml = '<tr><td colspan="4" class="text-center text-muted">Keine Pools gefunden</td></tr>';
}
return `
<div class="card">
<div class="card-title">ZFS Pools</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Größe</th>
<th>Auslastung</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${poolsHtml}
</tbody>
</table>
</div>
`;
}
function renderShares() {
const sambaHtml = state.sambaShares.map(share => `
<tr>
<td><strong>${share.name}</strong></td>
<td class="text-muted text-center">${share.path}</td>
<td class="text-muted text-center">${share.comment || '-'}</td>
<td class="text-center">
<button class="danger" style="padding: 4px 8px; font-size: 12px;" onclick="deleteSambaShare('${share.name}')">Löschen</button>
</td>
</tr>
`).join('') || '<tr><td colspan="4" class="text-center text-muted">Keine Samba Shares</td></tr>';
const nfsHtml = state.nfsShares.map(share => `
<tr>
<td><strong>${share.path}</strong></td>
<td class="text-muted text-center">${share.clients}</td>
<td class="text-muted text-center">${share.options || '-'}</td>
<td class="text-center">
<button class="danger" style="padding: 4px 8px; font-size: 12px;" onclick="deleteNfsShare('${share.path}')">Löschen</button>
</td>
</tr>
`).join('') || '<tr><td colspan="4" class="text-center text-muted">Keine NFS Shares</td></tr>';
return `
<div class="tabs">
<div class="tab ${state.sharesTab === 'samba' ? 'active' : ''}" onclick="state.sharesTab = 'samba'; render()">Samba</div>
<div class="tab ${state.sharesTab === 'nfs' ? 'active' : ''}" onclick="state.sharesTab = 'nfs'; render()">NFS</div>
<div class="tab ${state.sharesTab === 'config' ? 'active' : ''}" onclick="state.sharesTab = 'config'; render()">Konfiguration</div>
</div>
${state.sharesTab === 'samba' ? `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div class="card-title" style="margin: 0;">Samba Shares</div>
<button class="primary" style="font-size: 12px;" onclick="state.createType = 'samba'; state.showCreateDialog = true; render()">+ Neu</button>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Pfad</th>
<th>Beschreibung</th>
<th style="width: 80px;">Aktion</th>
</tr>
</thead>
<tbody>
${sambaHtml}
</tbody>
</table>
</div>
` : state.sharesTab === 'nfs' ? `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div class="card-title" style="margin: 0;">NFS Shares</div>
<button class="primary" style="font-size: 12px;" onclick="state.createType = 'nfs'; state.showCreateDialog = true; render()">+ Neu</button>
</div>
<table>
<thead>
<tr>
<th>Pfad</th>
<th>Clients</th>
<th>Optionen</th>
<th style="width: 80px;">Aktion</th>
</tr>
</thead>
<tbody>
${nfsHtml}
</tbody>
</table>
</div>
` : `
<div class="card">
<div class="card-title" style="margin-bottom: 12px;">Samba Konfiguration</div>
<div style="margin-bottom: 12px;">
<label>Globale Einstellungen</label>
<textarea id="samba-config-text" style="width: 100%; height: 400px; padding: 8px; background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 4px; font-family: monospace; font-size: 12px;">${state.sambaConfig}</textarea>
</div>
<div style="display: flex; gap: 8px;">
<button class="primary" onclick="saveSambaConfig()" style="font-size: 12px;">Speichern</button>
<button class="secondary" onclick="importSambaConfig()" style="font-size: 12px;">Importieren</button>
</div>
</div>
`}
`;
}
function renderNavigator() {
const breadcrumbs = [];
let path = '';
breadcrumbs.push('<span style="cursor: pointer; color: var(--primary);" onclick="navigateToPath(\'\')">📁 root</span>');
if (state.navigatorPath) {
const parts = state.navigatorPath.split('/').filter(p => p);
for (let part of parts) {
path += '/' + part;
breadcrumbs.push(`<span style="cursor: pointer; color: var(--primary);" onclick="navigateToPath('${path}')">${part}</span>`);
}
}
let filesHtml = '';
if (state.loading) {
filesHtml = '<tr><td colspan="5" class="text-center"><span class="loading"></span> Lade...</td></tr>';
} else if (state.navigatorFiles.length) {
filesHtml = state.navigatorFiles.map(file => {
const icon = file.is_dir ? '📁' : '📄';
const sizeStr = file.is_dir ? '-' : (file.size > 1024 * 1024 ? (file.size / 1024 / 1024).toFixed(1) + ' MB' : (file.size / 1024).toFixed(1) + ' KB');
const dateStr = new Date(file.modified * 1000).toLocaleDateString('de-DE');
const path = file.path;
const clickHandler = file.is_dir ? `navigateToPath('${path}')` : `downloadFile('${path}')`;
return `
<tr style="cursor: ${file.is_dir ? 'pointer' : 'default'};">
<td style="cursor: pointer; color: var(--primary);" onclick="${clickHandler}">${icon} ${file.name}</td>
<td class="text-muted" style="font-size: 12px;">${sizeStr}</td>
<td class="text-muted" style="font-size: 12px;">${dateStr}</td>
<td class="text-muted" style="font-size: 12px;">${file.permissions}</td>
<td style="text-align: center; display: flex; gap: 4px; justify-content: center;">
<button style="padding: 2px 6px; font-size: 11px; background: var(--primary); color: white; border: none; border-radius: 3px; cursor: pointer;" onclick="openPermissionsDialog('${path}')">✎ Rechte</button>
<button class="danger" style="padding: 2px 6px; font-size: 11px;" onclick="deleteFileOrDir('${path}')">✕</button>
</td>
</tr>
`;
}).join('');
} else {
filesHtml = '<tr><td colspan="5" class="text-center text-muted">Verzeichnis ist leer</td></tr>';
}
return `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div style="font-size: 12px; color: var(--text-muted);">
${breadcrumbs.join(' / ')}
</div>
<div style="display: flex; gap: 8px;">
<button class="primary" onclick="createNewFolder()" style="font-size: 12px;">+ Ordner</button>
<button class="secondary" onclick="uploadFiles()" style="font-size: 12px;">↑ Upload</button>
</div>
</div>
<table>
<thead>
<tr>
<th>Name</th>
<th style="width: 120px;">Größe</th>
<th style="width: 100px;">Geändert</th>
<th style="width: 100px;">Berechtigung</th>
<th style="width: 160px;">Aktionen</th>
</tr>
</thead>
<tbody>
${filesHtml}
</tbody>
</table>
</div>
${state.spaceInfo.error ? '' : `
<div class="card" style="margin-top: 12px; font-size: 12px;">
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div>
<div class="text-muted">Speicher insgesamt</div>
<strong>${state.spaceInfo.total ? (state.spaceInfo.total / 1024 / 1024 / 1024).toFixed(2) : '?'} GB</strong>
</div>
<div>
<div class="text-muted">Verwendet</div>
<strong>${state.spaceInfo.used ? (state.spaceInfo.used / 1024 / 1024 / 1024).toFixed(2) : '?'} GB</strong>
</div>
<div>
<div class="text-muted">Verfügbar</div>
<strong>${state.spaceInfo.available ? (state.spaceInfo.available / 1024 / 1024 / 1024).toFixed(2) : '?'} GB</strong>
</div>
</div>
</div>
`}
`;
}
function renderIdentities() {
const usersHtml = state.loading ?
'<tr><td colspan="6" class="text-center"><span class="loading"></span> Lade...</td></tr>' :
(state.users.length ?
state.users.map(user => `
<tr>
<td><strong>${user.username}</strong></td>
<td class="text-muted" style="font-size: 12px;">${user.uid}</td>
<td class="text-muted" style="font-size: 12px;">${user.home}</td>
<td class="text-muted" style="font-size: 12px;">${user.shell}</td>
<td class="text-muted" style="font-size: 12px;">${user.groups.join(', ') || '-'}</td>
<td style="text-align: center;">
<button class="danger" style="padding: 2px 6px; font-size: 11px;" onclick="deleteUser('${user.username}')">✕</button>
</td>
</tr>
`).join('') :
'<tr><td colspan="6" class="text-center text-muted">Keine Benutzer</td></tr>'
);
const groupsHtml = state.loading ?
'<tr><td colspan="4" class="text-center"><span class="loading"></span> Lade...</td></tr>' :
(state.groups.length ?
state.groups.map(group => `
<tr>
<td><strong>${group.groupname}</strong></td>
<td class="text-muted" style="font-size: 12px;">${group.gid}</td>
<td class="text-muted" style="font-size: 12px;">${group.members.length} Mitglied(er)</td>
<td style="text-align: center;">
<button class="danger" style="padding: 2px 6px; font-size: 11px;" onclick="deleteGroup('${group.groupname}')">✕</button>
</td>
</tr>
`).join('') :
'<tr><td colspan="4" class="text-center text-muted">Keine Gruppen</td></tr>'
);
return `
<div class="tabs">
<div class="tab ${state.identitiesTab === 'users' ? 'active' : ''}" onclick="state.identitiesTab = 'users'; render()">Benutzer</div>
<div class="tab ${state.identitiesTab === 'groups' ? 'active' : ''}" onclick="state.identitiesTab = 'groups'; render()">Gruppen</div>
<div class="tab ${state.identitiesTab === 'login' ? 'active' : ''}" onclick="state.identitiesTab = 'login'; render()">Login History</div>
</div>
${state.identitiesTab === 'users' ? `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div class="card-title" style="margin: 0;">Benutzer</div>
<button class="primary" style="font-size: 12px;" onclick="createNewUser()">+ Neu</button>
</div>
<table>
<thead>
<tr>
<th>Benutzername</th>
<th style="width: 60px;">UID</th>
<th>Home</th>
<th>Shell</th>
<th>Gruppen</th>
<th style="width: 40px;">Aktion</th>
</tr>
</thead>
<tbody>
${usersHtml}
</tbody>
</table>
</div>
` : state.identitiesTab === 'groups' ? `
<div class="card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
<div class="card-title" style="margin: 0;">Gruppen</div>
<button class="primary" style="font-size: 12px;" onclick="createNewGroup()">+ Neu</button>
</div>
<table>
<thead>
<tr>
<th>Gruppenname</th>
<th style="width: 60px;">GID</th>
<th>Mitglieder</th>
<th style="width: 40px;">Aktion</th>
</tr>
</thead>
<tbody>
${groupsHtml}
</tbody>
</table>
</div>
` : `
<div class="card">
<div class="card-title" style="margin-bottom: 12px;">Login History</div>
<table>
<thead>
<tr>
<th>Benutzer</th>
<th style="width: 100px;">TTY</th>
<th>Host</th>
<th style="width: 150px;">Datum & Zeit</th>
<th>Dauer</th>
</tr>
</thead>
<tbody>
${state.loginHistory.length ? state.loginHistory.map(login => `
<tr>
<td><strong>${login.username}</strong></td>
<td class="text-muted" style="font-size: 12px;">${login.tty}</td>
<td class="text-muted" style="font-size: 12px;">${login.host}</td>
<td style="font-size: 12px;">${login.date} ${login.time}</td>
<td class="text-muted" style="font-size: 11px;">${login.duration}</td>
</tr>
`).join('') : '<tr><td colspan="5" class="text-center text-muted">Keine Login History verfügbar</td></tr>'}
</tbody>
</table>
</div>
`}
`;
}
function renderLogin() {
app.innerHTML = `
<div class="login-container">
<div class="login-box">
<h1>⚙️ ZFS Manager</h1>
<div class="form-group">
<label>Benutzer</label>
<input id="username" type="text" placeholder="administrator">
</div>
<div class="form-group">
<label>Passwort</label>
<input id="password" type="password" placeholder="Passwort">
</div>
<button class="primary" style="width: 100%;" onclick="handleLogin()">
Anmelden
</button>
<p class="text-muted text-center mt-2" style="font-size: 12px;">
PAM Authentifizierung
</p>
</div>
</div>
`;
window.handleLogin = async () => {
const user = document.getElementById('username').value;
const pass = document.getElementById('password').value;
if (await login(user, pass)) {
loadDashboard();
} else {
alert('Anmeldung fehlgeschlagen');
}
};
}
function renderApp() {
const content = getPageContent();
const dialog = state.showCreateDialog ? renderCreateDialog() : '';
const permissionsDialog = state.permissionsDialog ? renderPermissionsDialog() : '';
app.innerHTML = `
<div class="container">
<div class="sidebar">
<div class="sidebar-header">⚙️ ZFS Manager</div>
<ul class="sidebar-nav">
<li><a onclick="loadDashboard()" class="${state.page === 'dashboard' ? 'active' : ''}">Dashboard</a></li>
<li><a onclick="loadShares()" class="${state.page === 'shares' ? 'active' : ''}">Shares</a></li>
<li><a onclick="loadNavigator()" class="${state.page === 'navigator' ? 'active' : ''}">Navigator</a></li>
<li><a onclick="loadIdentities()" class="${state.page === 'identities' ? 'active' : ''}">Identitäten</a></li>
<li style="margin-top: auto; border-top: 1px solid var(--border);">
<a onclick="logout()" class="text-muted">Abmelden</a>
</li>
</ul>
</div>
<div class="main">
<div class="header">
<div class="header-title">${getPageTitle()}</div>
<div class="header-actions">
<span class="text-muted">${state.user}</span>
</div>
</div>
<div class="content">
${content}
</div>
</div>
</div>
${dialog}
${permissionsDialog}
`;
}
function renderCreateDialog() {
if (state.createType === 'samba') {
return `
<div class="dialog-overlay active">
<div class="dialog-box">
<div class="dialog-title">Neue Samba Share</div>
<div class="form-group">
<label>Name</label>
<input id="samba-name" type="text" placeholder="z.B. backup">
</div>
<div class="form-group">
<label>Pfad</label>
<input id="samba-path" type="text" placeholder="z.B. /mnt/tank/share">
</div>
<div class="form-group">
<label>Beschreibung (optional)</label>
<input id="samba-comment" type="text" placeholder="z.B. Backup-Verzeichnis">
</div>
<div class="dialog-actions">
<button onclick="state.showCreateDialog = false; render()">Abbrechen</button>
<button class="primary" onclick="createSambaShare(document.getElementById('samba-name').value, document.getElementById('samba-path').value, document.getElementById('samba-comment').value)">Erstellen</button>
</div>
</div>
</div>
`;
} else {
return `
<div class="dialog-overlay active">
<div class="dialog-box">
<div class="dialog-title">Neue NFS Share</div>
<div class="form-group">
<label>Pfad</label>
<input id="nfs-path" type="text" placeholder="z.B. /mnt/tank/share">
</div>
<div class="form-group">
<label>Clients</label>
<input id="nfs-clients" type="text" placeholder="z.B. 192.168.1.0/24 oder *">
</div>
<div class="form-group">
<label>Optionen (optional)</label>
<input id="nfs-options" type="text" placeholder="z.B. rw,sync,no_subtree_check" value="rw,sync,no_subtree_check">
</div>
<div class="dialog-actions">
<button onclick="state.showCreateDialog = false; render()">Abbrechen</button>
<button class="primary" onclick="createNfsShare(document.getElementById('nfs-path').value, document.getElementById('nfs-clients').value, document.getElementById('nfs-options').value)">Erstellen</button>
</div>
</div>
</div>
`;
}
}
function renderPermissionsDialog() {
if (!state.permissionsDialog) return '';
const dlg = state.permissionsDialog;
const modes = [
{ label: 'rwxrwxrwx (777)', value: '777' },
{ label: 'rwxr-xr-x (755)', value: '755' },
{ label: 'rw-r--r-- (644)', value: '644' },
{ label: 'rw------- (600)', value: '600' }
];
return `
<div class="dialog-overlay active">
<div class="dialog-box">
<div class="dialog-title">Datei-Eigenschaften: ${dlg.name}</div>
<div class="form-group">
<label style="font-size: 12px; color: var(--text-muted);">Pfad: ${dlg.path}</label>
</div>
<div style="border-top: 1px solid var(--border); padding-top: 12px; margin: 12px 0;">
<div style="font-weight: bold; margin-bottom: 8px;">Berechtigung</div>
<div class="form-group">
<label>Modus</label>
<select style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px;" onchange="state.permissionsDialog.currentMode = this.value; render()">
${modes.map(m => `<option value="${m.value}" ${m.value === dlg.currentMode.slice(-3) ? 'selected' : ''}>${m.label}</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>
<input type="checkbox" ${dlg.recursive ? 'checked' : ''} onchange="state.permissionsDialog.recursive = this.checked; render()">
Rekursiv (Ordner und Inhalt)
</label>
</div>
</div>
<div style="border-top: 1px solid var(--border); padding-top: 12px; margin: 12px 0;">
<div style="font-weight: bold; margin-bottom: 8px;">Besitzer</div>
<div class="form-group">
<label>Benutzer</label>
<select style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px;"
onchange="state.permissionsDialog.newOwner = this.value; state.permissionsDialog.ownerChanged = this.value !== dlg.owner; render()">
<option value="${dlg.owner}" selected>Aktuell: ${state.availableUsers.find(u => u.uid.toString() === dlg.owner)?.username || 'UID ' + dlg.owner}</option>
${state.availableUsers.map(u => `<option value="${u.username}">${u.username} (UID ${u.uid})</option>`).join('')}
</select>
</div>
<div class="form-group">
<label>Gruppe</label>
<select style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 4px;"
onchange="state.permissionsDialog.newGroup = this.value; state.permissionsDialog.groupChanged = this.value !== dlg.group; render()">
<option value="${dlg.group}" selected>Aktuell: ${state.availableGroups.find(g => g.gid.toString() === dlg.group)?.groupname || 'GID ' + dlg.group}</option>
${state.availableGroups.map(g => `<option value="${g.groupname}">${g.groupname} (GID ${g.gid})</option>`).join('')}
</select>
</div>
</div>
<div class="dialog-actions">
<button onclick="closePermissionsDialog()">Abbrechen</button>
<button class="primary" onclick="savePermissions()">Speichern</button>
</div>
</div>
</div>
`;
}
function render() {
if (!state.token) {
renderLogin();
} else {
renderApp();
}
}
// Init
if (state.token) {
loadDashboard();
} else {
render();
}
</script>
</body>
</html>