92bed208e0
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>
1374 lines
54 KiB
HTML
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>
|