Files
zmb-webui/backend/static/navigator.html
T
Claude Code 92bed208e0 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

681 lines
24 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>File Navigator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg: #1b1b1f;
--bg-light: #252526;
--border: #3c3c3d;
--text: #f3f3f3;
--text-muted: #a0a0a0;
--primary: #0066ff;
--danger: #d52f2f;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
display: flex;
flex-direction: column;
height: 100vh;
}
.nav-header {
background: var(--bg-light);
border-bottom: 1px solid var(--border);
padding: 12px 16px;
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.nav-buttons {
display: flex;
gap: 4px;
}
button {
background: var(--bg-light);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
button:hover {
background: var(--border);
}
button.primary {
background: var(--primary);
border-color: var(--primary);
color: white;
}
button.primary:hover {
background: #0055dd;
}
.address-bar {
flex: 1;
min-width: 200px;
background: var(--bg);
border: 1px solid var(--border);
padding: 8px 12px;
color: var(--text);
border-radius: 4px;
font-size: 12px;
}
.search-bar {
width: 200px;
background: var(--bg);
border: 1px solid var(--border);
padding: 8px 12px;
color: var(--text);
border-radius: 4px;
font-size: 12px;
}
.nav-main {
display: flex;
flex: 1;
overflow: hidden;
}
.nav-content {
flex: 1;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.nav-info-panel {
width: 280px;
background: var(--bg-light);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
}
.nav-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.nav-table thead {
position: sticky;
top: 0;
background: var(--bg);
border-bottom: 2px solid var(--border);
}
.nav-table th {
padding: 8px 12px;
text-align: left;
font-weight: 600;
cursor: pointer;
user-select: none;
color: var(--text-muted);
}
.nav-table th:hover {
background: var(--border);
}
.nav-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--border);
}
.nav-table tr {
cursor: pointer;
}
.nav-table tr:hover {
background: var(--border);
}
.nav-table tr.selected {
background: var(--primary);
}
.nav-file-icon {
margin-right: 8px;
width: 20px;
display: inline-block;
text-align: center;
}
.nav-footer {
background: var(--bg-light);
border-top: 1px solid var(--border);
padding: 8px 16px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.nav-properties {
flex: 1;
}
.nav-property {
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.nav-property-label {
font-size: 11px;
color: var(--text-muted);
font-weight: 600;
margin-bottom: 4px;
}
.nav-property-value {
font-size: 12px;
color: var(--text);
word-break: break-all;
}
.nav-property-input {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 6px 8px;
border-radius: 3px;
width: 100%;
font-size: 12px;
}
.nav-permissions-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 8px;
margin: 8px 0;
}
.nav-permission-row {
display: contents;
}
.nav-permission-label {
font-size: 11px;
color: var(--text-muted);
padding: 4px;
font-weight: 600;
}
.nav-permission-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.nav-permission-checkbox input {
cursor: pointer;
}
.hidden-toggle {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.hidden-toggle input[type="checkbox"] {
cursor: pointer;
}
</style>
</head>
<body>
<!-- Header -->
<div class="nav-header">
<div class="nav-buttons">
<button title="Back" onclick="navBack()">← Zurück</button>
<button title="Forward" onclick="navForward()">Vorwärts →</button>
<button title="Up" onclick="navUp()">↑ Nach oben</button>
<button title="Refresh" onclick="loadDirectory()">🔄 Aktualisieren</button>
</div>
<input type="text" class="address-bar" id="addressBar" placeholder="/tank/share"
onkeypress="if(event.key==='Enter') loadDirectory(this.value)">
<input type="text" class="search-bar" id="searchBar" placeholder="Suchen..."
onkeyup="filterFiles()">
<div class="nav-buttons">
<button class="primary" title="Neuer Ordner" onclick="createFolder()">📁 + Ordner</button>
<button class="primary" title="Neue Datei" onclick="createFile()">📄 + Datei</button>
<button class="primary" title="Upload" onclick="uploadFile()">⬆ Upload</button>
</div>
</div>
<!-- Main Content -->
<div class="nav-main">
<!-- File List -->
<div class="nav-content">
<table class="nav-table">
<thead>
<tr>
<th onclick="sortBy('name')" style="width: 40%;">📁 Name <span id="sort-name"></span></th>
<th onclick="sortBy('size')" style="width: 15%;">Größe <span id="sort-size"></span></th>
<th onclick="sortBy('modified')" style="width: 15%;">Geändert <span id="sort-modified"></span></th>
<th onclick="sortBy('permissions')" style="width: 15%;">Rechte <span id="sort-permissions"></span></th>
<th onclick="sortBy('owner')" style="width: 15%;">Besitzer <span id="sort-owner"></span></th>
</tr>
</thead>
<tbody id="fileList"></tbody>
</table>
</div>
<!-- Info Panel -->
<div class="nav-info-panel">
<div id="infoContent" style="display: none;">
<h3 id="selectedFileName" style="margin-bottom: 16px; font-size: 14px;"></h3>
<div id="viewProperties">
<div class="nav-property">
<div class="nav-property-label">Typ</div>
<div class="nav-property-value" id="propType">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Größe</div>
<div class="nav-property-value" id="propSize">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Geändert</div>
<div class="nav-property-value" id="propModified">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Besitzer</div>
<div class="nav-property-value" id="propOwner">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Gruppe</div>
<div class="nav-property-value" id="propGroup">-</div>
</div>
<div class="nav-property">
<div class="nav-property-label">Rechte</div>
<div class="nav-property-value" id="propPermissions">-</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button onclick="editFile()" class="primary" style="flex: 1;">✎ Bearbeiten</button>
<button onclick="deleteFile()" style="flex: 1; background: var(--danger); border-color: var(--danger);">🗑 Löschen</button>
</div>
</div>
<div id="editProperties" style="display: none;">
<div class="nav-property">
<div class="nav-property-label">Besitzer</div>
<input type="text" id="editOwner" class="nav-property-input" placeholder="root">
</div>
<div class="nav-property">
<div class="nav-property-label">Gruppe</div>
<input type="text" id="editGroup" class="nav-property-input" placeholder="root">
</div>
<div class="nav-property">
<div class="nav-property-label">Berechtigungen</div>
<div class="nav-permissions-grid">
<div class="nav-permission-label"></div>
<div class="nav-permission-label">Lesen</div>
<div class="nav-permission-label">Schreiben</div>
<div class="nav-permission-label">Ausführen</div>
<div class="nav-permission-label">Besitzer</div>
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-r"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-w"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="owner-x"></div>
<div class="nav-permission-label">Gruppe</div>
<div class="nav-permission-checkbox"><input type="checkbox" id="group-r"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="group-w"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="group-x"></div>
<div class="nav-permission-label">Andere</div>
<div class="nav-permission-checkbox"><input type="checkbox" id="other-r"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="other-w"></div>
<div class="nav-permission-checkbox"><input type="checkbox" id="other-x"></div>
</div>
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<button onclick="cancelEdit()" style="flex: 1;">✕ Abbrechen</button>
<button onclick="saveEdit()" class="primary" style="flex: 1;">💾 Speichern</button>
</div>
</div>
</div>
<div id="noSelection" style="text-align: center; padding: 32px 16px; color: var(--text-muted);">
<p style="font-size: 14px;">Wähle eine Datei aus</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="nav-footer">
<div>
<span id="fileStats">0 Dateien, 0 Ordner (0 B)</span>
</div>
<div style="flex: 1;"></div>
<div class="hidden-toggle">
<input type="checkbox" id="showHidden" onchange="loadDirectory()">
<label for="showHidden">Versteckte Dateien</label>
</div>
</div>
<script>
const API_URL = '';
let token = localStorage.getItem('access_token');
let currentPath = '';
let selectedFile = null;
let historyStack = [];
let historyIndex = -1;
async function apiCall(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_URL}/api${endpoint}`, {
...options,
headers
});
if (response.status === 401) {
alert('Session expired');
window.location.href = '/';
return null;
}
return response.json();
}
async function loadDirectory(path = '') {
if (path) {
currentPath = path;
}
document.getElementById('addressBar').value = currentPath;
const data = await apiCall(`/files/browse?path=${encodeURIComponent(currentPath)}`);
if (data && data.entries) {
let entries = data.entries;
// Filter hidden files
if (!document.getElementById('showHidden').checked) {
entries = entries.filter(e => !e.name.startsWith('.'));
}
// Sort by name
entries.sort((a, b) => a.name.localeCompare(b.name));
renderFileList(entries);
updateStats(entries);
}
}
function renderFileList(entries) {
const html = entries.map(file => `
<tr onclick="selectFile('${file.path}', this)" class="file-row">
<td><span class="nav-file-icon">${file.is_dir ? '📁' : '📄'}</span>${file.name}</td>
<td>${file.is_dir ? '-' : formatSize(file.size)}</td>
<td>${new Date(file.modified * 1000).toLocaleDateString('de-DE')}</td>
<td>${file.permissions}</td>
<td>${file.uid}</td>
</tr>
`).join('');
document.getElementById('fileList').innerHTML = html;
}
function selectFile(path, element) {
document.querySelectorAll('.file-row').forEach(e => e.classList.remove('selected'));
element.classList.add('selected');
// Load file info
loadFileInfo(path);
}
async function loadFileInfo(path) {
selectedFile = path;
const data = await apiCall(`/files/info?path=${encodeURIComponent(path)}`);
if (data) {
const isDir = data.is_dir;
document.getElementById('selectedFileName').textContent = data.name;
document.getElementById('propType').textContent = isDir ? 'Verzeichnis' : 'Datei';
document.getElementById('propSize').textContent = isDir ? '-' : formatSize(data.size);
document.getElementById('propModified').textContent = new Date(data.modified * 1000).toLocaleString('de-DE');
document.getElementById('propOwner').textContent = data.uid;
document.getElementById('propGroup').textContent = data.gid;
document.getElementById('propPermissions').textContent = data.permissions;
document.getElementById('noSelection').style.display = 'none';
document.getElementById('infoContent').style.display = 'block';
}
}
function editFile() {
document.getElementById('viewProperties').style.display = 'none';
document.getElementById('editProperties').style.display = 'block';
// Load current permissions into checkboxes
const perms = document.getElementById('propPermissions').textContent;
if (perms && perms.length >= 9) {
document.getElementById('owner-r').checked = perms[1] === 'r';
document.getElementById('owner-w').checked = perms[2] === 'w';
document.getElementById('owner-x').checked = perms[3] === 'x';
document.getElementById('group-r').checked = perms[4] === 'r';
document.getElementById('group-w').checked = perms[5] === 'w';
document.getElementById('group-x').checked = perms[6] === 'x';
document.getElementById('other-r').checked = perms[7] === 'r';
document.getElementById('other-w').checked = perms[8] === 'w';
document.getElementById('other-x').checked = perms[9] === 'x';
}
}
function cancelEdit() {
document.getElementById('viewProperties').style.display = 'block';
document.getElementById('editProperties').style.display = 'none';
}
async function saveEdit() {
// Calculate mode from checkboxes
const mode = calculateMode();
try {
await apiCall(`/files/permissions`, {
method: 'POST',
body: JSON.stringify({
path: selectedFile,
mode: mode
})
});
alert('Rechte aktualisiert');
cancelEdit();
loadDirectory();
} catch (e) {
alert('Fehler: ' + e);
}
}
function calculateMode() {
let mode = 0;
if (document.getElementById('owner-r').checked) mode += 400;
if (document.getElementById('owner-w').checked) mode += 200;
if (document.getElementById('owner-x').checked) mode += 100;
if (document.getElementById('group-r').checked) mode += 40;
if (document.getElementById('group-w').checked) mode += 20;
if (document.getElementById('group-x').checked) mode += 10;
if (document.getElementById('other-r').checked) mode += 4;
if (document.getElementById('other-w').checked) mode += 2;
if (document.getElementById('other-x').checked) mode += 1;
return mode.toString(8).padStart(3, '0');
}
async function deleteFile() {
if (!confirm(`Löschen: ${selectedFile}?`)) return;
try {
await apiCall(`/files/delete?path=${encodeURIComponent(selectedFile)}`, {
method: 'DELETE'
});
loadDirectory();
document.getElementById('noSelection').style.display = 'block';
document.getElementById('infoContent').style.display = 'none';
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createFolder() {
const name = prompt('Ordnername:');
if (!name) return;
const path = currentPath ? `${currentPath}/${name}` : name;
try {
await apiCall(`/files/mkdir`, {
method: 'POST',
body: JSON.stringify({ path })
});
loadDirectory();
} catch (e) {
alert('Fehler: ' + e);
}
}
async function createFile() {
const name = prompt('Dateiname:');
if (!name) return;
const path = currentPath ? `${currentPath}/${name}` : name;
try {
await apiCall(`/files/create`, {
method: 'POST',
body: JSON.stringify({ path })
});
loadDirectory();
} catch (e) {
alert('Fehler: ' + e);
}
}
function uploadFile() {
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) {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch(`${API_URL}/api/files/upload?path=${encodeURIComponent(currentPath)}`, {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: formData
});
if (!response.ok) throw new Error(await response.text());
} catch (e) {
alert(`Fehler bei ${file.name}: ${e}`);
}
}
loadDirectory();
};
input.click();
}
function navBack() {
if (historyIndex > 0) {
historyIndex--;
currentPath = historyStack[historyIndex];
loadDirectory();
}
}
function navForward() {
if (historyIndex < historyStack.length - 1) {
historyIndex++;
currentPath = historyStack[historyIndex];
loadDirectory();
}
}
function navUp() {
const parts = currentPath.split('/').filter(p => p);
parts.pop();
currentPath = '/' + parts.join('/');
if (currentPath === '/') currentPath = '';
loadDirectory();
}
function sortBy(key) {
// TODO: Implement sorting
}
function filterFiles() {
const search = document.getElementById('searchBar').value.toLowerCase();
document.querySelectorAll('.file-row').forEach(row => {
const name = row.textContent.toLowerCase();
row.style.display = name.includes(search) ? '' : 'none';
});
}
function updateStats(entries) {
const files = entries.filter(e => !e.is_dir).length;
const dirs = entries.filter(e => e.is_dir).length;
const size = entries.reduce((sum, e) => sum + (e.size || 0), 0);
document.getElementById('fileStats').textContent =
`${files} Datei(en), ${dirs} Ordner (${formatSize(size)})`;
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
loadDirectory();
});
</script>
</body>
</html>