mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-06 16:53:21 +02:00
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:
+107
-36
@@ -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."""
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 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();
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user