mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-07-26 19:42:43 +02:00
ca2a6bacf9
New full-screen panel (main menu / FAB) for repeater administration: - repeaters table (saved per-pubkey admin password, login metadata); plaintext per observer_brokers precedent (single-user LAN app) - REST /api/repeaters: list merged with device contact truth, add (device REP contacts only), set/clear password, remove, login - device_manager: _repeater_lock serializes all repeater ops (companion firmware has a single pending-request slot); repeater_login now filters LOGIN_SUCCESS by pubkey_prefix and captures is_admin/ permissions into an in-memory session store - login timeout UX: wrong password and unreachable repeater are indistinguishable (firmware stays silent) - error message names both - panel UI: add-picker, password modal (remembered password), per- repeater path editor reusing /api/contacts/<pk>/paths + Leaflet repeater map picker (adapted from the DM panel) - meshcore pin bumped to >=2.3.7 (send_login_sync era API, fixed LOGIN_SUCCESS/LOGIN_FAILED parsing; container already ships 2.3.7) Stage 2 (management panel with Status/Telemetry/Neighbors/CLI/ Settings/Actions tools) follows after user review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
441 lines
20 KiB
HTML
441 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en" data-theme="light" data-bs-theme="light">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
<title>My Repeaters - mc-webui</title>
|
|
|
|
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
|
<script>
|
|
(function() {
|
|
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', t);
|
|
document.documentElement.setAttribute('data-bs-theme', t);
|
|
})();
|
|
</script>
|
|
|
|
<!-- Favicon -->
|
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
|
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="{{ url_for('static', filename='images/favicon-16x16.png') }}">
|
|
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
|
|
|
<!-- Bootstrap 5 CSS (local) -->
|
|
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
|
<!-- Bootstrap Icons (local) -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/bootstrap-icons/bootstrap-icons.css') }}">
|
|
<!-- Leaflet CSS (for the repeater map picker) -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
crossorigin="" />
|
|
<!-- Custom CSS -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
|
<!-- Theme CSS (light/dark mode) -->
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
|
|
|
<style>
|
|
/* Standalone page: allow normal scrolling (style.css sets overflow hidden) */
|
|
html, body {
|
|
overflow: auto !important;
|
|
height: 100%;
|
|
}
|
|
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background-color: var(--bg-body);
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.repeaters-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 1rem;
|
|
border-bottom: 1px solid var(--border-color);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.repeaters-list-wrap {
|
|
flex: 1 1 0;
|
|
min-height: 0;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
.repeater-row {
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.5rem;
|
|
padding: 0.75rem 1rem;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
.repeater-row .rpt-main {
|
|
cursor: pointer;
|
|
min-width: 0;
|
|
}
|
|
|
|
.repeater-row.rpt-offline .rpt-main {
|
|
cursor: default;
|
|
}
|
|
|
|
.repeater-row.rpt-offline {
|
|
opacity: 0.65;
|
|
}
|
|
|
|
.rpt-path {
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
color: var(--text-secondary, #6c757d);
|
|
padding: 3rem 1rem;
|
|
}
|
|
|
|
.empty-state i {
|
|
font-size: 3rem;
|
|
display: block;
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
/* Device repeater picker rows */
|
|
.device-rpt-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.device-rpt-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.device-rpt-item:hover {
|
|
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
|
}
|
|
|
|
.device-rpt-item.added {
|
|
opacity: 0.55;
|
|
cursor: default;
|
|
}
|
|
|
|
/* Path list items (adapted from DM Contact Info) */
|
|
.path-section-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.path-list-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.35rem 0.5rem;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 0.375rem;
|
|
margin-bottom: 0.35rem;
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.path-list-item.primary {
|
|
border-color: #ffc107;
|
|
}
|
|
|
|
.path-list-item .path-hex {
|
|
font-family: var(--bs-font-monospace, monospace);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.path-list-item .path-label {
|
|
color: var(--text-secondary, #6c757d);
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
flex: 1 1 auto;
|
|
min-width: 0;
|
|
}
|
|
|
|
.path-list-item .path-actions {
|
|
margin-left: auto;
|
|
display: flex;
|
|
gap: 0.4rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.path-uniqueness-warning {
|
|
font-size: 0.75rem;
|
|
color: #b58900;
|
|
}
|
|
|
|
.repeater-picker-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.35rem 0.5rem;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.repeater-picker-item:hover {
|
|
background: var(--hover-bg, rgba(0, 0, 0, 0.05));
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- Toolbar -->
|
|
<div class="repeaters-toolbar">
|
|
<span class="text-muted small flex-grow-1" id="repeaterCount"></span>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" id="refreshBtn" title="Refresh list">
|
|
<i class="bi bi-arrow-clockwise"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-sm btn-success" id="addRepeaterBtn">
|
|
<i class="bi bi-plus-lg"></i> Add repeater
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Repeater list -->
|
|
<div class="repeaters-list-wrap">
|
|
<div id="repeaterList"></div>
|
|
<div class="empty-state" id="emptyState" style="display: none;">
|
|
<i class="bi bi-diagram-3"></i>
|
|
<p class="mb-1">No repeaters yet.</p>
|
|
<p class="small mb-0">Use <strong>Add repeater</strong> to pick repeaters stored on your device.<br>
|
|
Only repeaters saved in the device contacts can be managed.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Repeater Picker Modal -->
|
|
<div class="modal fade" id="addRepeaterModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-plus-circle"></i> Add Repeater</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0 d-flex flex-column">
|
|
<div class="p-2 border-bottom">
|
|
<input type="text" class="form-control form-control-sm" id="deviceRptSearch"
|
|
placeholder="Search repeaters on device..." autocomplete="off">
|
|
</div>
|
|
<div id="deviceRptList" style="overflow-y: auto;">
|
|
<div class="text-muted small p-3">Loading device contacts...</div>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<span class="me-auto small text-muted" id="deviceRptCount"></span>
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password Modal (set password / login prompt) -->
|
|
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-key"></i> <span id="passwordModalTitle">Set password</span></h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-2 small text-muted" id="passwordModalInfo"></div>
|
|
<div class="input-group input-group-sm mb-2">
|
|
<input type="password" class="form-control" id="passwordInput"
|
|
placeholder="Repeater password" autocomplete="off">
|
|
<button type="button" class="btn btn-outline-secondary" id="togglePasswordBtn" title="Show/hide password">
|
|
<i class="bi bi-eye"></i>
|
|
</button>
|
|
</div>
|
|
<div class="form-check" id="savePasswordWrap">
|
|
<input class="form-check-input" type="checkbox" id="savePasswordCheck" checked>
|
|
<label class="form-check-label small" for="savePasswordCheck">
|
|
Remember password (stored in the app database)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Path Management Modal (per repeater) -->
|
|
<div class="modal fade" id="pathModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Paths — <span id="pathModalName"></span></h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body pb-1">
|
|
<div class="small text-muted mb-2">
|
|
Device path: <span class="font-monospace" id="pathModalCurrent">—</span>
|
|
</div>
|
|
<div class="path-section-header">
|
|
<h6 class="mb-0 small fw-bold">Configured paths</h6>
|
|
<button type="button" class="btn btn-outline-primary btn-sm" id="addPathBtn" title="Add path">
|
|
<i class="bi bi-plus-lg"></i>
|
|
</button>
|
|
</div>
|
|
<div id="pathList"></div>
|
|
<div class="d-flex justify-content-end gap-2 mt-1 mb-2">
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" id="clearPathsBtn"
|
|
title="Delete all configured paths from database">
|
|
<i class="bi bi-trash"></i> Clear Paths
|
|
</button>
|
|
<button type="button" class="btn btn-outline-danger btn-sm" id="resetFloodBtn"
|
|
title="Reset device path to FLOOD mode">
|
|
<i class="bi bi-broadcast"></i> Reset to FLOOD
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Path Modal (adapted from DM panel) -->
|
|
<div class="modal fade" id="addPathModal" tabindex="-1" style="z-index: 1070;">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-signpost-split"></i> Add Path</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1">Hash Size</label>
|
|
<div class="btn-group btn-group-sm w-100" role="group">
|
|
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
|
|
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
|
|
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
|
|
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
|
|
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
|
|
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
|
|
</div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1">Path (hex)</label>
|
|
<div class="input-group input-group-sm">
|
|
<input type="text" class="form-control font-monospace" id="pathHexInput"
|
|
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
|
|
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterBtn"
|
|
title="Pick repeater from list">
|
|
<i class="bi bi-plus-circle"></i>
|
|
</button>
|
|
<button type="button" class="btn btn-outline-secondary" id="pickRepeaterMapBtn"
|
|
title="Pick repeater from map">
|
|
<i class="bi bi-geo-alt"></i>
|
|
</button>
|
|
</div>
|
|
<div id="pathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
|
|
</div>
|
|
<!-- Repeater picker (hidden by default) -->
|
|
<div id="repeaterPicker" style="display: none;" class="border rounded mb-2">
|
|
<div class="d-flex border-bottom">
|
|
<div class="btn-group btn-group-sm flex-shrink-0" role="group">
|
|
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchName" value="name" checked>
|
|
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchName">Name</label>
|
|
<input type="radio" class="btn-check" name="repeaterSearchMode" id="rptSearchId" value="id">
|
|
<label class="btn btn-outline-secondary border-0 rounded-0" for="rptSearchId">ID</label>
|
|
</div>
|
|
<input type="text" class="form-control form-control-sm border-0"
|
|
id="repeaterSearch" placeholder="Search by name..." autocomplete="off">
|
|
</div>
|
|
<div id="repeaterList2" style="max-height: 180px; overflow-y: auto;"></div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<label class="form-label small mb-1">Label (optional)</label>
|
|
<input type="text" class="form-control form-control-sm" id="pathLabelInput"
|
|
placeholder="e.g. via Mountain RPT" maxlength="50">
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-sm btn-primary" id="savePathBtn">Add Path</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Repeater Map Picker Modal (adapted from DM panel) -->
|
|
<div class="modal fade" id="repeaterMapModal" tabindex="-1" style="z-index: 1080;">
|
|
<div class="modal-dialog modal-lg modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-geo-alt"></i> Select Repeater from Map</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom bg-light">
|
|
<div class="form-check form-switch mb-0">
|
|
<input class="form-check-input" type="checkbox" id="rptMapCachedSwitch">
|
|
<label class="form-check-label small" for="rptMapCachedSwitch">Cached</label>
|
|
</div>
|
|
<span class="text-muted small ms-auto" id="rptMapCount"></span>
|
|
</div>
|
|
<div id="rptLeafletMap" style="height: 400px; width: 100%;"></div>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<span class="me-auto small text-muted" id="rptMapSelected">Click a repeater on the map</span>
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Close</button>
|
|
<button type="button" class="btn btn-sm btn-primary" id="rptMapAddBtn" disabled>Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Remove Confirmation Modal -->
|
|
<div class="modal fade" id="removeRepeaterModal" tabindex="-1" aria-hidden="true">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content">
|
|
<div class="modal-header py-2">
|
|
<h6 class="modal-title"><i class="bi bi-trash"></i> Remove Repeater</h6>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p class="mb-1">Remove <strong id="removeRepeaterName"></strong> from My Repeaters?</p>
|
|
<p class="small text-muted mb-0">The saved password will be deleted.
|
|
The contact on the device is not affected.</p>
|
|
</div>
|
|
<div class="modal-footer py-2">
|
|
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
<button type="button" class="btn btn-sm btn-danger" id="removeRepeaterConfirmBtn">Remove</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Toast container for notifications (position applied by JS from ui_settings) -->
|
|
<div class="toast-container position-fixed top-0 start-0 p-3" data-toast-container>
|
|
<div id="notificationToast" class="toast" role="alert">
|
|
<div class="toast-header">
|
|
<strong class="me-auto">My Repeaters</strong>
|
|
<button type="button" class="btn-close" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
<div class="toast-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bootstrap JS Bundle (local) -->
|
|
<script src="{{ url_for('static', filename='vendor/bootstrap/js/bootstrap.bundle.min.js') }}"></script>
|
|
<!-- Leaflet JS -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
crossorigin=""></script>
|
|
<!-- My Repeaters JS -->
|
|
<script src="{{ url_for('static', filename='js/repeaters.js') }}"></script>
|
|
</body>
|
|
</html>
|