app.py: - Remove 'raw' field from export dict entries (only comment/blank kinds need raw; export entries are always reconstructed from path/rest/enabled when writing back) - Remove extra blank line shares.js: - Remove unused pathJson variable in SMB row (was only needed for the quota button, which was removed) docker-compose.yml: - Deleted entirely: was missing templates/ and static/ mounts so the Flask app couldn't serve pages; SYS_ADMIN cap_add was only needed for quotas; deployment is systemd, not Docker README.md: - Remove quota package from apt install command - Remove quota setup section (feature removed) - Remove docker-compose from Dev section CLAUDE.md / PLANNING.md: - Remove docker-compose.yml references and quotas.js from file tree .gitignore: - Remove redundant micronas.env entry (already covered by *.env) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 lines
10 KiB
JavaScript
228 lines
10 KiB
JavaScript
async function loadShares() {
|
|
const res = await apiFetch('/api/shares');
|
|
if (!res) return;
|
|
const shares = await res.json();
|
|
|
|
const sb = document.getElementById('smbTableBody');
|
|
if (!shares.smb?.length) {
|
|
sb.innerHTML = `<tr><td colspan="5" style="color:var(--muted)">No SMB shares.</td></tr>`;
|
|
} else {
|
|
sb.innerHTML = shares.smb.map(s => {
|
|
const enabled = s.enabled !== false;
|
|
const dot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
|
|
// esc() HTML-encodes JSON so " inside onclick="..." doesn't break the attribute
|
|
const shareJson = esc(JSON.stringify(s));
|
|
const nameJson = esc(JSON.stringify(s.name));
|
|
return `<tr style="${enabled ? '' : 'opacity:.55'}">
|
|
<td>${esc(s.name)}</td>
|
|
<td><code>${esc(s.path)}</code></td>
|
|
<td style="color:var(--muted); font-size:.87em">${esc(s.valid_users || '—')}</td>
|
|
<td>${dot}</td>
|
|
<td>
|
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
|
onclick="openEditModal('smb',${shareJson})">Edit</button>
|
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
|
onclick="toggleShare('smb',${nameJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
|
|
<button class="btn btn-sm btn-danger"
|
|
onclick="deleteShare('smb',${nameJson})">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
|
|
const nb = document.getElementById('nfsTableBody');
|
|
if (!shares.nfs?.length) {
|
|
nb.innerHTML = `<tr><td colspan="4" style="color:var(--muted)">No NFS exports.</td></tr>`;
|
|
} else {
|
|
nb.innerHTML = shares.nfs.map(s => {
|
|
const enabled = s.enabled !== false;
|
|
const dot = `<span class="dot ${enabled ? 'dot-green' : 'dot-red'}" style="margin-right:0"></span>`;
|
|
const shareJson = esc(JSON.stringify(s));
|
|
const pathJson = esc(JSON.stringify(s.path));
|
|
return `<tr style="${enabled ? '' : 'opacity:.55'}">
|
|
<td><code>${esc(s.path)}</code></td>
|
|
<td style="color:var(--muted); font-size:.87em">${esc(s.clients)}</td>
|
|
<td>${dot}</td>
|
|
<td>
|
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
|
onclick="openEditModal('nfs',${shareJson})">Edit</button>
|
|
<button class="btn btn-sm me-1" style="border:1px solid var(--border); color:var(--muted)"
|
|
onclick="toggleShare('nfs',${pathJson},${!enabled})">${enabled ? 'Disable' : 'Enable'}</button>
|
|
<button class="btn btn-sm btn-danger"
|
|
onclick="deleteShare('nfs',${pathJson})">Delete</button>
|
|
</td>
|
|
</tr>`;
|
|
}).join('');
|
|
}
|
|
}
|
|
|
|
async function addSMBShare() {
|
|
const name = document.getElementById('smbName').value.trim();
|
|
const path = document.getElementById('smbPath').value.trim();
|
|
const validUsers = document.getElementById('smbUsers').value.trim();
|
|
if (!name || !path) return showToast('Name and path are required.', 'danger');
|
|
|
|
const res = await apiFetch('/api/share/smb', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({name, path, validUsers}),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
|
showToast('SMB share created.');
|
|
document.getElementById('smbName').value = '';
|
|
document.getElementById('smbPath').value = '';
|
|
document.getElementById('smbUsers').value = '';
|
|
loadShares(); loadDashboard();
|
|
}
|
|
|
|
async function addNFSShare() {
|
|
const path = document.getElementById('nfsPath').value.trim();
|
|
const clients = document.getElementById('nfsClients').value.trim() || '*';
|
|
const readOnly = document.getElementById('nfsReadOnly').checked;
|
|
if (!path) return showToast('Path is required.', 'danger');
|
|
|
|
const res = await apiFetch('/api/share/nfs', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({path, clients, readOnly}),
|
|
});
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
|
showToast('NFS export created.');
|
|
document.getElementById('nfsPath').value = '';
|
|
document.getElementById('nfsClients').value = '';
|
|
document.getElementById('nfsReadOnly').checked = false;
|
|
loadShares(); loadDashboard();
|
|
}
|
|
|
|
// ===== EDIT =====
|
|
|
|
let _editCtx = null;
|
|
|
|
function openEditModal(type, share) {
|
|
_editCtx = { type, original: share };
|
|
document.getElementById('editModalTitle').textContent =
|
|
type === 'smb' ? `Edit SMB Share — ${esc(share.name)}` : `Edit NFS Export — ${esc(share.path)}`;
|
|
|
|
let body = '';
|
|
if (type === 'smb') {
|
|
body = `
|
|
<div class="mb-3">
|
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Share Name</label>
|
|
<input type="text" id="editName" class="form-control" value="${esc(share.name)}">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Path</label>
|
|
<input type="text" id="editPath" class="form-control" value="${esc(share.path)}">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Valid Users</label>
|
|
<input type="text" id="editUsers" class="form-control" value="${esc(share.valid_users || '')}">
|
|
</div>`;
|
|
} else {
|
|
const isRo = /\(ro[,)]/.test(share.clients);
|
|
const cleanClients = share.clients.replace(/\s*\(.*?\)/g, '').trim();
|
|
body = `
|
|
<div class="mb-3">
|
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Path</label>
|
|
<input type="text" class="form-control" value="${esc(share.path)}" readonly style="color:var(--muted)">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label" style="color:var(--muted); font-size:.85rem">Clients</label>
|
|
<input type="text" id="editClients" class="form-control" value="${esc(cleanClients)}">
|
|
</div>
|
|
<div class="form-check ms-1 mt-2">
|
|
<input type="checkbox" id="editReadOnly" class="form-check-input" ${isRo ? 'checked' : ''}>
|
|
<label class="form-check-label" for="editReadOnly" style="color:var(--muted)">Read only</label>
|
|
</div>`;
|
|
}
|
|
|
|
document.getElementById('editModalBody').innerHTML = body;
|
|
new bootstrap.Modal(document.getElementById('editModal')).show();
|
|
}
|
|
|
|
async function submitEdit() {
|
|
const ctx = _editCtx;
|
|
if (!ctx) return;
|
|
|
|
let res;
|
|
if (ctx.type === 'smb') {
|
|
const name = document.getElementById('editName').value.trim();
|
|
const path = document.getElementById('editPath').value.trim();
|
|
const validUsers = document.getElementById('editUsers').value.trim();
|
|
res = await apiFetch(`/api/share/smb/${encodeURIComponent(ctx.original.name)}`, {
|
|
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({name, path, validUsers}),
|
|
});
|
|
} else {
|
|
const clients = document.getElementById('editClients').value.trim();
|
|
const readOnly = document.getElementById('editReadOnly').checked;
|
|
res = await apiFetch('/api/share/nfs', {
|
|
method: 'PATCH', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({path: ctx.original.path, clients, readOnly}),
|
|
});
|
|
}
|
|
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
bootstrap.Modal.getInstance(document.getElementById('editModal')).hide();
|
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
|
showToast('Share updated.');
|
|
loadShares(); loadDashboard();
|
|
}
|
|
|
|
// ===== DELETE =====
|
|
|
|
function deleteShare(type, identifier) {
|
|
const label = type === 'smb' ? `SMB share "${identifier}"` : `NFS export "${identifier}"`;
|
|
document.getElementById('deleteModalMsg').textContent =
|
|
`Are you sure you want to delete ${label}? This cannot be undone.`;
|
|
|
|
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
|
const btn = document.getElementById('deleteModalConfirm');
|
|
const handler = async () => {
|
|
btn.removeEventListener('click', handler);
|
|
modal.hide();
|
|
let res;
|
|
if (type === 'smb') {
|
|
res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
|
|
} else {
|
|
res = await apiFetch('/api/share/nfs', {
|
|
method: 'DELETE', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({path: identifier}),
|
|
});
|
|
}
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
|
showToast(`${label} deleted.`);
|
|
loadShares(); loadDashboard();
|
|
};
|
|
btn.addEventListener('click', handler);
|
|
modal.show();
|
|
}
|
|
|
|
// ===== TOGGLE =====
|
|
|
|
async function toggleShare(type, identifier, enable) {
|
|
let res;
|
|
if (type === 'smb') {
|
|
res = await apiFetch(`/api/share/smb/${encodeURIComponent(identifier)}/toggle`, {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({enabled: enable}),
|
|
});
|
|
} else {
|
|
res = await apiFetch('/api/share/nfs/toggle', {
|
|
method: 'POST', headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({path: identifier, enabled: enable}),
|
|
});
|
|
}
|
|
|
|
if (!res) return;
|
|
const data = await res.json();
|
|
if (!res.ok) return showToast('Error: ' + (data.error || res.status), 'danger');
|
|
showToast(`Share ${enable ? 'enabled' : 'disabled'}.`);
|
|
loadShares(); loadDashboard();
|
|
}
|