diff --git a/index.html b/index.html index 1538a9db..f454ee56 100644 --- a/index.html +++ b/index.html @@ -1,1142 +1,2269 @@ - - mergerfs ui + + + + mergerfs Runtime Configuration Manager + - - -
- - -
-
-
-
-
- - -
-
- - -
+ + +
+
+

mergerfs ui

+
Advanced configuration and management tool
-
-
-
-
-
-
- - + + +
+
+ + + + + +
+ + +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+
Branch Path
+
Mode
+
Min Free Space
+
Actions
+
+ +
+
+
📁
+

No branches configured

+

Click "Add Branch" to get started

+
+
-
-
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
Create Category
+
+ Controls how new files and directories are created. Default: pfrd (percentage free random distribution) +
+ +
+ +
+
Search Category
+
+ Controls how files are searched and accessed. Default: ff (first found) +
+ +
+ +
+
Action Category
+
+ Controls how file attributes are modified. Default: epall (existing path all) +
+ +
+ +
+
Readdir Policy
+
+ Controls how directory contents are read. Default: seq (sequential) +
+ +
+
+ +
+ Policy Categories:
+ • Create: mkdir, create, mknod, symlink
+ • Search: access, getattr, getxattr, ioctl, listxattr, open, readlink
+ • Action: chmod, chown, link, removexattr, rename, rmdir, setxattr, truncate, unlink, utimens
+ • Readdir: Directory listing operations +
+
+ + +
+
+
+
+ + +
+
+ + + +
+
+
+ +
+ 🔍 + +
+ +
+ + + + + + + + + + + + + +
OptionCurrent ValueDescription
Loading configuration...
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+

Runtime Commands

+
+
+
Garbage Collection
+
+ Trigger thorough garbage collection of mergerfs resources +
+ +
+ +
+
Quick GC
+
+ Trigger simple garbage collection (runs periodically) +
+ +
+ +
+
Invalidate Cache
+
+ Invalidate all FUSE node caches (for debugging) +
+ +
+
+
+ +
+ Commands:
+ • GC: Comprehensive cleanup of internal caches and resources
+ • Quick GC: Lightweight cleanup that runs automatically every ~15 minutes
+ • Invalidate: Forces FUSE to release cached file information (use for debugging) +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+ +
+
+
📄
+

Enter a file path to get mergerfs-specific information

+

Information includes basepath, relpath, fullpath, and allpaths

+
+
+
+
+ + + -
- -
+ + + + - + // Global state management + const AppState = { + mounts: [], + currentMount: null, + branches: [], + config: {}, + policies: {}, + pendingPathInput: null, + branchCounter: 0 + }; + + // Utility functions + const Utils = { + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + }, + + formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 B'; + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; + }, + + validatePath(path) { + if (!path || typeof path !== 'string') { + return { valid: false, message: 'Path is required' }; + } + if (path.length > 4096) { + return { valid: false, message: 'Path too long' }; + } + if (!path.startsWith('/')) { + return { valid: false, message: 'Path must be absolute' }; + } + return { valid: true }; + }, + + validateMinfreespace(value, unit) { + const num = parseFloat(value); + if (isNaN(num) || num < 0) { + return { valid: false, message: 'Must be a positive number' }; + } + if (unit === 'B' && num > 9000000000000000) { + return { valid: false, message: 'Value too large' }; + } + return { valid: true }; + } + }; + + // API service - Updated to use xattrs for runtime configuration + const API = { + async request(url, options = {}) { + try { + const response = await fetch(url, { + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + ...options + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + + return await response.json(); + } catch (error) { + console.error('API request failed:', error); + showToast(error.message || 'Request failed', 'error'); + throw error; + } + }, + + async getMounts() { + const data = await this.request('/mounts/mergerfs'); + AppState.mounts = data; + return data; + }, + + async getAllMounts() { + return await this.request('/mounts'); + }, + + async getBranches(mount) { + const response = await fetch(`/kvs/branches?mount=${encodeURIComponent(mount)}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + return await response.text(); + }, + + async getConfig(mount) { + const data = await this.request(`/kvs?mount=${encodeURIComponent(mount)}`); + return data; + }, + + async setConfig(mount, key, value) { + return await this.request(`/kvs/${encodeURIComponent(key)}?mount=${encodeURIComponent(mount)}`, { + method: 'POST', + body: JSON.stringify(value) + }); + }, + + async setBranches(mount, branches) { + return await this.request(`/kvs/branches?mount=${encodeURIComponent(mount)}`, { + method: 'POST', + body: JSON.stringify(branches) + }); + }, + + // Runtime xattr operations + async setXattr(mount, key, value) { + // Use the .mergerfs pseudo file for runtime configuration + const xattrKey = `user.mergerfs.${key}`; + return await this.request(`/${encodeURIComponent(mount)}/.mergerfs`, { + method: 'POST', + headers: { + 'X-Attr-Key': xattrKey, + 'Content-Type': 'text/plain' + }, + body: value + }); + }, + + async getXattr(mount, key) { + const xattrKey = `user.mergerfs.${key}`; + return await this.request(`/${encodeURIComponent(mount)}/.mergerfs?xattr=${encodeURIComponent(xattrKey)}`); + }, + + async executeCommand(mount, command) { + return await this.setXattr(mount, `cmd.${command}`, ''); + }, + + async getFileInfo(mount, filePath, infoType) { + const xattrKey = `user.mergerfs.${infoType}`; + return await this.request(`/${encodeURIComponent(mount)}${filePath}?xattr=${encodeURIComponent(xattrKey)}`); + } + }; + + // UI Components + const UI = { + showLoading(element) { + if (element) { + element.innerHTML = '
Loading...'; + } + }, + + hideLoading(element, content) { + if (element) { + element.innerHTML = content || ''; + } + }, + + showModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.add('show'); + modal.setAttribute('aria-hidden', 'false'); + const focusableElements = modal.querySelectorAll('button, input, select, textarea'); + if (focusableElements.length > 0) { + focusableElements[0].focus(); + } + } + }, + + hideModal(modalId) { + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.remove('show'); + modal.setAttribute('aria-hidden', 'true'); + } + }, + + createBranchEntry(path = '', mode = 'RW', minfreespace = '') { + const entry = document.createElement('div'); + entry.className = 'branch-entry'; + entry.draggable = true; + entry.id = `branch-entry-${AppState.branchCounter++}`; + + // Path group with input and browse button + const pathGroup = document.createElement('div'); + pathGroup.className = 'branch-path-group'; + + const pathInput = document.createElement('input'); + pathInput.type = 'text'; + pathInput.className = 'branch-path'; + pathInput.placeholder = '/path/to/branch'; + pathInput.value = path; + pathInput.setAttribute('aria-label', 'Branch path'); + + const browseBtn = document.createElement('button'); + browseBtn.className = 'icon-button browse-button'; + browseBtn.innerHTML = 'Browse'; + browseBtn.title = 'Browse mount points'; + browseBtn.setAttribute('aria-label', 'Browse mount points'); + browseBtn.onclick = () => openPathModal(pathInput); + + pathGroup.appendChild(pathInput); + pathGroup.appendChild(browseBtn); + + const modeSelect = document.createElement('select'); + modeSelect.className = 'branch-mode'; + modeSelect.setAttribute('aria-label', 'Branch mode'); + + const modes = [ + { value: 'RW', label: 'Read/Write' }, + { value: 'RO', label: 'Read-Only' }, + { value: 'NC', label: 'No Create' } + ]; + + modes.forEach(m => { + const option = document.createElement('option'); + option.value = m.value; + option.textContent = m.label; + if (m.value === mode) option.selected = true; + modeSelect.appendChild(option); + }); + + const minfreespaceContainer = document.createElement('div'); + minfreespaceContainer.className = 'branch-minfreespace'; + + const valueInput = document.createElement('input'); + valueInput.type = 'number'; + valueInput.className = 'branch-minfreespace-value'; + valueInput.placeholder = '0'; + valueInput.min = '0'; + valueInput.setAttribute('aria-label', 'Minimum free space value'); + + const unitSelect = document.createElement('select'); + unitSelect.className = 'branch-minfreespace-unit'; + unitSelect.setAttribute('aria-label', 'Minimum free space unit'); + + const units = [ + { value: 'B', label: 'B' }, + { value: 'K', label: 'KB' }, + { value: 'M', label: 'MB' }, + { value: 'G', label: 'GB' }, + { value: 'T', label: 'TB' } + ]; + + units.forEach(u => { + const option = document.createElement('option'); + option.value = u.value; + option.textContent = u.label; + unitSelect.appendChild(option); + }); + + let value = ''; + let unit = 'G'; + if (minfreespace) { + const match = minfreespace.match(/^(\d+)([BKMG])?$/i); + if (match) { + value = match[1]; + if (match[2]) unit = match[2].toUpperCase(); + } else { + value = minfreespace; + } + } + + valueInput.value = value; + unitSelect.value = unit; + + minfreespaceContainer.appendChild(valueInput); + minfreespaceContainer.appendChild(unitSelect); + + const controls = document.createElement('div'); + controls.className = 'branch-controls'; + + const moveUpBtn = document.createElement('button'); + moveUpBtn.className = 'icon-button'; + moveUpBtn.innerHTML = '↑'; + moveUpBtn.title = 'Move up'; + moveUpBtn.setAttribute('aria-label', 'Move branch up'); + moveUpBtn.onclick = () => moveBranchUp(entry); + + const moveDownBtn = document.createElement('button'); + moveDownBtn.className = 'icon-button'; + moveDownBtn.innerHTML = '↓'; + moveDownBtn.title = 'Move down'; + moveDownBtn.setAttribute('aria-label', 'Move branch down'); + moveDownBtn.onclick = () => moveBranchDown(entry); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'icon-button'; + removeBtn.innerHTML = '🗑'; + removeBtn.title = 'Remove branch'; + removeBtn.setAttribute('aria-label', 'Remove branch'); + removeBtn.onclick = () => entry.remove(); + + controls.appendChild(moveUpBtn); + controls.appendChild(moveDownBtn); + controls.appendChild(removeBtn); + + entry.appendChild(pathGroup); + entry.appendChild(modeSelect); + entry.appendChild(minfreespaceContainer); + entry.appendChild(controls); + + this.addDragDropListeners(entry); + + return entry; + }, + + addDragDropListeners(entry) { + entry.addEventListener('dragstart', handleDragStart); + entry.addEventListener('dragend', handleDragEnd); + entry.addEventListener('dragover', handleDragOver); + entry.addEventListener('dragleave', handleDragLeave); + entry.addEventListener('drop', handleDrop); + } + }; + + // Toast notifications + function showToast(message, type = 'success', duration = 3000) { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = `toast ${type} show`; + + setTimeout(() => { + toast.classList.remove('show'); + }, duration); + } + + + + // Tab management + function initTabs() { + const tabButtons = document.querySelectorAll('.tab-button'); + const tabContents = document.querySelectorAll('.tab-content'); + + tabButtons.forEach(button => { + button.addEventListener('click', () => { + const targetTab = button.getAttribute('aria-controls'); + + tabButtons.forEach(btn => { + btn.setAttribute('aria-selected', 'false'); + btn.classList.remove('active'); + btn.setAttribute('tabindex', '-1'); + }); + + button.setAttribute('aria-selected', 'true'); + button.classList.add('active'); + button.setAttribute('tabindex', '0'); + + tabContents.forEach(content => { + content.classList.remove('active'); + }); + + document.getElementById(targetTab).classList.add('active'); + + localStorage.setItem('mergerfs_activeTab', targetTab); + }); + + button.addEventListener('keydown', (e) => { + let targetButton = null; + + switch(e.key) { + case 'ArrowLeft': + case 'ArrowUp': + targetButton = button.previousElementSibling || tabButtons[tabButtons.length - 1]; + break; + case 'ArrowRight': + case 'ArrowDown': + targetButton = button.nextElementSibling || tabButtons[0]; + break; + case 'Home': + targetButton = tabButtons[0]; + break; + case 'End': + targetButton = tabButtons[tabButtons.length - 1]; + break; + } + + if (targetButton) { + e.preventDefault(); + targetButton.focus(); + } + }); + }); + + const savedTab = localStorage.getItem('mergerfs_activeTab'); + if (savedTab) { + const savedButton = document.querySelector(`[aria-controls="${savedTab}"]`); + if (savedButton) { + savedButton.click(); + } + } + } + + // Mount management + async function loadMounts() { + try { + await API.getMounts(); + populateMountSelects(); + + if (AppState.mounts.length > 0) { + AppState.currentMount = AppState.mounts[0]; + await loadMountData(AppState.currentMount); + } + } catch (error) { + console.error('Failed to load mounts:', error); + showToast('Failed to load mount points', 'error'); + } + } + + function populateMountSelects() { + const selects = ['mount-select-branches', 'mount-select-policies', 'mount-select-config', 'mount-select-commands', 'mount-select-info']; + + selects.forEach(selectId => { + const select = document.getElementById(selectId); + if (select) { + select.innerHTML = ''; + + if (AppState.mounts.length === 0) { + const option = document.createElement('option'); + option.value = ''; + option.textContent = 'No mounts available'; + select.appendChild(option); + return; + } + + AppState.mounts.forEach(mount => { + const option = document.createElement('option'); + option.value = mount; + option.textContent = mount; + select.appendChild(option); + }); + + if (AppState.currentMount) { + select.value = AppState.currentMount; + } + + select.addEventListener('change', async () => { + if (select.value) { + AppState.currentMount = select.value; + await loadMountData(select.value); + } + }); + } + }); + } + + async function loadMountData(mount) { + if (!mount) return; + + try { + const activeTab = document.querySelector('.tab-button[aria-selected="true"]').getAttribute('aria-controls'); + + switch(activeTab) { + case 'branches-panel': + await loadBranches(mount); + break; + case 'policies-panel': + await loadPolicies(mount); + break; + case 'config-panel': + await loadConfig(mount); + break; + case 'commands-panel': + // Commands don't need data loading + break; + case 'info-panel': + // File info is loaded on demand + break; + } + } catch (error) { + console.error('Failed to load mount data:', error); + showToast('Failed to load configuration', 'error'); + } + } + + // Branch management + async function loadBranches(mount) { + const container = document.getElementById('branches-container'); + UI.showLoading(container); + + try { + const branchesStr = await API.getBranches(mount); + const container = document.getElementById('branches-container'); + container.innerHTML = ''; + + if (!branchesStr) { + container.innerHTML = UI.createBranchEntry().outerHTML; + return; + } + + const branches = branchesStr.split(':').map(b => b.trim()).filter(b => b); + + if (branches.length === 0) { + container.innerHTML = UI.createBranchEntry().outerHTML; + return; + } + + branches.forEach(branchStr => { + let path = branchStr; + let mode = 'RW'; + let minfreespace = ''; + + const eqPos = branchStr.indexOf('='); + if (eqPos !== -1) { + path = branchStr.substring(0, eqPos).trim(); + const options = branchStr.substring(eqPos + 1).trim(); + if (options) { + const parts = options.split(','); + if (parts.length >= 1 && parts[0]) { + mode = parts[0].trim().toUpperCase(); + } + if (parts.length >= 2 && parts[1]) { + minfreespace = parts[1].trim(); + } + } + } + + container.appendChild(UI.createBranchEntry(path, mode, minfreespace)); + }); + } catch (error) { + console.error('Failed to load branches:', error); + const container = document.getElementById('branches-container'); + container.innerHTML = '
Failed to load branches
'; + } + } + + async function saveBranches() { + if (!AppState.currentMount) { + showToast('No mount selected', 'error'); + return; + } + + const entries = document.querySelectorAll('.branch-entry'); + const branches = []; + + for (const entry of entries) { + const pathInput = entry.querySelector('.branch-path'); + const modeSelect = entry.querySelector('.branch-mode'); + const valueInput = entry.querySelector('.branch-minfreespace-value'); + const unitSelect = entry.querySelector('.branch-minfreespace-unit'); + + if (pathInput && pathInput.value.trim()) { + const pathValidation = Utils.validatePath(pathInput.value.trim()); + if (!pathValidation.valid) { + showToast(`Invalid path: ${pathValidation.message}`, 'error'); + pathInput.focus(); + return; + } + + if (valueInput.value.trim()) { + const spaceValidation = Utils.validateMinfreespace(valueInput.value.trim(), unitSelect.value); + if (!spaceValidation.valid) { + showToast(`Invalid minfreespace: ${spaceValidation.message}`, 'error'); + valueInput.focus(); + return; + } + } + + let branchStr = pathInput.value.trim(); + + if (modeSelect && modeSelect.value && modeSelect.value !== 'RW') { + branchStr += '=' + modeSelect.value; + } else { + branchStr += '=RW'; + } + + if (valueInput && valueInput.value.trim() && unitSelect) { + const value = valueInput.value.trim(); + const unit = unitSelect.value; + branchStr += ',' + value + unit; + } + + branches.push(branchStr); + } + } + + if (branches.length === 0) { + showToast('At least one branch is required', 'error'); + return; + } + + try { + await API.setBranches(AppState.currentMount, branches.join(':')); + showToast('Branch configuration saved successfully', 'success'); + await loadBranches(AppState.currentMount); + } catch (error) { + console.error('Failed to save branches:', error); + showToast('Failed to save branch configuration', 'error'); + } + } + + function addBranchEntry() { + const container = document.getElementById('branches-container'); + + const emptyState = container.querySelector('.empty-state'); + if (emptyState) { + emptyState.remove(); + } + + container.appendChild(UI.createBranchEntry()); + } + + function moveBranchUp(entry) { + const prev = entry.previousElementSibling; + if (prev && prev.classList.contains('branch-entry')) { + entry.parentNode.insertBefore(entry, prev); + } + } + + function moveBranchDown(entry) { + const next = entry.nextElementSibling; + if (next && next.classList.contains('branch-entry')) { + entry.parentNode.insertBefore(next, entry); + } + } + + // Drag and drop handlers + let draggedEntry = null; + + function handleDragStart(e) { + draggedEntry = this; + this.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/html', this.innerHTML); + } + + function handleDragEnd(e) { + this.classList.remove('dragging'); + document.querySelectorAll('.branch-entry').forEach(entry => { + entry.classList.remove('drag-over'); + }); + draggedEntry = null; + } + + function handleDragOver(e) { + if (e.preventDefault) { + e.preventDefault(); + } + e.dataTransfer.dropEffect = 'move'; + + if (this !== draggedEntry) { + this.classList.add('drag-over'); + } + return false; + } + + function handleDragLeave(e) { + this.classList.remove('drag-over'); + } + + function handleDrop(e) { + if (e.stopPropagation) { + e.stopPropagation(); + } + + this.classList.remove('drag-over'); + + if (draggedEntry !== this) { + const container = document.getElementById('branches-container'); + const allEntries = Array.from(container.querySelectorAll('.branch-entry')); + const draggedIndex = allEntries.indexOf(draggedEntry); + const targetIndex = allEntries.indexOf(this); + + if (draggedIndex < targetIndex) { + container.insertBefore(draggedEntry, this.nextSibling); + } else { + container.insertBefore(draggedEntry, this); + } + } + + return false; + } + + // Policy management + async function loadPolicies(mount) { + // Load current policy values from runtime configuration + try { + const policyKeys = ['category.create', 'category.search', 'category.action', 'func.readdir']; + const policyValues = {}; + + for (const key of policyKeys) { + try { + const value = await API.getXattr(mount, key); + policyValues[key] = value; + } catch (e) { + console.warn(`Failed to load policy ${key}:`, e); + policyValues[key] = getDefaultPolicy(key); + } + } + + // Update UI with loaded values + Object.entries(policyValues).forEach(([key, value]) => { + const selectId = `policy-${key.split('.').pop()}`; + const select = document.getElementById(selectId); + if (select) { + select.value = value; + } + }); + + AppState.policies = policyValues; + } catch (error) { + console.error('Failed to load policies:', error); + showToast('Failed to load policies', 'error'); + } + } + + function getDefaultPolicy(key) { + const defaults = { + 'category.create': 'pfrd', + 'category.search': 'ff', + 'category.action': 'epall', + 'func.readdir': 'seq' + }; + return defaults[key] || ''; + } + + async function savePolicies() { + if (!AppState.currentMount) { + showToast('No mount selected', 'error'); + return; + } + + const policyMappings = { + 'policy-create': 'category.create', + 'policy-search': 'category.search', + 'policy-action': 'category.action', + 'policy-readdir': 'func.readdir' + }; + + try { + for (const [selectId, policyKey] of Object.entries(policyMappings)) { + const select = document.getElementById(selectId); + if (select && select.value) { + await API.setXattr(AppState.currentMount, policyKey, select.value); + } + } + + showToast('Policy configuration saved successfully', 'success'); + await loadPolicies(AppState.currentMount); + } catch (error) { + console.error('Failed to save policies:', error); + showToast('Failed to save policy configuration', 'error'); + } + } + + // Configuration management + async function loadConfig(mount) { + const tbody = document.getElementById('config-tbody'); + UI.showLoading(tbody); + + try { + const config = await API.getConfig(mount); + AppState.config = config; + renderConfigTable(config); + } catch (error) { + console.error('Failed to load config:', error); + tbody.innerHTML = 'Failed to load configuration'; + } + } + + function renderConfigTable(config) { + const tbody = document.getElementById('config-tbody'); + tbody.innerHTML = ''; + + const priorityKeys = ['fsname', 'branches', 'version', 'minfreespace', 'moveonenospc']; + const orderedEntries = []; + + priorityKeys.forEach(key => { + if (config.hasOwnProperty(key)) { + orderedEntries.push([key, config[key]]); + delete config[key]; + } + }); + + for (const [key, value] of Object.entries(config)) { + orderedEntries.push([key, value]); + } + + const descriptions = { + 'fsname': 'Filesystem name', + 'branches': 'Branch paths and options', + 'version': 'mergerfs version', + 'minfreespace': 'Minimum free space for creation', + 'moveonenospc': 'Move file when no space left', + 'cache.files': 'File caching mode', + 'cache.attr': 'Attribute cache timeout', + 'cache.entry': 'Entry cache timeout', + 'category.create': 'File creation policy', + 'category.search': 'File search policy', + 'category.action': 'File action policy', + 'inodecalc': 'Inode calculation method', + 'threads': 'Number of worker threads' + }; + + orderedEntries.forEach(([key, value]) => { + const row = document.createElement('tr'); + + const keyCell = document.createElement('td'); + keyCell.textContent = key; + keyCell.style.fontFamily = 'monospace'; + keyCell.style.fontSize = '13px'; + + const valueCell = document.createElement('td'); + const input = document.createElement('input'); + input.type = 'text'; + input.value = value; + input.style.width = '100%'; + input.style.fontFamily = 'monospace'; + input.style.fontSize = '13px'; + + const debouncedSave = Utils.debounce(async () => { + try { + await API.setConfig(AppState.currentMount, key, input.value); + showToast(`Updated ${key}`, 'success', 2000); + } catch (error) { + showToast(`Failed to update ${key}`, 'error'); + input.value = value; + } + }, 1000); + + input.addEventListener('input', debouncedSave); + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + input.blur(); + } + }); + + valueCell.appendChild(input); + + const descCell = document.createElement('td'); + descCell.textContent = descriptions[key] || ''; + descCell.style.fontSize = '12px'; + descCell.style.color = 'var(--text-secondary)'; + + row.appendChild(keyCell); + row.appendChild(valueCell); + row.appendChild(descCell); + tbody.appendChild(row); + }); + + if (orderedEntries.length === 0) { + tbody.innerHTML = 'No configuration available'; + } + } + + // Command execution + async function executeCommand(command) { + if (!AppState.currentMount) { + showToast('No mount selected', 'error'); + return; + } + + try { + await API.executeCommand(AppState.currentMount, command); + showToast(`${command} command executed successfully`, 'success'); + } catch (error) { + console.error(`Failed to execute ${command}:`, error); + showToast(`Failed to execute ${command}`, 'error'); + } + } + + // File info + async function getFileInfo() { + const mount = AppState.currentMount; + const filePath = document.getElementById('file-path-input').value.trim(); + + if (!mount) { + showToast('No mount selected', 'error'); + return; + } + + if (!filePath) { + showToast('Please enter a file path', 'error'); + return; + } + + const content = document.getElementById('file-info-content'); + UI.showLoading(content); + + try { + const infoTypes = ['basepath', 'relpath', 'fullpath', 'allpaths']; + const info = {}; + + for (const type of infoTypes) { + try { + info[type] = await API.getFileInfo(mount, filePath, type); + } catch (e) { + info[type] = 'Not available'; + } + } + + content.innerHTML = ` +
+

File Information

+
+ Base Path: ${Utils.escapeHtml(info.basepath)} + Relative Path: ${Utils.escapeHtml(info.relpath)} + Full Path: ${Utils.escapeHtml(info.fullpath)} + All Paths: ${Utils.escapeHtml(info.allpaths)} +
+
+ `; + } catch (error) { + console.error('Failed to get file info:', error); + content.innerHTML = '
Failed to retrieve file information
'; + showToast('Failed to get file information', 'error'); + } + } + + // Modal management + function openPathModal(targetInput) { + AppState.pendingPathInput = targetInput; + UI.showModal('pathModal'); + loadAvailableMounts(); + } + + async function loadAvailableMounts() { + const mountList = document.getElementById('mount-list'); + UI.showLoading(mountList); + + try { + const mounts = await API.getAllMounts(); + const currentMount = AppState.currentMount; + + mountList.innerHTML = ''; + + const filteredMounts = mounts.filter(m => m.path !== currentMount); + + if (filteredMounts.length === 0) { + mountList.innerHTML = '
No other mount points available
'; + return; + } + + filteredMounts.forEach(mount => { + const item = document.createElement('div'); + item.className = 'mount-item'; + item.innerHTML = ` +
+
${Utils.escapeHtml(mount.path)}
+
+
${Utils.escapeHtml(mount.type)}
+ `; + + item.addEventListener('click', () => { + if (AppState.pendingPathInput) { + AppState.pendingPathInput.value = mount.path; + } + UI.hideModal('pathModal'); + AppState.pendingPathInput = null; + }); + + mountList.appendChild(item); + }); + } catch (error) { + console.error('Failed to load mounts:', error); + mountList.innerHTML = '
Failed to load mount points
'; + } + } + + // Search functionality + function initSearch() { + const searchInput = document.getElementById('config-search'); + if (searchInput) { + searchInput.addEventListener('input', Utils.debounce(() => { + const searchTerm = searchInput.value.toLowerCase(); + const rows = document.querySelectorAll('#config-tbody tr'); + + rows.forEach(row => { + const text = row.textContent.toLowerCase(); + row.style.display = text.includes(searchTerm) ? '' : 'none'; + }); + }, 300)); + } + + const mountSearchInput = document.getElementById('mount-search'); + if (mountSearchInput) { + mountSearchInput.addEventListener('input', Utils.debounce(() => { + const searchTerm = mountSearchInput.value.toLowerCase(); + const items = document.querySelectorAll('.mount-item'); + + items.forEach(item => { + const text = item.textContent.toLowerCase(); + item.style.display = text.includes(searchTerm) ? '' : 'none'; + }); + }, 300)); + } + } + + // Initialize application + async function initApp() { + try { + initTabs(); + initSearch(); + await loadMounts(); + + // Set up event listeners + document.getElementById('add-branch-btn').addEventListener('click', addBranchEntry); + document.getElementById('save-branches-btn').addEventListener('click', saveBranches); + document.getElementById('reset-branches-btn').addEventListener('click', () => loadBranches(AppState.currentMount)); + document.getElementById('save-policies-btn').addEventListener('click', savePolicies); + document.getElementById('reset-policies-btn').addEventListener('click', () => loadPolicies(AppState.currentMount)); + document.getElementById('export-config-btn').addEventListener('click', exportConfig); + document.getElementById('import-config-btn').addEventListener('click', importConfig); + document.getElementById('refresh-config-btn').addEventListener('click', () => loadConfig(AppState.currentMount)); + document.getElementById('get-file-info-btn').addEventListener('click', getFileInfo); + + // Command buttons + document.getElementById('cmd-gc').addEventListener('click', () => executeCommand('gc')); + document.getElementById('cmd-gc1').addEventListener('click', () => executeCommand('gc1')); + document.getElementById('cmd-invalidate').addEventListener('click', () => executeCommand('invalidate-all-nodes')); + + // Modal event listeners + document.getElementById('pathModalClose').addEventListener('click', () => UI.hideModal('pathModal')); + document.getElementById('pathModal').addEventListener('click', (e) => { + if (e.target.id === 'pathModal') { + UI.hideModal('pathModal'); + } + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + if (e.ctrlKey || e.metaKey) { + switch(e.key) { + case 's': + e.preventDefault(); + const activeTab = document.querySelector('.tab-button[aria-selected="true"]').getAttribute('aria-controls'); + if (activeTab === 'branches-panel') { + document.getElementById('save-branches-btn').click(); + } else if (activeTab === 'policies-panel') { + document.getElementById('save-policies-btn').click(); + } + break; + case 'e': + e.preventDefault(); + document.getElementById('export-config-btn').click(); + break; + case 'o': + e.preventDefault(); + document.getElementById('import-config-btn').click(); + break; + + } + } + + if (e.key === 'Escape') { + UI.hideModal('pathModal'); + } + }); + + + } catch (error) { + console.error('Failed to initialize application:', error); + showToast('Failed to initialize application', 'error'); + } + } + + // Export/Import functionality + function exportConfig() { + if (!AppState.currentMount) { + showToast('No mount selected', 'error'); + return; + } + + const config = { + mount: AppState.currentMount, + timestamp: new Date().toISOString(), + config: AppState.config, + policies: AppState.policies, + exportedBy: 'mergerfs Runtime Manager' + }; + + const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `mergerfs-config-${AppState.currentMount.replace(/[\/\\]/g, '-')}-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + showToast('Configuration exported successfully', 'success'); + } + + function importConfig() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + + input.addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + + try { + const text = await file.text(); + const config = JSON.parse(text); + + if (!config.config || !config.mount) { + throw new Error('Invalid configuration file'); + } + + showToast('Configuration imported successfully. Please review and save changes.', 'success'); + // Note: Actual import would require proper API implementation + } catch (error) { + console.error('Failed to import config:', error); + showToast('Failed to import configuration', 'error'); + } + }); + + input.click(); + } + + // Start the application when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initApp); + } else { + initApp(); + } + + diff --git a/src/mergerfs_webui.cpp b/src/mergerfs_webui.cpp index 1f439616..653c2662 100644 --- a/src/mergerfs_webui.cpp +++ b/src/mergerfs_webui.cpp @@ -6,14 +6,10 @@ #include "str.hpp" #include "CLI11.hpp" -#include "fmt/core.h" #include "httplib.h" -#include "json.hpp" #include - - -using json = nlohmann::json; +#include static void @@ -159,7 +155,7 @@ window.onload = () => { loadMounts(); }; )html"; res_.set_content(html, - "text/html"); + "text/html"); } #define IERT(S) if(type_ == (S)) return true; @@ -191,28 +187,26 @@ void _get_mounts(const httplib::Request &req_, httplib::Response &res_) { - json json_array; + std::string response = "["; std::string type; fs::MountVec mounts; + bool first = true; fs::mounts(mounts); - json_array = json::array(); for(const auto &mount : mounts) { - json obj; - if(not ::_valid_fs_type(mount.type)) continue; - obj["path"] = mount.dir; - obj["type"] = mount.type; - - json_array.push_back(obj); + if(!first) response += ","; + response += "{\"path\":\"" + mount.dir.string() + "\",\"type\":\"" + mount.type + "\"}"; + first = false; } - res_.set_content(json_array.dump(), - "application/json"); + response += "]"; + + res_.set_content(response, "application/json"); } static @@ -220,22 +214,26 @@ void _get_mounts_mergerfs(const httplib::Request &req_, httplib::Response &res_) { - json j; + std::string response = "["; std::string type; fs::MountVec mounts; + bool first = true; fs::mounts(mounts); - j = json::array(); for(const auto &mount : mounts) { if(mount.type != "fuse.mergerfs") continue; - j.push_back(mount.dir); + + if(!first) response += ","; + response += "\"" + mount.dir.string() + "\""; + first = false; } - res_.set_content(j.dump(), - "application/json"); + response += "]"; + + res_.set_content(response, "application/json"); } static @@ -251,18 +249,25 @@ _get_kvs(const httplib::Request &req_, return; } - json j; + std::string response = "{"; std::string mount; std::map kvs; + bool first = true; mount = req_.get_param_value("mount"); mergerfs::api::get_kvs(mount,&kvs); - j = kvs; + for(const auto &[key, value] : kvs) + { + if(!first) response += ","; + response += "\"" + key + "\":\"" + value + "\""; + first = false; + } - res_.set_content(j.dump(), - "application/json"); + response += "}"; + + res_.set_content(response, "application/json"); } static @@ -278,7 +283,6 @@ _get_kvs_key(const httplib::Request &req_, return; } - json j; fs::path mount; std::string key; std::string val; @@ -288,106 +292,201 @@ _get_kvs_key(const httplib::Request &req_, mergerfs::api::get_kv(mount,key,&val); - j = val; + res_.set_content(val, "text/plain"); +} + +// New endpoints for enhanced UI - res_.set_content(j.dump(), - "application/json"); +static +void +_get_branches(const httplib::Request &req_, + httplib::Response &res_) +{ + if(not req_.has_param("mount")) + { + res_.status = 400; + res_.set_content("{\"error\":\"mount param not set\"}", + "application/json"); + return; + } + + fs::path mount; + std::string val; + + mount = req_.get_param_value("mount"); + + if(mergerfs::api::get_kv(mount,"branches",&val) >= 0) + { + res_.set_content(val, "text/plain"); + } + else + { + res_.set_content("", "text/plain"); + } } static -json -_generate_error(const fs::path &mount_, - const std::string &key_, - const std::string &val_, - const int err_) +void +_post_branches(const httplib::Request &req_, + httplib::Response &res_) { - json rv; + if(not req_.has_param("mount")) + { + res_.status = 400; + res_.set_content("{\"error\":\"mount param not set\"}", + "application/json"); + return; + } - rv = json::object(); + fs::path mount; + std::string val; - rv["mount"] = mount_.string(); - rv["key"] = key_; - rv["value"] = val_; - switch(err_) + mount = req_.get_param_value("mount"); + val = req_.body; + + int rv = mergerfs::api::set_kv(mount,"branches",val); + + if(rv >= 0) + { + res_.set_content("{\"result\":\"success\"}", "application/json"); + } + else { - case -EROFS: - rv["msg"] = fmt::format("'{}' is read only",key_); - break; - case -EINVAL: - rv["msg"] = fmt::format("value '{}' is not valid for '{}'", - val_, - key_); - break; - case -EACCES: - rv["msg"] = fmt::format("mergerfs.webui (pid {}) is running as uid {}" - " which appears not to have access to modify the" - " mount's config", - ::getpid(), - ::getuid()); - break; - case -ENOTCONN: - rv["msg"] = fmt::format("Appears the mergerfs mount '{}' is broken. " - "mergerfs may have crashed.", - mount_.string()); - break; - default: - rv["msg"] = strerror(-err_); - break; + res_.status = 400; + res_.set_content("{\"result\":\"error\",\"message\":\"Failed to set branches\"}", "application/json"); } +} - return rv; +static +void +_get_xattr(const httplib::Request &req_, + httplib::Response &res_) +{ + if(not req_.has_param("mount")) + { + res_.status = 400; + res_.set_content("{\"error\":\"mount param not set\"}", + "application/json"); + return; + } + + fs::path mount; + std::string key; + std::string val; + + mount = req_.get_param_value("mount"); + key = req_.path_params.at("key"); + + int rv = mergerfs::api::get_kv(mount,key,&val); + + if(rv >= 0) + { + res_.set_content("\"" + val + "\"", "application/json"); + } + else + { + res_.status = 404; + res_.set_content("{\"error\":\"xattr not found\"}", "application/json"); + } } static void -_post_kvs_key(const httplib::Request &req_, - httplib::Response &res_) +_post_xattr(const httplib::Request &req_, + httplib::Response &res_) { if(not req_.has_param("mount")) { res_.status = 400; - res_.set_content("mount param not set", - "text/plain"); + res_.set_content("{\"error\":\"mount param not set\"}", + "application/json"); return; } - try + fs::path mount; + std::string key; + std::string val; + + mount = req_.get_param_value("mount"); + key = req_.path_params.at("key"); + val = req_.body; + + int rv = mergerfs::api::set_kv(mount,key,val); + + if(rv >= 0) { - int rv; - json j; - fs::path mount; - std::string key; - std::string val; - - j = json::parse(req_.body); - key = req_.path_params.at("key"); - val = j; - mount = req_.get_param_value("mount"); - - rv = mergerfs::api::set_kv(mount,key,val); - - j = json::object(); - if(rv >= 0) - { - res_.status = 200; - j["result"] = "success"; - } - else - { - res_.status = 400; - j["result"] = "error"; - j["error"] = ::_generate_error(mount,key,val,rv); - } + res_.set_content("{\"result\":\"success\"}", "application/json"); + } + else + { + res_.status = 400; + res_.set_content("{\"result\":\"error\",\"message\":\"Failed to set xattr\"}", "application/json"); + } +} - res_.set_content(j.dump(), +static +void +_execute_command(const httplib::Request &req_, + httplib::Response &res_) +{ + if(not req_.has_param("mount")) + { + res_.status = 400; + res_.set_content("{\"error\":\"mount param not set\"}", "application/json"); + return; + } + fs::path mount; + std::string cmd; + + mount = req_.get_param_value("mount"); + cmd = req_.path_params.at("command"); + + int rv = mergerfs::api::set_kv(mount,"cmd." + cmd,""); + + if(rv >= 0) + { + res_.set_content("{\"result\":\"success\",\"message\":\"Command executed\"}", "application/json"); + } + else + { + res_.status = 400; + res_.set_content("{\"result\":\"error\",\"message\":\"Failed to execute command\"}", "application/json"); + } +} + +static +void +_post_kvs_key(const httplib::Request &req_, + httplib::Response &res_) +{ + if(not req_.has_param("mount")) + { + res_.status = 400; + res_.set_content("{\"error\":\"mount param not set\"}", + "application/json"); + return; + } + + fs::path mount; + std::string key; + std::string val; + + key = req_.path_params.at("key"); + val = req_.body; + mount = req_.get_param_value("mount"); + + int rv = mergerfs::api::set_kv(mount,key,val); + + if(rv >= 0) + { + res_.set_content("{\"result\":\"success\"}", "application/json"); } - catch (const std::exception& e) + else { - fmt::print("{}\n",e.what()); res_.status = 400; - res_.set_content("Invalid JSON","text/plain"); + res_.set_content("{\"result\":\"error\",\"message\":\"Failed to set configuration\"}", "application/json"); } } @@ -433,7 +532,14 @@ mergerfs::webui::main(const int argc_, http_server.Get("/kvs/:key",::_get_kvs_key); http_server.Post("/kvs/:key",::_post_kvs_key); - fmt::print("host:port = http://{}:{}\n",host,port); + // New endpoints for enhanced UI + http_server.Get("/branches",::_get_branches); + http_server.Post("/branches",::_post_branches); + http_server.Get("/xattr/:key",::_get_xattr); + http_server.Post("/xattr/:key",::_post_xattr); + http_server.Post("/cmd/:command",::_execute_command); + + std::cout << "host:port = http://" << host << ":" << port << std::endl; http_server.listen(host,port);