feat(repeaters): Repeater Management panel (stage 2)

/repeaters/manage?pubkey=... — per-repeater management panel opened
automatically after login from the My Repeaters list:
- header card: name, shortened pubkey with copy, current path,
  location, ADMIN/GUEST badge from the captured login session
- tools grid (Status / Telemetry / Neighbors / CLI / Settings /
  Actions) with pane placeholders; CLI+Settings+Actions are locked
  for guest logins (firmware accepts text CLI from admins only)
- auto-login with the saved password when the in-memory session is
  gone (e.g. after app restart), password-modal fallback with retry
- REST: GET /api/repeaters/<pk> (merged entry + session state),
  GET .../session, POST .../logout

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-07-18 07:24:52 +02:00
parent ca2a6bacf9
commit f288586ea6
5 changed files with 836 additions and 38 deletions
+107 -36
View File
@@ -5718,48 +5718,119 @@ def list_my_repeaters():
if success and contacts_detailed:
device_by_key = {k.lower(): v for k, v in contacts_detailed.items()}
repeaters = []
for row in rows:
pk = row['public_key'].lower()
details = device_by_key.get(pk)
entry = {
'public_key': pk,
'password_set': bool(row.get('password')),
'added_at': row.get('added_at'),
'last_login_at': row.get('last_login_at'),
'last_login_role': row.get('last_login_role'),
'on_device': details is not None,
'name': '',
'out_path_len': None,
'out_path': '',
'out_path_hash_mode': 0,
'path_or_mode': '',
'adv_lat': None,
'adv_lon': None,
'last_advert': None,
}
if details:
entry.update({
'name': details.get('adv_name', ''),
'out_path_len': details.get('out_path_len', -1),
'out_path': details.get('out_path', ''),
'out_path_hash_mode': details.get('out_path_hash_mode', 0),
'path_or_mode': _format_path_display(
details.get('out_path_len', -1),
details.get('out_path', ''),
details.get('out_path_hash_mode', 0)),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon'),
'last_advert': details.get('last_advert'),
})
repeaters.append(entry)
repeaters = [_merged_repeater_entry(row, device_by_key) for row in rows]
return jsonify({'success': True, 'repeaters': repeaters}), 200
except Exception as e:
logger.error(f"Error listing repeaters: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
def _merged_repeater_entry(row, device_by_key):
"""Merge a repeaters DB row with device contact truth for API output."""
pk = row['public_key'].lower()
details = device_by_key.get(pk)
entry = {
'public_key': pk,
'password_set': bool(row.get('password')),
'added_at': row.get('added_at'),
'last_login_at': row.get('last_login_at'),
'last_login_role': row.get('last_login_role'),
'on_device': details is not None,
'name': '',
'out_path_len': None,
'out_path': '',
'out_path_hash_mode': 0,
'path_or_mode': '',
'adv_lat': None,
'adv_lon': None,
'last_advert': None,
}
if details:
entry.update({
'name': details.get('adv_name', ''),
'out_path_len': details.get('out_path_len', -1),
'out_path': details.get('out_path', ''),
'out_path_hash_mode': details.get('out_path_hash_mode', 0),
'path_or_mode': _format_path_display(
details.get('out_path_len', -1),
details.get('out_path', ''),
details.get('out_path_hash_mode', 0)),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon'),
'last_advert': details.get('last_advert'),
})
return entry
def _repeater_session_payload(dm, pk):
"""Session dict for API output ({'logged_in': False} when absent)."""
session = dm.get_repeater_session(pk) if dm else None
if not session:
return {'logged_in': False}
return {
'logged_in': True,
'is_admin': session.get('is_admin', False),
'permissions': session.get('permissions'),
'logged_in_at': session.get('logged_in_at'),
}
@api_bp.route('/repeaters/<public_key>', methods=['GET'])
def get_my_repeater(public_key):
"""Single saved repeater merged with device truth + login session state."""
db = _get_db()
if not db:
return jsonify({'success': False, 'error': 'Database not available'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
try:
row = db.get_repeater(pk)
if not row:
return jsonify({'success': False, 'error': 'Repeater not in list'}), 404
device_by_key = {}
success, contacts_detailed, _error = get_contacts_detailed_cached()
if success and contacts_detailed:
device_by_key = {k.lower(): v for k, v in contacts_detailed.items()}
return jsonify({
'success': True,
'repeater': _merged_repeater_entry(row, device_by_key),
'session': _repeater_session_payload(_get_dm(), pk),
}), 200
except Exception as e:
logger.error(f"Error getting repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters/<public_key>/session', methods=['GET'])
def get_my_repeater_session(public_key):
"""Login session state for a repeater (in-memory; cleared on app restart)."""
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
return jsonify({'success': True, **_repeater_session_payload(_get_dm(), pk)}), 200
@api_bp.route('/repeaters/<public_key>/logout', methods=['POST'])
def logout_my_repeater(public_key):
"""Log out of a repeater and drop the in-memory session."""
dm = _get_dm()
if not dm:
return jsonify({'success': False, 'error': 'Device not connected'}), 503
pk = _normalize_repeater_key(public_key)
if not pk:
return jsonify({'success': False, 'error': 'Invalid public_key'}), 400
try:
result = dm.repeater_logout(pk)
if result.get('success'):
return jsonify({'success': True}), 200
return jsonify({'success': False,
'error': result.get('error', 'Logout failed')}), _repeater_result_status(result)
except Exception as e:
logger.error(f"Error logging out of repeater: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@api_bp.route('/repeaters', methods=['POST'])
def add_my_repeater():
"""Add a device repeater contact to the My Repeaters list."""
+9
View File
@@ -112,6 +112,15 @@ def repeaters():
)
@views_bp.route('/repeaters/manage')
def repeater_manage():
"""Repeater Management panel for one repeater (?pubkey=<64 hex>)."""
return render_template(
'repeater-manage.html',
device_name=runtime_config.get_device_name()
)
@views_bp.route('/logs')
def logs():
"""System log viewer - real-time log streaming with filters."""
+411
View File
@@ -0,0 +1,411 @@
// Repeater Management panel (one repeater, after login)
// Loaded as /repeaters/manage?pubkey=<64 hex> inside the My Repeaters iframe.
// ================================================================
// UI settings + toast (same behavior as repeaters.js)
// ================================================================
const RPT_UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const RPT_TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const RPT_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
window.uiSettingsCache = window.uiSettingsCache || { ...RPT_UI_SETTINGS_DEFAULTS };
function applyToastPosition(position) {
const classes = RPT_TOAST_POSITION_CLASSES[position] || RPT_TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
RPT_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
async function loadUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = { ...RPT_UI_SETTINGS_DEFAULTS, ...data };
applyToastPosition(window.uiSettingsCache.toast_position);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
function showNotification(message, type = 'info') {
const toastEl = document.getElementById('notificationToast');
if (!toastEl) return;
const toastBody = toastEl.querySelector('.toast-body');
if (toastBody) {
toastBody.textContent = message;
}
const toastHeader = toastEl.querySelector('.toast-header');
if (toastHeader) {
toastHeader.className = 'toast-header';
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'danger') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
}
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
function esc(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
// ================================================================
// Tools configuration
// ================================================================
const TOOLS = [
{ key: 'status', icon: 'bi-bar-chart-line', title: 'Status',
desc: 'Battery, radio and packet statistics', adminOnly: false },
{ key: 'telemetry', icon: 'bi-activity', title: 'Telemetry',
desc: 'Sensor channels (Cayenne LPP)', adminOnly: false },
{ key: 'neighbors', icon: 'bi-people', title: 'Neighbors',
desc: 'Zero-hop repeaters heard', adminOnly: false },
{ key: 'cli', icon: 'bi-terminal', title: 'CLI',
desc: 'Send text commands to the repeater', adminOnly: true },
{ key: 'settings', icon: 'bi-gear', title: 'Settings',
desc: 'Configure repeater parameters', adminOnly: true },
{ key: 'actions', icon: 'bi-lightning', title: 'Actions',
desc: 'Advert, clock sync, reboot', adminOnly: true },
];
// ================================================================
// State
// ================================================================
let _pubkey = null;
let _repeater = null; // merged entry from GET /api/repeaters/<pk>
let _session = null; // {logged_in, is_admin, ...}
let _passwordModal = null;
// ================================================================
// State screens
// ================================================================
function showLoading(text) {
document.getElementById('loadingState').style.display = '';
document.getElementById('loadingText').textContent = text || 'Loading…';
document.getElementById('errorState').style.display = 'none';
document.getElementById('panelContent').style.display = 'none';
}
function showError(text) {
document.getElementById('loadingState').style.display = 'none';
document.getElementById('errorState').style.display = '';
document.getElementById('errorText').textContent = text || 'Something went wrong.';
document.getElementById('panelContent').style.display = 'none';
}
function showPanel() {
document.getElementById('loadingState').style.display = 'none';
document.getElementById('errorState').style.display = 'none';
document.getElementById('panelContent').style.display = '';
document.getElementById('logoutBtn').classList.remove('d-none');
renderHeader();
renderTools();
showToolsGrid();
}
function goBackToList() {
window.location.href = '/repeaters';
}
// ================================================================
// Header + tools rendering
// ================================================================
function shortPubkey(pk) {
return `${pk.substring(0, 12)}${pk.substring(pk.length - 8)}`;
}
function renderHeader() {
const r = _repeater;
document.getElementById('rptName').textContent = r.name || r.public_key.substring(0, 12);
document.getElementById('rptPubkey').textContent = `<${shortPubkey(r.public_key)}>`;
document.getElementById('rptPath').textContent = r.path_or_mode || '—';
const loc = (r.adv_lat != null && r.adv_lon != null && (r.adv_lat !== 0 || r.adv_lon !== 0))
? `${r.adv_lat.toFixed(4)}, ${r.adv_lon.toFixed(4)}`
: '—';
document.getElementById('rptLocation').textContent = loc;
const badge = document.getElementById('roleBadge');
if (_session && _session.logged_in) {
const admin = !!_session.is_admin;
badge.textContent = admin ? 'ADMIN' : 'GUEST';
badge.className = 'badge ' + (admin ? 'bg-success' : 'bg-secondary');
} else {
badge.textContent = '';
badge.className = 'badge';
}
}
function renderTools() {
const row = document.getElementById('toolsRow');
row.innerHTML = '';
const isAdmin = !!(_session && _session.is_admin);
TOOLS.forEach(tool => {
const locked = tool.adminOnly && !isAdmin;
const col = document.createElement('div');
col.className = 'col-12 col-sm-6 col-lg-4';
col.innerHTML = `
<div class="tool-tile${locked ? ' disabled' : ''}" data-tool="${tool.key}"
${locked ? 'title="Admin login required"' : ''}>
<div class="tool-icon ${tool.key}"><i class="bi ${tool.icon}"></i></div>
<div class="flex-grow-1" style="min-width: 0;">
<h6>${esc(tool.title)}${locked ? ' <i class="bi bi-lock-fill small text-muted"></i>' : ''}</h6>
<p class="tool-desc">${esc(tool.desc)}</p>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
`;
const tile = col.querySelector('.tool-tile');
tile.addEventListener('click', () => {
if (locked) {
showNotification('Admin login required for this tool', 'warning');
return;
}
openToolPane(tool);
});
row.appendChild(col);
});
}
// ================================================================
// Tool panes
// ================================================================
function showToolsGrid() {
document.getElementById('toolsGrid').style.display = '';
document.getElementById('toolPane').style.display = 'none';
}
function openToolPane(tool) {
document.getElementById('toolsGrid').style.display = 'none';
const pane = document.getElementById('toolPane');
pane.style.display = '';
const icon = document.getElementById('paneIcon');
icon.className = `tool-icon ${tool.key}`;
icon.style.width = '32px';
icon.style.height = '32px';
icon.style.fontSize = '1rem';
icon.innerHTML = `<i class="bi ${tool.icon}"></i>`;
document.getElementById('paneTitle').textContent = tool.title;
const body = document.getElementById('paneBody');
body.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">The <strong>${esc(tool.title)}</strong> tool is coming in a later stage.</p>
</div>
`;
}
// ================================================================
// Login flow
// ================================================================
async function fetchRepeater() {
const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load repeater');
}
_repeater = data.repeater;
_session = data.session;
}
async function doLogin(password, save) {
const name = (_repeater && _repeater.name) || 'repeater';
showLoading(`Logging in to ${name}… (may take up to 60 s on flood paths)`);
let data = null;
try {
const body = {};
if (password) {
body.password = password;
body.save = !!save;
}
const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
data = await response.json();
} catch (e) {
console.error('Login request failed:', e);
data = { success: false, error: 'Login request failed' };
}
if (data && data.success) {
_session = {
logged_in: true,
is_admin: !!data.is_admin,
permissions: data.permissions
};
const role = data.is_admin ? 'ADMIN' : 'GUEST';
showNotification(`Logged in as ${role}`, 'success');
showPanel();
} else {
const error = (data && data.error) || 'Login failed';
openPasswordModal(error);
}
}
function openPasswordModal(errorHint = '') {
// Keep the loading screen behind the modal but stop the spinner text
showLoading('Waiting for password…');
const name = (_repeater && _repeater.name) || _pubkey.substring(0, 12);
document.getElementById('passwordModalTitle').textContent = `Log in — ${name}`;
const info = document.getElementById('passwordModalInfo');
info.innerHTML = errorHint
? `<span class="text-danger">${esc(errorHint)}</span><br>Check the password and try again.`
: 'Enter the repeater password to log in.';
const input = document.getElementById('passwordInput');
input.value = '';
input.type = 'password';
document.getElementById('savePasswordCheck').checked = true;
_passwordModal.show();
setTimeout(() => input.focus(), 300);
}
async function submitPasswordModal() {
const input = document.getElementById('passwordInput');
const password = input.value;
if (!password) {
showNotification('Password cannot be empty', 'warning');
return;
}
const save = document.getElementById('savePasswordCheck').checked;
_passwordModal.hide();
await doLogin(password, save);
}
async function logout() {
const logoutBtn = document.getElementById('logoutBtn');
logoutBtn.disabled = true;
try {
const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/logout`, { method: 'POST' });
const data = await response.json();
if (!data.success) {
showNotification(data.error || 'Logout failed', 'danger');
logoutBtn.disabled = false;
return;
}
} catch (e) {
console.error('Logout failed:', e);
}
goBackToList();
}
// ================================================================
// Init
// ================================================================
async function init() {
const params = new URLSearchParams(window.location.search);
_pubkey = (params.get('pubkey') || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(_pubkey)) {
showError('Invalid repeater public key in URL.');
return;
}
showLoading('Loading…');
try {
await fetchRepeater();
} catch (e) {
showError(e.message);
return;
}
if (!_repeater.on_device) {
showError('This repeater is not stored on the device — it cannot be managed.');
return;
}
if (_session && _session.logged_in) {
showPanel();
} else if (_repeater.password_set) {
// Saved password: log in automatically (e.g. after app restart)
await doLogin(null, false);
} else {
openPasswordModal();
}
}
document.addEventListener('DOMContentLoaded', () => {
_passwordModal = new bootstrap.Modal(document.getElementById('passwordModal'));
loadUiSettings();
document.getElementById('backBtn').addEventListener('click', goBackToList);
document.getElementById('errorBackBtn').addEventListener('click', goBackToList);
document.getElementById('errorRetryBtn').addEventListener('click', init);
document.getElementById('logoutBtn').addEventListener('click', logout);
document.getElementById('paneBackBtn').addEventListener('click', showToolsGrid);
document.getElementById('passwordSubmitBtn').addEventListener('click', submitPasswordModal);
document.getElementById('passwordCancelBtn').addEventListener('click', () => {
_passwordModal.hide();
goBackToList();
});
document.getElementById('passwordInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitPasswordModal();
}
});
document.getElementById('togglePasswordBtn').addEventListener('click', () => {
const input = document.getElementById('passwordInput');
input.type = input.type === 'password' ? 'text' : 'password';
});
document.getElementById('copyPubkeyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(_repeater ? _repeater.public_key : _pubkey);
showNotification('Public key copied', 'info');
} catch (e) {
showNotification('Copy failed', 'warning');
}
});
init();
});
+2 -2
View File
@@ -273,8 +273,8 @@ async function doLogin(pubkey, password, save) {
if (data && data.success) {
const role = data.is_admin ? 'ADMIN' : 'GUEST';
showNotification(`Logged in to ${r.name || 'repeater'} as ${role}`, 'success');
// Stage 2 will navigate to the management panel here.
await loadRepeaters();
window.location.href = `/repeaters/manage?pubkey=${encodeURIComponent(pubkey)}`;
return;
} else {
await loadRepeaters();
const error = (data && data.error) || 'Login failed';
+307
View File
@@ -0,0 +1,307 @@
<!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>Repeater Management - 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') }}">
<!-- 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);
}
.manage-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.manage-content {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
padding: 1rem;
}
/* Header card */
.rpt-header-card {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.rpt-header-icon {
width: 56px;
height: 56px;
border-radius: 50%;
background: rgba(25, 135, 84, 0.12);
border: 2px solid #198754;
color: #198754;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.6rem;
flex-shrink: 0;
}
.rpt-header-meta {
font-size: 0.85rem;
color: var(--text-secondary, #6c757d);
}
.rpt-pubkey {
font-family: var(--bs-font-monospace, monospace);
font-size: 0.8rem;
}
/* Tools grid */
.tool-tile {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
display: flex;
align-items: center;
gap: 0.85rem;
cursor: pointer;
transition: box-shadow 0.15s;
height: 100%;
}
.tool-tile:hover {
box-shadow: var(--card-shadow-hover, 0 2px 8px rgba(0, 0, 0, 0.15));
}
.tool-tile.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.tool-tile.disabled:hover {
box-shadow: none;
}
.tool-icon {
width: 44px;
height: 44px;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.tool-icon.status { background: rgba(13, 110, 253, 0.12); color: #0d6efd; }
.tool-icon.telemetry { background: rgba(111, 66, 193, 0.12); color: #6f42c1; }
.tool-icon.neighbors { background: rgba(25, 135, 84, 0.12); color: #198754; }
.tool-icon.cli { background: rgba(255, 143, 0, 0.12); color: #e65100; }
.tool-icon.settings { background: rgba(220, 53, 69, 0.12); color: #dc3545; }
.tool-icon.actions { background: rgba(13, 202, 240, 0.12); color: #0aa2c0; }
.tool-tile h6 {
margin: 0;
font-weight: 600;
}
.tool-tile .tool-desc {
font-size: 0.8rem;
color: var(--text-secondary, #6c757d);
margin: 0;
}
/* Tool pane */
.tool-pane-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.tool-pane-body {
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 0.5rem;
padding: 1rem;
}
/* Centered state screens */
.state-screen {
text-align: center;
padding: 3rem 1rem;
color: var(--text-secondary, #6c757d);
}
.state-screen i {
font-size: 3rem;
display: block;
margin-bottom: 0.75rem;
}
</style>
</head>
<body>
<!-- Toolbar -->
<div class="manage-toolbar">
<button type="button" class="btn btn-sm btn-outline-secondary" id="backBtn" title="Back to My Repeaters">
<i class="bi bi-arrow-left"></i>
</button>
<span class="fw-semibold flex-grow-1">Repeater Management</span>
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="logoutBtn" title="Log out of this repeater">
<i class="bi bi-box-arrow-right"></i> Logout
</button>
</div>
<div class="manage-content">
<!-- Loading / logging-in state -->
<div class="state-screen" id="loadingState">
<div class="spinner-border text-success" role="status" style="width: 3rem; height: 3rem;"></div>
<p class="mt-3 mb-0" id="loadingText">Loading…</p>
</div>
<!-- Error state -->
<div class="state-screen" id="errorState" style="display: none;">
<i class="bi bi-exclamation-triangle text-warning"></i>
<p class="mb-3" id="errorText">Something went wrong.</p>
<div class="d-flex gap-2 justify-content-center">
<button type="button" class="btn btn-sm btn-outline-secondary" id="errorBackBtn">
<i class="bi bi-arrow-left"></i> Back to list
</button>
<button type="button" class="btn btn-sm btn-primary" id="errorRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
</button>
</div>
</div>
<!-- Panel content (after login) -->
<div id="panelContent" style="display: none;">
<!-- Header card -->
<div class="rpt-header-card">
<div class="rpt-header-icon"><i class="bi bi-diagram-3"></i></div>
<div class="flex-grow-1" style="min-width: 0;">
<div class="d-flex align-items-center gap-2 flex-wrap">
<h5 class="mb-0 text-truncate" id="rptName"></h5>
<span class="badge" id="roleBadge"></span>
</div>
<div class="rpt-pubkey text-muted mt-1">
<span id="rptPubkey"></span>
<button type="button" class="btn btn-link btn-sm p-0 ms-1 align-baseline" id="copyPubkeyBtn" title="Copy full public key">
<i class="bi bi-copy"></i>
</button>
</div>
<div class="rpt-header-meta mt-1">
<i class="bi bi-signpost-split"></i> <span class="font-monospace" id="rptPath"></span>
<span class="ms-2"><i class="bi bi-geo-alt"></i> <span id="rptLocation"></span></span>
</div>
</div>
</div>
<!-- Tools grid -->
<div id="toolsGrid">
<div class="text-muted small text-uppercase fw-bold mb-2">Management Tools</div>
<div class="row g-3" id="toolsRow"></div>
</div>
<!-- Tool pane (shown instead of the grid when a tool is open) -->
<div id="toolPane" style="display: none;">
<div class="tool-pane-header">
<button type="button" class="btn btn-sm btn-outline-secondary" id="paneBackBtn" title="Back to tools">
<i class="bi bi-arrow-left"></i>
</button>
<span class="tool-icon" id="paneIcon" style="width: 32px; height: 32px; font-size: 1rem;"></span>
<h6 class="mb-0" id="paneTitle"></h6>
</div>
<div class="tool-pane-body" id="paneBody"></div>
</div>
</div>
</div>
<!-- Password Modal (login prompt) -->
<div class="modal fade" id="passwordModal" tabindex="-1" aria-hidden="true" data-bs-backdrop="static">
<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">Log in</span></h6>
</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">
<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" id="passwordCancelBtn">Back to list</button>
<button type="button" class="btn btn-sm btn-primary" id="passwordSubmitBtn">Log in</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">Repeater Management</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>
<!-- Repeater Management JS -->
<script src="{{ url_for('static', filename='js/repeater-manage.js') }}"></script>
</body>
</html>