mirror of https://github.com/trapexit/mergerfs.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2269 lines
80 KiB
2269 lines
80 KiB
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>mergerfs Runtime Configuration Manager</title>
|
|
<meta name="description" content="Advanced runtime configuration and management tool for mergerfs filesystem">
|
|
<link rel="icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAACf0lEQVQ4EY3BW0jTYRgH4N/7fv9tmkYlBBFRBCW1ESGSdeGFYZIT7Ihi6rSkoNNFUkaHmwVRiRFZBCnSAUlpohZmdiExoiBUgsSiQkQiEknISplu/+97szFjBUbPQ/D72ZPgzbENT7w/s+EF/kNZR1YmKTPvbs+zblpb3XODoA4JkZDRV9+e2nAc/1DekX0ZoEqwQMTUMQn5IOY2iW4Aq2Pu6p7TmENZ25YqaKoUMfdEzB0wlzCIEgS6/22o8xBEtwrUeffF3gr8pbg1xwfwRQPz5ON8UyGE1wS4GBBE+f0m/OmLj0SCYLrpqe7dhpg9gexcBWoATC++jxUGNwdtxDDiDF7Pmw5P2TshMmAEzZ5LLzMLAlszFDsDAIYiDju/sax/EnEYswoCylPTVzz4+XWIWPJAMuJKGH+gxPlIgG8adu5IMsbLO7JK0uvSHYgiMGJS05atFqFG98q0+jcnM0ZTEvv2rVjcsghQymb2Nu1++nH5D64XqMZVSxPd+EUEFmJEE0EZEpjy9Ve6wgtSujOIdUgTbW/Z1TlQ1Oq9pLW9VykDpQ3BiShGPCIwyX0i3h8KL3NP6QVFLYXtz/ObfZXaJFUJ6CFmRBBDACMeAaz4GsMUhKcWbnlcWvso+9aJUlun1EzrpDptpi8QkTgwi2AhDgkAAl5VetswI6vBnwtwgy2h9q53i46WrBtOB4QwSwAmxCP8QVwbBdxkfZ0ohd9vEEWIZ4lAYEBOFSGBA4jgt+CB0+cQhy0SAolhiyzDBCKxCHgPZR02cOwgkYiIDGEO9iSGVTIiLnbWArQEQh9Y23IQECLIGoE+PHB20yjm0FwcHNPaHIGhVBiIRPTBn4WjGt9/EpzPAAAAAElFTkSuQmCC">
|
|
<style>
|
|
:root {
|
|
--bg-primary: #1a1a1a;
|
|
--bg-secondary: #2d2d2d;
|
|
--bg-tertiary: #1e1e1e;
|
|
--bg-hover: #3d3d3d;
|
|
--bg-input: #333;
|
|
--border-color: #444;
|
|
--text-primary: #e0e0e0;
|
|
--text-secondary: #aaa;
|
|
--accent-primary: #1565c0;
|
|
--accent-hover: #1976d2;
|
|
--success-color: #2e7d32;
|
|
--success-hover: #388e3c;
|
|
--danger-color: #8b0000;
|
|
--danger-hover: #a00000;
|
|
--warning-color: #f57c00;
|
|
--radius: 4px;
|
|
--transition: all 0.2s ease;
|
|
--shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
body {
|
|
background-color: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
line-height: 1.6;
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
header {
|
|
background-color: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
padding: 20px 0;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.header-content {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 0 20px;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.header-content {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 0 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 24px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.subtitle {
|
|
color: var(--text-secondary);
|
|
font-size: 14px;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
|
|
|
|
.tabs {
|
|
display: flex;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius) var(--radius) 0 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.tab-button {
|
|
background: none;
|
|
border: none;
|
|
padding: 15px 25px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: var(--transition);
|
|
position: relative;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.tab-button:hover {
|
|
background-color: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.tab-button.active {
|
|
color: var(--text-primary);
|
|
background-color: var(--bg-tertiary);
|
|
}
|
|
|
|
.tab-button.active::after {
|
|
content: '';
|
|
position: absolute;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 2px;
|
|
background-color: var(--accent-primary);
|
|
}
|
|
|
|
.tab-button:focus {
|
|
outline: 2px solid var(--accent-primary);
|
|
outline-offset: -2px;
|
|
}
|
|
|
|
.tab-content {
|
|
display: none;
|
|
background-color: var(--bg-tertiary);
|
|
border: 1px solid var(--border-color);
|
|
border-top: none;
|
|
border-radius: 0 0 var(--radius) var(--radius);
|
|
padding: 25px;
|
|
min-height: 500px;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.controls-section {
|
|
margin-bottom: 25px;
|
|
}
|
|
|
|
.controls-row {
|
|
display: flex;
|
|
gap: 15px;
|
|
align-items: flex-end;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.form-group label {
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
color: var(--text-secondary);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
|
|
select, input[type="text"], input[type="number"], input[type="search"] {
|
|
background-color: var(--bg-input);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
padding: 10px 12px;
|
|
border-radius: var(--radius);
|
|
font-size: 14px;
|
|
transition: var(--transition);
|
|
min-width: 200px;
|
|
}
|
|
|
|
select:focus, input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-primary);
|
|
box-shadow: 0 0 0 2px rgba(21, 101, 192, 0.2);
|
|
}
|
|
|
|
.button {
|
|
background-color: var(--accent-primary);
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
transition: var(--transition);
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.button:hover {
|
|
background-color: var(--accent-hover);
|
|
}
|
|
|
|
.button:focus {
|
|
outline: 2px solid var(--accent-primary);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.button:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.button.success {
|
|
background-color: var(--success-color);
|
|
}
|
|
|
|
.button.success:hover {
|
|
background-color: var(--success-hover);
|
|
}
|
|
|
|
.button.danger {
|
|
background-color: var(--danger-color);
|
|
}
|
|
|
|
.button.danger:hover {
|
|
background-color: var(--danger-hover);
|
|
}
|
|
|
|
.button.secondary {
|
|
background-color: var(--bg-input);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.button.secondary:hover {
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
.action-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.search-container {
|
|
position: relative;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.search-input {
|
|
width: 100%;
|
|
padding-left: 40px;
|
|
}
|
|
|
|
.search-icon {
|
|
position: absolute;
|
|
left: 12px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.table-container {
|
|
overflow-x: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
background-color: var(--bg-secondary);
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th, td {
|
|
padding: 12px;
|
|
text-align: left;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
th {
|
|
background-color: var(--bg-secondary);
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-secondary);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 10;
|
|
}
|
|
|
|
tr:hover {
|
|
background-color: var(--bg-hover);
|
|
}
|
|
|
|
tr:last-child td {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.branches-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
}
|
|
|
|
.branch-entry {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 15px;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
transition: var(--transition);
|
|
position: relative;
|
|
}
|
|
|
|
.branch-path-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
flex: 1;
|
|
min-width: 250px;
|
|
}
|
|
|
|
.branch-entry:hover {
|
|
background-color: var(--bg-hover);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
.branch-entry.dragging {
|
|
opacity: 0.5;
|
|
cursor: grabbing;
|
|
}
|
|
|
|
.branch-entry.drag-over {
|
|
border-color: var(--accent-primary);
|
|
box-shadow: 0 0 0 2px rgba(21, 101, 192, 0.2);
|
|
}
|
|
|
|
.branch-path {
|
|
flex: 1;
|
|
}
|
|
|
|
.branch-mode {
|
|
width: 100px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.branch-minfreespace {
|
|
display: flex;
|
|
gap: 0;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.branch-minfreespace input {
|
|
border-radius: var(--radius) 0 0 var(--radius);
|
|
width: 80px;
|
|
text-align: center;
|
|
}
|
|
|
|
.branch-minfreespace select {
|
|
border-radius: 0 var(--radius) var(--radius) 0;
|
|
width: 80px;
|
|
}
|
|
|
|
.branch-controls {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
align-items: center;
|
|
}
|
|
|
|
.icon-button {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
padding: 8px;
|
|
border-radius: var(--radius);
|
|
transition: var(--transition);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.icon-button:hover {
|
|
background-color: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.icon-button:focus {
|
|
outline: 2px solid var(--accent-primary);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.icon-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.browse-button {
|
|
min-width: 80px;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.modal {
|
|
display: none;
|
|
position: fixed;
|
|
z-index: 1000;
|
|
left: 0;
|
|
top: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background-color: rgba(0,0,0,0.7);
|
|
animation: fadeIn 0.2s ease;
|
|
}
|
|
|
|
.modal.show {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
@keyframes fadeIn {
|
|
from { opacity: 0; }
|
|
to { opacity: 1; }
|
|
}
|
|
|
|
.modal-content {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 30px;
|
|
max-width: 600px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
animation: slideUp 0.2s ease;
|
|
}
|
|
|
|
@keyframes slideUp {
|
|
from { transform: translateY(20px); opacity: 0; }
|
|
to { transform: translateY(0); opacity: 1; }
|
|
}
|
|
|
|
.modal-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.modal-title {
|
|
margin: 0;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
color: var(--text-secondary);
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
width: 30px;
|
|
height: 30px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
border-radius: var(--radius);
|
|
transition: var(--transition);
|
|
}
|
|
|
|
.modal-close:hover {
|
|
background-color: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.modal-close:focus {
|
|
outline: 2px solid var(--accent-primary);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
.mount-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
}
|
|
|
|
.mount-item {
|
|
padding: 12px;
|
|
background-color: var(--bg-tertiary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
cursor: pointer;
|
|
transition: var(--transition);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.mount-item:hover {
|
|
background-color: var(--bg-hover);
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.mount-item.selected {
|
|
background-color: var(--accent-primary);
|
|
border-color: var(--accent-primary);
|
|
}
|
|
|
|
.mount-path {
|
|
font-family: monospace;
|
|
font-size: 13px;
|
|
}
|
|
|
|
.mount-type {
|
|
font-size: 11px;
|
|
color: var(--text-secondary);
|
|
background-color: var(--bg-input);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
padding: 15px 20px;
|
|
border-radius: var(--radius);
|
|
color: white;
|
|
font-weight: 500;
|
|
z-index: 2000;
|
|
max-width: 400px;
|
|
box-shadow: var(--shadow);
|
|
animation: slideInRight 0.3s ease;
|
|
display: none;
|
|
}
|
|
|
|
.toast.show {
|
|
display: block;
|
|
}
|
|
|
|
.toast.success {
|
|
background-color: var(--success-color);
|
|
}
|
|
|
|
.toast.error {
|
|
background-color: var(--danger-color);
|
|
}
|
|
|
|
.toast.warning {
|
|
background-color: var(--warning-color);
|
|
}
|
|
|
|
@keyframes slideInRight {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
|
|
.loading-spinner {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid transparent;
|
|
border-top: 2px solid currentColor;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
|
|
.help-text {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.tooltip {
|
|
position: relative;
|
|
display: inline-block;
|
|
}
|
|
|
|
.tooltip .tooltiptext {
|
|
visibility: hidden;
|
|
width: 200px;
|
|
background-color: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
text-align: center;
|
|
border-radius: var(--radius);
|
|
padding: 8px;
|
|
position: absolute;
|
|
z-index: 1;
|
|
bottom: 125%;
|
|
left: 50%;
|
|
margin-left: -100px;
|
|
opacity: 0;
|
|
transition: opacity 0.3s;
|
|
border: 1px solid var(--border-color);
|
|
font-size: 12px;
|
|
}
|
|
|
|
.tooltip:hover .tooltiptext {
|
|
visibility: visible;
|
|
opacity: 1;
|
|
}
|
|
|
|
.branches-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 12px 15px;
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
margin-bottom: 15px;
|
|
font-weight: 600;
|
|
font-size: 12px;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.header-path { flex: 1; min-width: 250px; }
|
|
.header-mode { width: 100px; text-align: center; }
|
|
.header-minfreespace { width: 160px; text-align: center; }
|
|
.header-actions { width: 120px; text-align: center; }
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 15px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
.validation-error {
|
|
color: var(--danger-color);
|
|
font-size: 12px;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
/* Policy-specific styles */
|
|
.policy-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
}
|
|
|
|
.policy-card {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 20px;
|
|
}
|
|
|
|
.policy-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
margin-bottom: 10px;
|
|
color: var(--accent-primary);
|
|
}
|
|
|
|
.policy-description {
|
|
font-size: 13px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.policy-select {
|
|
width: 100%;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.command-section {
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.command-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 15px;
|
|
}
|
|
|
|
.command-card {
|
|
background-color: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: var(--radius);
|
|
padding: 15px;
|
|
text-align: center;
|
|
}
|
|
|
|
.command-title {
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.command-description {
|
|
font-size: 12px;
|
|
color: var(--text-secondary);
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
/* Responsive Design */
|
|
@media (max-width: 768px) {
|
|
.container {
|
|
padding: 10px;
|
|
}
|
|
|
|
.header-content {
|
|
flex-direction: column;
|
|
gap: 15px;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.controls-row {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.tabs {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.tab-button {
|
|
padding: 12px 15px;
|
|
}
|
|
|
|
.branch-entry {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
gap: 10px;
|
|
}
|
|
|
|
.branch-path-group,
|
|
.branch-path,
|
|
.branch-mode,
|
|
.branch-minfreespace,
|
|
.branch-controls {
|
|
width: 100%;
|
|
}
|
|
|
|
.branch-minfreespace {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.branch-minfreespace input,
|
|
.branch-minfreespace select {
|
|
flex: 1;
|
|
}
|
|
|
|
.branches-header {
|
|
display: none;
|
|
}
|
|
|
|
.modal-content {
|
|
padding: 20px;
|
|
margin: 10px;
|
|
}
|
|
|
|
.toast {
|
|
right: 10px;
|
|
left: 10px;
|
|
max-width: none;
|
|
}
|
|
|
|
.policy-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.command-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.action-buttons {
|
|
flex-direction: column;
|
|
}
|
|
|
|
.button {
|
|
width: 100%;
|
|
justify-content: center;
|
|
}
|
|
}
|
|
|
|
/* High contrast mode support */
|
|
@media (prefers-contrast: high) {
|
|
:root {
|
|
--border-color: #ffffff;
|
|
--text-secondary: #ffffff;
|
|
}
|
|
}
|
|
|
|
/* Reduced motion support */
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* {
|
|
animation-duration: 0.01ms !important;
|
|
animation-iteration-count: 1 !important;
|
|
transition-duration: 0.01ms !important;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<div class="header-content">
|
|
<h1>mergerfs ui</h1>
|
|
<div class="subtitle">Advanced configuration and management tool</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="container">
|
|
<div class="tabs" role="tablist">
|
|
<button class="tab-button active" role="tab" aria-selected="true" aria-controls="branches-panel" id="branches-tab" tabindex="0">
|
|
Branches
|
|
</button>
|
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="policies-panel" id="policies-tab" tabindex="-1">
|
|
Policies
|
|
</button>
|
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="config-panel" id="config-tab" tabindex="-1">
|
|
Configuration
|
|
</button>
|
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="commands-panel" id="commands-tab" tabindex="-1">
|
|
Commands
|
|
</button>
|
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="info-panel" id="info-tab" tabindex="-1">
|
|
File Info
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Branches Tab -->
|
|
<div class="tab-content active" id="branches-panel" role="tabpanel" aria-labelledby="branches-tab">
|
|
<div class="controls-section">
|
|
<div class="controls-row">
|
|
<div class="form-group">
|
|
<label for="mount-select-branches">Mount Point</label>
|
|
<select id="mount-select-branches">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="action-buttons">
|
|
<button class="button" id="add-branch-btn">
|
|
<span>+</span> Add Branch
|
|
</button>
|
|
<button class="button success" id="save-branches-btn">
|
|
<span>💾</span> Save Configuration
|
|
</button>
|
|
<button class="button secondary" id="reset-branches-btn">
|
|
<span>↺</span> Reset
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="branches-header">
|
|
<div class="header-path">Branch Path</div>
|
|
<div class="header-mode">Mode</div>
|
|
<div class="header-minfreespace">Min Free Space</div>
|
|
<div class="header-actions">Actions</div>
|
|
</div>
|
|
|
|
<div class="branches-container" id="branches-container">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📁</div>
|
|
<p>No branches configured</p>
|
|
<p class="help-text">Click "Add Branch" to get started</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Policies Tab -->
|
|
<div class="tab-content" id="policies-panel" role="tabpanel" aria-labelledby="policies-tab">
|
|
<div class="controls-section">
|
|
<div class="controls-row">
|
|
<div class="form-group">
|
|
<label for="mount-select-policies">Mount Point</label>
|
|
<select id="mount-select-policies">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="action-buttons">
|
|
<button class="button success" id="save-policies-btn">
|
|
<span>💾</span> Save Policies
|
|
</button>
|
|
<button class="button secondary" id="reset-policies-btn">
|
|
<span>↺</span> Reset to Defaults
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="policy-grid" id="policy-grid">
|
|
<div class="policy-card">
|
|
<div class="policy-title">Create Category</div>
|
|
<div class="policy-description">
|
|
Controls how new files and directories are created. Default: pfrd (percentage free random distribution)
|
|
</div>
|
|
<select class="policy-select" id="policy-create">
|
|
<option value="pfrd">pfrd - Percentage Free Random Distribution</option>
|
|
<option value="mfs">mfs - Most Free Space</option>
|
|
<option value="lfs">lfs - Least Free Space</option>
|
|
<option value="lus">lus - Least Used Space</option>
|
|
<option value="rand">rand - Random</option>
|
|
<option value="ff">ff - First Found</option>
|
|
<option value="epmfs">epmfs - Existing Path Most Free Space</option>
|
|
<option value="eprand">eprand - Existing Path Random</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="policy-card">
|
|
<div class="policy-title">Search Category</div>
|
|
<div class="policy-description">
|
|
Controls how files are searched and accessed. Default: ff (first found)
|
|
</div>
|
|
<select class="policy-select" id="policy-search">
|
|
<option value="ff">ff - First Found</option>
|
|
<option value="newest">newest - Newest File</option>
|
|
<option value="rand">rand - Random</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="policy-card">
|
|
<div class="policy-title">Action Category</div>
|
|
<div class="policy-description">
|
|
Controls how file attributes are modified. Default: epall (existing path all)
|
|
</div>
|
|
<select class="policy-select" id="policy-action">
|
|
<option value="epall">epall - Existing Path All</option>
|
|
<option value="all">all - All Branches</option>
|
|
<option value="epff">epff - Existing Path First Found</option>
|
|
<option value="ff">ff - First Found</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="policy-card">
|
|
<div class="policy-title">Readdir Policy</div>
|
|
<div class="policy-description">
|
|
Controls how directory contents are read. Default: seq (sequential)
|
|
</div>
|
|
<select class="policy-select" id="policy-readdir">
|
|
<option value="seq">seq - Sequential</option>
|
|
<option value="cosr">cosr - Concurrent Open Sequential Read</option>
|
|
<option value="cor">cor - Concurrent Open and Read</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-text" style="margin-top: 20px;">
|
|
<strong>Policy Categories:</strong><br>
|
|
• Create: mkdir, create, mknod, symlink<br>
|
|
• Search: access, getattr, getxattr, ioctl, listxattr, open, readlink<br>
|
|
• Action: chmod, chown, link, removexattr, rename, rmdir, setxattr, truncate, unlink, utimens<br>
|
|
• Readdir: Directory listing operations
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Configuration Tab -->
|
|
<div class="tab-content" id="config-panel" role="tabpanel" aria-labelledby="config-tab">
|
|
<div class="controls-section">
|
|
<div class="controls-row">
|
|
<div class="form-group">
|
|
<label for="mount-select-config">Mount Point</label>
|
|
<select id="mount-select-config">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="action-buttons">
|
|
<button class="button" id="export-config-btn">
|
|
<span>📥</span> Export
|
|
</button>
|
|
<button class="button" id="import-config-btn">
|
|
<span>📤</span> Import
|
|
</button>
|
|
<button class="button secondary" id="refresh-config-btn">
|
|
<span>🔄</span> Refresh
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-container">
|
|
<span class="search-icon">🔍</span>
|
|
<input type="search" class="search-input" id="config-search" placeholder="Search configuration options...">
|
|
</div>
|
|
|
|
<div class="table-container">
|
|
<table id="config-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Option</th>
|
|
<th>Current Value</th>
|
|
<th>Description</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="config-tbody">
|
|
<tr>
|
|
<td colspan="3" class="empty-state">Loading configuration...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Commands Tab -->
|
|
<div class="tab-content" id="commands-panel" role="tabpanel" aria-labelledby="commands-tab">
|
|
<div class="controls-section">
|
|
<div class="controls-row">
|
|
<div class="form-group">
|
|
<label for="mount-select-commands">Mount Point</label>
|
|
<select id="mount-select-commands">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="command-section">
|
|
<h3 style="margin-bottom: 15px;">Runtime Commands</h3>
|
|
<div class="command-grid">
|
|
<div class="command-card">
|
|
<div class="command-title">Garbage Collection</div>
|
|
<div class="command-description">
|
|
Trigger thorough garbage collection of mergerfs resources
|
|
</div>
|
|
<button class="button" id="cmd-gc">Execute</button>
|
|
</div>
|
|
|
|
<div class="command-card">
|
|
<div class="command-title">Quick GC</div>
|
|
<div class="command-description">
|
|
Trigger simple garbage collection (runs periodically)
|
|
</div>
|
|
<button class="button" id="cmd-gc1">Execute</button>
|
|
</div>
|
|
|
|
<div class="command-card">
|
|
<div class="command-title">Invalidate Cache</div>
|
|
<div class="command-description">
|
|
Invalidate all FUSE node caches (for debugging)
|
|
</div>
|
|
<button class="button warning" id="cmd-invalidate">Execute</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="help-text" style="margin-top: 20px;">
|
|
<strong>Commands:</strong><br>
|
|
• GC: Comprehensive cleanup of internal caches and resources<br>
|
|
• Quick GC: Lightweight cleanup that runs automatically every ~15 minutes<br>
|
|
• Invalidate: Forces FUSE to release cached file information (use for debugging)
|
|
</div>
|
|
</div>
|
|
|
|
<!-- File Info Tab -->
|
|
<div class="tab-content" id="info-panel" role="tabpanel" aria-labelledby="info-tab">
|
|
<div class="controls-section">
|
|
<div class="controls-row">
|
|
<div class="form-group">
|
|
<label for="mount-select-info">Mount Point</label>
|
|
<select id="mount-select-info">
|
|
<option value="">Loading...</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="file-path-input">File Path</label>
|
|
<input type="text" id="file-path-input" placeholder="/path/to/file/in/mergerfs">
|
|
</div>
|
|
<div class="action-buttons">
|
|
<button class="button" id="get-file-info-btn">
|
|
<span>🔍</span> Get Info
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="file-info-content">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📄</div>
|
|
<p>Enter a file path to get mergerfs-specific information</p>
|
|
<p class="help-text">Information includes basepath, relpath, fullpath, and allpaths</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Path Selection Modal -->
|
|
<div class="modal" id="pathModal" role="dialog" aria-labelledby="pathModalTitle" aria-hidden="true">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h2 class="modal-title" id="pathModalTitle">Select Mount Path</h2>
|
|
<button class="modal-close" id="pathModalClose" aria-label="Close modal">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="search-container">
|
|
<span class="search-icon">🔍</span>
|
|
<input type="search" class="search-input" id="mount-search" placeholder="Search mount points...">
|
|
</div>
|
|
<div class="mount-list" id="mount-list">
|
|
<div class="empty-state">Loading available mount points...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast Notification -->
|
|
<div class="toast" id="toast" role="alert" aria-live="polite"></div>
|
|
|
|
<script>
|
|
// 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 = '<div class="loading-spinner"></div> 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 = '<div class="empty-state">Failed to load branches</div>';
|
|
}
|
|
}
|
|
|
|
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 = '<tr><td colspan="3" class="empty-state">Failed to load configuration</td></tr>';
|
|
}
|
|
}
|
|
|
|
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 = '<tr><td colspan="3" class="empty-state">No configuration available</td></tr>';
|
|
}
|
|
}
|
|
|
|
// 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 = `
|
|
<div style="background: var(--bg-secondary); padding: 20px; border-radius: var(--radius); border: 1px solid var(--border-color);">
|
|
<h3 style="margin-top: 0; color: var(--accent-primary);">File Information</h3>
|
|
<div style="display: grid; grid-template-columns: auto 1fr; gap: 10px; font-family: monospace; font-size: 13px;">
|
|
<strong>Base Path:</strong> <span>${Utils.escapeHtml(info.basepath)}</span>
|
|
<strong>Relative Path:</strong> <span>${Utils.escapeHtml(info.relpath)}</span>
|
|
<strong>Full Path:</strong> <span>${Utils.escapeHtml(info.fullpath)}</span>
|
|
<strong>All Paths:</strong> <span style="word-break: break-all;">${Utils.escapeHtml(info.allpaths)}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} catch (error) {
|
|
console.error('Failed to get file info:', error);
|
|
content.innerHTML = '<div class="empty-state">Failed to retrieve file information</div>';
|
|
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 = '<div class="empty-state">No other mount points available</div>';
|
|
return;
|
|
}
|
|
|
|
filteredMounts.forEach(mount => {
|
|
const item = document.createElement('div');
|
|
item.className = 'mount-item';
|
|
item.innerHTML = `
|
|
<div>
|
|
<div class="mount-path">${Utils.escapeHtml(mount.path)}</div>
|
|
</div>
|
|
<div class="mount-type">${Utils.escapeHtml(mount.type)}</div>
|
|
`;
|
|
|
|
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 = '<div class="empty-state">Failed to load mount points</div>';
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|