+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ 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 +
+ + • 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 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Option | +Current Value | +Description | +
|---|---|---|
| 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) +
+ + • 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 = '
+
+ `;
+ } catch (error) {
+ console.error('Failed to get file info:', error);
+ 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)}
+
+ 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