fix(i18n): finish repeater management, contacts, path analyzer (stage 9d)

An end-of-stage-9 sweep over every JS file found ~85 user-facing strings that
stages 3-5 never extracted, because their browser diffs only ever opened the
states those pages start in.

Most of it is repeater-manage.js: the entire login flow (prompt, progress,
failure hint, password modal), the reboot and radio-change confirmations, the
"Updated Xs ago" labels and fmtHeardAgo, the Status/Telemetry/Neighbours/CLI
tool chrome and the Status table's section headings and row labels. Plus
contacts.js (auto-cleanup status, push/move confirmations, bulk progress
counters, QR error), path-analyzer.js (map popups, table tooltips, the map
toggles) and two strings in repeaters.js.

Row labels in the Status table go through t(), not tHtml(): statusSection()
already runs esc() over the title and every label, so tHtml would escape twice.

Protocol terms stay English as before: flood/direct in the packet counters,
the CLI quick commands and the Cayenne LPP sensor type names.

Verified with F:\tmp\pwverify\i18n-stage9d.js. Two strings come back identical
and both are correct: "Hop" is glossary, and "Repeater not in list" is backend
error text, which is out of scope by decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-08-01 15:20:00 +02:00
parent 1ee7f0703d
commit fbd6820df0
6 changed files with 319 additions and 117 deletions
+16 -16
View File
@@ -407,7 +407,7 @@ function applyCleanupSettingsToUI(settings) {
if (statusText) {
if (settings.enabled) {
const hourStr = hour.toString().padStart(2, '0');
statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`;
statusText.textContent = t('contacts.cleanup.auto_enabled', { time: `${hourStr}:00`, tz: cleanupTimezone });
statusText.classList.remove('text-muted');
statusText.classList.add('text-success');
} else {
@@ -532,7 +532,7 @@ async function saveCleanupSettings(enabled) {
if (data.settings.enabled) {
const savedHour = data.settings.hour !== undefined ? data.settings.hour : 1;
const hourStr = savedHour.toString().padStart(2, '0');
statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`;
statusText.textContent = t('contacts.cleanup.auto_enabled', { time: `${hourStr}:00`, tz: cleanupTimezone });
statusText.classList.remove('text-muted');
statusText.classList.add('text-success');
} else {
@@ -553,7 +553,7 @@ async function saveCleanupSettings(enabled) {
if (autoCleanupSettings.enabled) {
const prevHour = autoCleanupSettings.hour !== undefined ? autoCleanupSettings.hour : 1;
const hourStr = prevHour.toString().padStart(2, '0');
statusText.textContent = `Enabled (runs daily at ${hourStr}:00 ${cleanupTimezone})`;
statusText.textContent = t('contacts.cleanup.auto_enabled', { time: `${hourStr}:00`, tz: cleanupTimezone });
} else {
statusText.textContent = t('common.disabled');
}
@@ -740,9 +740,9 @@ async function handleCleanupConfirm() {
if (modal) modal.hide();
// Show success message
let message = `Cleanup completed: ${data.deleted_count} deleted`;
let message = t('contacts.cleanup.completed', { count: data.deleted_count });
if (data.failed_count > 0) {
message += `, ${data.failed_count} failed`;
message += t('contacts.cleanup.completed_failed', { count: data.failed_count });
}
showToast(message, data.failed_count > 0 ? 'warning' : 'success');
@@ -763,7 +763,7 @@ async function handleCleanupConfirm() {
}
} catch (error) {
console.error('Error during cleanup:', error);
showToast('Network error during cleanup', 'danger');
showToast(t('contacts.cleanup.network_error'), 'danger');
} finally {
// Re-enable button
confirmBtn.disabled = false;
@@ -1272,8 +1272,8 @@ function renderPendingList(contacts) {
emptyDiv.className = 'empty-state';
emptyDiv.innerHTML = `
<i class="bi bi-funnel"></i>
<p class="mb-0">No contacts match filters</p>
<small class="text-muted">Try changing your filter criteria</small>
<p class="mb-0">${tHtml('contacts.filter.no_match')}</p>
<small class="text-muted">${tHtml('contacts.filter.no_match_hint')}</small>
`;
listEl.appendChild(emptyDiv);
return;
@@ -1336,7 +1336,7 @@ function createContactCard(contact, index) {
lastAdvertDiv = document.createElement('div');
lastAdvertDiv.className = 'text-muted small';
const relativeTime = formatRelativeTime(contact.last_advert);
lastAdvertDiv.textContent = `Last seen: ${relativeTime}`;
lastAdvertDiv.textContent = t('repeaters.map.last_seen', { time: relativeTime });
}
// Action buttons
@@ -1559,7 +1559,7 @@ async function batchApproveContacts() {
// Update button with progress
if (confirmBtn) {
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> Approving ${i + 1}/${filteredPendingContacts.length}...`;
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> ${tHtml('contacts.progress.approving', { done: i + 1, total: filteredPendingContacts.length })}`;
}
try {
@@ -1667,7 +1667,7 @@ async function batchIgnoreContacts() {
const contact = filteredPendingContacts[i];
if (confirmBtn) {
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> Ignoring ${i + 1}/${filteredPendingContacts.length}...`;
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> ${tHtml('contacts.progress.ignoring', { done: i + 1, total: filteredPendingContacts.length })}`;
}
try {
@@ -2445,7 +2445,7 @@ async function confirmDelete() {
// =============================================================================
async function pushContactToDevice(contact) {
if (!confirm(`Push "${contact.name}" to device?`)) return;
if (!confirm(t('contacts.confirm.push', { name: contact.name }))) return;
try {
const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, {
@@ -2454,7 +2454,7 @@ async function pushContactToDevice(contact) {
});
const data = await response.json();
if (data.success) {
showToast(data.message || `${contact.name} pushed to device`, 'success');
showToast(data.message || t('contacts.toast.pushed', { name: contact.name }), 'success');
setTimeout(() => loadExistingContacts(), 500);
} else {
showToast(data.error || t('contacts.toast.push_failed'), 'danger');
@@ -2465,7 +2465,7 @@ async function pushContactToDevice(contact) {
}
async function moveContactToCache(contact) {
if (!confirm(`Move "${contact.name}" from device to cache?`)) return;
if (!confirm(t('contacts.confirm.to_cache', { name: contact.name }))) return;
try {
const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, {
@@ -2474,7 +2474,7 @@ async function moveContactToCache(contact) {
});
const data = await response.json();
if (data.success) {
showToast(data.message || `${contact.name} moved to cache`, 'success');
showToast(data.message || t('contacts.toast.to_cache', { name: contact.name }), 'success');
setTimeout(() => loadExistingContacts(), 500);
} else {
showToast(data.error || t('contacts.toast.move_failed'), 'danger');
@@ -2644,7 +2644,7 @@ function onQrCodeSuccess(decodedText) {
return;
}
showQrError('QR code does not contain a valid meshcore:// URI.');
showQrError(t('contacts.add.qr_invalid_uri'));
}
function showQrError(msg) {
+10 -10
View File
@@ -226,7 +226,7 @@ function paBuildEchoLine(msg, echo, echoIdx) {
const chip = document.createElement('span');
chip.className = 'pa-chip';
chip.textContent = tok;
chip.title = `Copy repeater hash ${tok}`;
chip.title = t('pa.copy_hash_title', { hash: tok });
chip.addEventListener('click', (e) => {
e.stopPropagation();
paCopyText(tok, t('pa.repeater_hash'));
@@ -457,7 +457,7 @@ function paRenderStats() {
for (const s of rows) {
const tr = document.createElement('tr');
tr.className = 'pa-stats-row';
tr.title = `Filter messages by ${s.token}`;
tr.title = t('pa.filter_by_title', { token: s.token });
const tdToken = document.createElement('td');
tdToken.innerHTML = `<span class="pa-hash">${s.token}</span>`;
@@ -576,7 +576,7 @@ function paRenderRoutes() {
const names = r.tokens.map(tok => {
const cands = paMatchContacts(tok);
if (cands.length === 1) return cands[0].name || '—';
return cands.length > 1 ? `ambiguous (${cands.length})` : '—';
return cands.length > 1 ? t('pa.ambiguous', { count: cands.length }) : '—';
});
if (names.some(nm => nm !== '—')) {
const nameLine = document.createElement('div');
@@ -610,7 +610,7 @@ function paRenderRoutes() {
if (rows.length === 0) {
document.getElementById('paEmptyText').textContent =
filtered.length === 0 ? t('pa.empty_filtered')
: `No paths with at least ${n} hops in the current selection.`;
: t('pa.no_paths_min_hops', { count: n });
paSetView('empty');
} else {
paSetView('routes');
@@ -674,8 +674,8 @@ function paAddMapToggles() {
ctl.onAdd = () => {
const box = L.DomUtil.create('div', 'pa-map-toggles');
box.innerHTML =
'<label><input type="checkbox" id="paToggleRepeaters"> All repeaters</label>' +
'<label><input type="checkbox" id="paToggleAltPaths"> Alternative paths</label>';
`<label><input type="checkbox" id="paToggleRepeaters"> ${tHtml('pa.toggle_all_repeaters')}</label>` +
`<label><input type="checkbox" id="paToggleAltPaths"> ${tHtml('pa.toggle_alt_paths')}</label>`;
L.DomEvent.disableClickPropagation(box);
L.DomEvent.disableScrollPropagation(box);
return box;
@@ -747,7 +747,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) {
if (primary) {
L.circleMarker([origin.adv_lat, origin.adv_lon], {
radius: 7, color: '#198754', weight: 2, fillOpacity: 0.8
}).bindPopup(`<strong>${origin.name}</strong><br>Origin (sender)`)
}).bindPopup(`<strong>${origin.name}</strong><br>${tHtml('pa.map.origin')}`)
.bindTooltip(origin.name, { permanent: true, direction: 'right', offset: [8, 0], className: 'pa-hop-label' })
.addTo(layer);
}
@@ -759,7 +759,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) {
if (contact) {
if (primary) {
L.marker([contact.adv_lat, contact.adv_lon], { icon: paHopIcon(i + 1) })
.bindPopup(`<strong>${contact.name}</strong><br>Hop ${i + 1}: <code>${tok}</code>`)
.bindPopup(`<strong>${contact.name}</strong><br>${tHtml('pa.map.hop', { n: i + 1, token: `<code>${tok}</code>` })}`)
.bindTooltip(contact.name, { permanent: true, direction: 'right', offset: [13, 0], className: 'pa-hop-label' })
.addTo(layer);
}
@@ -771,7 +771,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) {
candidates.forEach(c => {
L.circleMarker([c.adv_lat, c.adv_lon], {
radius: 6, color: '#d39e00', weight: 2, fillOpacity: 0.5, dashArray: '3'
}).bindPopup(`<strong>${c.name}</strong><br>Candidate for hop ${i + 1}: <code>${tok}</code>`).addTo(layer);
}).bindPopup(`<strong>${c.name}</strong><br>${tHtml('pa.map.candidate', { n: i + 1, token: `<code>${tok}</code>` })}`).addTo(layer);
});
}
gapPending = true;
@@ -783,7 +783,7 @@ function paDrawEcho(msg, echo, primary, seen, altColor) {
// only once - an alternative then shows exactly where it diverges instead of
// hiding under the primary route.
const snr = (echo.snr === null || echo.snr === undefined) ? '?' : `${Number(echo.snr).toFixed(1)} dB`;
const popup = `<strong>Alternative path</strong><br>${echo.tokens.join(' → ')}<br>${snr}`;
const popup = `<strong>${tHtml('pa.map.alt_path')}</strong><br>${echo.tokens.join(' → ')}<br>${snr}`;
let drawn = 0;
for (let i = 1; i < points.length; i++) {
const key = [String(points[i - 1].latlng), String(points[i].latlng)].sort().join('|');
+91 -89
View File
@@ -186,7 +186,7 @@ function renderTools() {
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"' : ''}>
${locked ? `title="${tHtml('rptmgmt.admin_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(t(tool.title))}${locked ? ' <i class="bi bi-lock-fill small text-muted"></i>' : ''}</h6>
@@ -304,8 +304,8 @@ function renderStatusPane(body) {
body.innerHTML = `
<div class="d-flex align-items-center mb-2">
<span class="text-muted small flex-grow-1" id="statusUpdated"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="statusRefreshBtn" title="Refresh status">
<i class="bi bi-arrow-clockwise"></i> Refresh
<button type="button" class="btn btn-sm btn-outline-secondary" id="statusRefreshBtn" title="${tHtml('rptmgmt.status.refresh_title')}">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
</div>
<div id="statusContainer"></div>
@@ -324,8 +324,8 @@ function setStatusUpdatedLabel() {
if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; }
const secs = Math.floor((Date.now() - _statusUpdatedAt) / 1000);
if (secs < 2) label.textContent = t('rptmgmt.updated_just_now');
else if (secs < 60) label.textContent = `Updated ${secs}s ago`;
else label.textContent = `Updated ${fmtDuration(secs)} ago`;
else if (secs < 60) label.textContent = t('rptmgmt.updated_ago', { time: `${secs}s` });
else label.textContent = t('rptmgmt.updated_ago', { time: fmtDuration(secs) });
};
tick();
_statusTimer = setInterval(tick, 5000);
@@ -339,7 +339,7 @@ async function loadStatus() {
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">Requesting status from the repeater…</p>
<p class="mt-2 mb-0">${tHtml('rptmgmt.status.loading')}</p>
</div>
`;
@@ -358,7 +358,7 @@ async function loadStatus() {
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.status_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="statusRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.try_again')}
</button>
</div>
`;
@@ -393,26 +393,30 @@ function renderStatusTable(container, s) {
? (((s.airtime + s.rx_airtime) / s.uptime) * 100).toFixed(2) + '%'
: '—';
const system = statusSection('System Information', [
['Battery', batStr],
['Uptime', fmtDuration(s.uptime)],
['Clock', '<span id="statusClock" class="text-muted">…</span>'],
['Queue length', fmtInt(s.tx_queue_len)],
['Debug / error events', fmtInt(s.full_evts)],
// Row labels use t(), not tHtml(): statusSection() runs esc() over the
// title and every label, so a pre-escaped value would be escaped twice.
// "flood" / "direct" inside the values are protocol terms and stay
// English (see docs/translations.md).
const system = statusSection(t('rptmgmt.status.system'), [
[t('rptmgmt.status.battery'), batStr],
[t('rptmgmt.status.uptime'), fmtDuration(s.uptime)],
[t('rptmgmt.status.clock'), '<span id="statusClock" class="text-muted">…</span>'],
[t('rptmgmt.status.queue'), fmtInt(s.tx_queue_len)],
[t('rptmgmt.status.events'), fmtInt(s.full_evts)],
]);
const radio = statusSection('Radio Statistics', [
['Last RSSI', s.last_rssi != null ? `${s.last_rssi} dBm` : '—'],
['Last SNR', s.last_snr != null ? `${s.last_snr} dB` : '—'],
['Noise floor', s.noise_floor != null ? `${s.noise_floor} dBm` : '—'],
['TX airtime', fmtDuration(s.airtime)],
['RX airtime', fmtDuration(s.rx_airtime)],
const radio = statusSection(t('rptmgmt.status.radio'), [
[t('rptmgmt.status.last_rssi'), s.last_rssi != null ? `${s.last_rssi} dBm` : '—'],
[t('rptmgmt.status.last_snr'), s.last_snr != null ? `${s.last_snr} dB` : '—'],
[t('rptmgmt.status.noise'), s.noise_floor != null ? `${s.noise_floor} dBm` : '—'],
[t('rptmgmt.status.tx_airtime'), fmtDuration(s.airtime)],
[t('rptmgmt.status.rx_airtime'), fmtDuration(s.rx_airtime)],
]);
const packets = statusSection('Packet Statistics', [
['Sent', `${fmtInt(s.nb_sent)} <span class="text-muted small">(flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})</span>`],
['Received', `${fmtInt(s.nb_recv)} <span class="text-muted small">(flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})</span>`],
['Duplicates', `<span class="text-muted small">flood</span> ${fmtInt(s.flood_dups)} · <span class="text-muted small">direct</span> ${fmtInt(s.direct_dups)}`],
...(s.recv_errors != null ? [['RX errors', fmtInt(s.recv_errors)]] : []),
['Channel utilization', util],
const packets = statusSection(t('rptmgmt.status.packets'), [
[t('rptmgmt.status.sent'), `${fmtInt(s.nb_sent)} <span class="text-muted small">(flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})</span>`],
[t('rptmgmt.status.received'), `${fmtInt(s.nb_recv)} <span class="text-muted small">(flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})</span>`],
[t('rptmgmt.status.duplicates'), `<span class="text-muted small">flood</span> ${fmtInt(s.flood_dups)} · <span class="text-muted small">direct</span> ${fmtInt(s.direct_dups)}`],
...(s.recv_errors != null ? [[t('rptmgmt.status.rx_errors'), fmtInt(s.recv_errors)]] : []),
[t('rptmgmt.status.utilization'), util],
]);
container.innerHTML = system + radio + packets;
@@ -478,8 +482,8 @@ function renderTelemetryPane(body) {
body.innerHTML = `
<div class="d-flex align-items-center mb-2">
<span class="text-muted small flex-grow-1" id="telemetryUpdated"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="telemetryRefreshBtn" title="Refresh telemetry">
<i class="bi bi-arrow-clockwise"></i> Refresh
<button type="button" class="btn btn-sm btn-outline-secondary" id="telemetryRefreshBtn" title="${tHtml('rptmgmt.tel.refresh_title')}">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
</div>
<div id="telemetryContainer"></div>
@@ -496,8 +500,8 @@ function setTelemetryUpdatedLabel() {
if (!label) { clearInterval(_telemetryTimer); _telemetryTimer = null; return; }
const secs = Math.floor((Date.now() - _telemetryUpdatedAt) / 1000);
if (secs < 2) label.textContent = t('rptmgmt.updated_just_now');
else if (secs < 60) label.textContent = `Updated ${secs}s ago`;
else label.textContent = `Updated ${fmtDuration(secs)} ago`;
else if (secs < 60) label.textContent = t('rptmgmt.updated_ago', { time: `${secs}s` });
else label.textContent = t('rptmgmt.updated_ago', { time: fmtDuration(secs) });
};
tick();
_telemetryTimer = setInterval(tick, 5000);
@@ -511,8 +515,8 @@ async function loadTelemetry() {
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">Requesting telemetry from the repeater…<br>
<span class="small">Multi-hop paths can take up to a minute.</span></p>
<p class="mt-2 mb-0">${tHtml('rptmgmt.tel.loading')}<br>
<span class="small">${tHtml('rptmgmt.tel.loading_hint')}</span></p>
</div>
`;
@@ -531,7 +535,7 @@ async function loadTelemetry() {
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.telemetry_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="telemetryRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.try_again')}
</button>
</div>
`;
@@ -574,7 +578,9 @@ function renderTelemetryCards(container, lpp) {
`;
}).join('');
// Channel 1 carries the repeater's own vitals (battery, MCU temp)
const chLabel = ch === 1 ? `Channel ${ch} <span class="text-muted fw-normal">· device</span>` : `Channel ${ch}`;
const chLabel = ch === 1
? `${tHtml('rptmgmt.tel.channel', { n: ch })} <span class="text-muted fw-normal">· ${tHtml('rptmgmt.tel.device_suffix')}</span>`
: tHtml('rptmgmt.tel.channel', { n: ch });
html += `
<div class="col-12 col-md-6 col-xl-4">
<div class="border rounded p-2 h-100">
@@ -626,12 +632,12 @@ function renderCliPane(body) {
).join('');
body.innerHTML = `
<div class="cli-terminal" id="cliOutput">
<div class="cli-line meta">Commands go to ${esc(_repeater ? _repeater.name : 'the repeater')}. One command at a time — replies travel over the mesh.</div>
<div class="cli-line meta">${tHtml('rptmgmt.cli.intro', { name: _repeater ? _repeater.name : t('rptmgmt.cli.the_repeater') })}</div>
</div>
<div class="d-flex flex-wrap gap-1 mt-2">${chips}</div>
<form id="cliForm" class="d-flex gap-2 mt-2">
<input type="text" id="cliInput" class="form-control form-control-sm font-monospace"
placeholder="Enter command (e.g. get name)" autocomplete="off"
placeholder="${tHtml('rptmgmt.cli.input_ph')}" autocomplete="off"
autocapitalize="off" spellcheck="false">
<button type="submit" class="btn btn-sm btn-success" id="cliSendBtn">
<i class="bi bi-send"></i>
@@ -700,7 +706,7 @@ async function sendCliCommand(raw) {
pushCliHistory(command);
cliAppend('cmd', command);
const pendingLine = cliAppend('meta cli-pending', 'Waiting for reply…');
const pendingLine = cliAppend('meta cli-pending', t('rptmgmt.cli.waiting'));
let data = null;
try {
@@ -716,7 +722,7 @@ async function sendCliCommand(raw) {
if (pendingLine) pendingLine.remove();
if (data && data.success) {
cliAppend('reply', data.output || '(empty reply)');
cliAppend('reply', data.output || t('rptmgmt.cli.empty_reply'));
if (data.elapsed_ms != null) {
cliAppend('meta', `(${(data.elapsed_ms / 1000).toFixed(1)} s)`);
}
@@ -746,14 +752,14 @@ function renderNeighborsPane(body) {
<span class="text-muted small flex-grow-1" id="neighborsCount"></span>
<div class="btn-group btn-group-sm" role="group" id="neighborsViewToggle" style="display: none;">
<button type="button" class="btn btn-outline-secondary active" id="nbListBtn">
<i class="bi bi-list-ul"></i> List
<i class="bi bi-list-ul"></i> ${tHtml('rptmgmt.neigh.list')}
</button>
<button type="button" class="btn btn-outline-secondary" id="nbMapBtn">
<i class="bi bi-map"></i> Map
<i class="bi bi-map"></i> ${tHtml('rptmgmt.neigh.map')}
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="neighborsRefreshBtn" title="Refresh neighbours">
<i class="bi bi-arrow-clockwise"></i> Refresh
<button type="button" class="btn btn-sm btn-outline-secondary" id="neighborsRefreshBtn" title="${tHtml('rptmgmt.neigh.refresh_title')}">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
</div>
<div id="neighborsContainer"></div>
@@ -777,8 +783,8 @@ async function loadNeighbors() {
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">Requesting neighbours from the repeater…<br>
<span class="small">Long lists are fetched in pages and can take a while.</span></p>
<p class="mt-2 mb-0">${tHtml('rptmgmt.neigh.loading')}<br>
<span class="small">${tHtml('rptmgmt.neigh.loading_hint')}</span></p>
</div>
`;
@@ -799,7 +805,7 @@ async function loadNeighbors() {
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.neighbours_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="neighborsRetryBtn">
<i class="bi bi-arrow-clockwise"></i> Try again
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.try_again')}
</button>
</div>
`;
@@ -825,8 +831,8 @@ function renderNeighborsList() {
const entries = data.entries || [];
if (countEl) {
let label = `${data.total} neighbor${data.total === 1 ? '' : 's'}`;
if (data.fetched < data.total) label += ` (showing ${data.fetched})`;
let label = tn('rptmgmt.neigh.count', data.total);
if (data.fetched < data.total) label += ` ${t('rptmgmt.neigh.showing', { count: data.fetched })}`;
countEl.textContent = label;
}
@@ -844,7 +850,7 @@ function renderNeighborsList() {
<tr>
<td class="text-truncate" style="max-width: 220px;">
${n.name ? esc(n.name) : `<span class="font-monospace text-muted">[${esc(n.pubkey_prefix)}]</span>`}
${n.lat != null ? '<i class="bi bi-geo-alt text-muted small ms-1" title="Position known"></i>' : ''}
${n.lat != null ? `<i class="bi bi-geo-alt text-muted small ms-1" title="${tHtml('rptmgmt.neigh.position_title')}"></i>` : ''}
</td>
<td class="text-muted text-nowrap">${fmtHeardAgo(n.secs_ago)}</td>
<td class="text-end text-nowrap fw-medium">${n.snr != null ? n.snr + ' dB' : '—'}</td>
@@ -864,10 +870,10 @@ function renderNeighborsList() {
function fmtHeardAgo(secs) {
if (secs == null) return '—';
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m ago`;
return `${Math.floor(secs / 86400)}d ${Math.floor((secs % 86400) / 3600)}h ago`;
if (secs < 60) return t('rptmgmt.ago_s', { s: secs });
if (secs < 3600) return t('rptmgmt.ago_ms', { m: Math.floor(secs / 60), s: secs % 60 });
if (secs < 86400) return t('rptmgmt.ago_hm', { h: Math.floor(secs / 3600), m: Math.floor((secs % 3600) / 60) });
return t('rptmgmt.ago_dh', { d: Math.floor(secs / 86400), h: Math.floor((secs % 86400) / 3600) });
}
function setNeighborsView(view) {
@@ -931,7 +937,7 @@ function renderNeighborsMap() {
marker.bindPopup(
`<b>${esc(neighborLabel(n))}</b><br>` +
`SNR: ${n.snr != null ? n.snr + ' dB' : '—'}<br>` +
`Heard: ${fmtHeardAgo(n.secs_ago)}`
tHtml('rptmgmt.neigh.heard_at', { time: fmtHeardAgo(n.secs_ago) })
);
if (hasCenter) {
const line = L.polyline([center, pos], {
@@ -980,13 +986,13 @@ async function loadClock() {
el.textContent = d.toLocaleString();
} else {
el.className = '';
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">fetch</button>`;
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">${tHtml('rptmgmt.status.clock_fetch')}</button>`;
const b = document.getElementById('clockRetryBtn');
if (b) b.addEventListener('click', loadClock);
}
} catch (e) {
el.className = '';
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">fetch</button>`;
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">${tHtml('rptmgmt.status.clock_fetch')}</button>`;
const b = document.getElementById('clockRetryBtn');
if (b) b.addEventListener('click', loadClock);
}
@@ -1080,14 +1086,14 @@ function settingsControlHtml(f) {
if (f.type === 'password') {
// Write-only: usable without loading, never prefilled
return `<input type="password" class="form-control form-control-sm sf-input" ${df}
placeholder="(unchanged)" autocomplete="new-password">`;
placeholder="${tHtml('rptmgmt.set.pw_unchanged_ph')}" autocomplete="new-password">`;
}
if (f.type === 'radio4') {
const subs = [
['Frequency (MHz)', 'any'],
['Bandwidth (kHz)', 'any'],
['Spreading factor', '1'],
['Coding rate', '1'],
[tHtml('settings.device.freq'), 'any'],
[tHtml('settings.device.bw'), 'any'],
[tHtml('settings.device.sf'), '1'],
[tHtml('settings.device.cr'), '1'],
].map(([lbl, step], i) => `
<div class="col-6 col-lg-3">
<label class="form-label small text-muted mb-0">${lbl}</label>
@@ -1142,10 +1148,10 @@ function renderSettingsPane(body) {
</button>` : ''}
<div class="d-flex align-items-center gap-2 mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary sec-refresh">
<i class="bi bi-arrow-clockwise"></i> Refresh
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
<button type="button" class="btn btn-sm btn-success sec-apply" disabled>
<i class="bi bi-check-lg"></i> Apply
<i class="bi bi-check-lg"></i> ${tHtml('common.apply')}
</button>
</div>
</div>
@@ -1154,10 +1160,7 @@ function renderSettingsPane(body) {
}).join('');
body.innerHTML = `
<p class="text-muted small mb-2">
Settings are read live from the repeater. Expand a section to load it —
every field is one mesh round-trip, so a section can take a few seconds.
</p>
<p class="text-muted small mb-2">${tHtml('rptmgmt.set.intro')}</p>
<div class="accordion" id="settingsAccordion">${items}</div>
`;
@@ -1279,7 +1282,7 @@ function updateSectionDirty(secKey) {
});
const applyBtn = item.querySelector('.sec-apply');
applyBtn.disabled = count === 0 || st.loading || st.applying;
applyBtn.innerHTML = `<i class="bi bi-check-lg"></i> Apply${count ? ` (${count})` : ''}`;
applyBtn.innerHTML = `<i class="bi bi-check-lg"></i> ${count ? tHtml('rptmgmt.set.apply_count', { count }) : tHtml('common.apply')}`;
const badge = item.querySelector('.sec-dirty-badge');
badge.classList.toggle('d-none', count === 0);
badge.textContent = count;
@@ -1296,8 +1299,8 @@ async function loadSettingsSection(secKey) {
const statusEl = item.querySelector('.sec-status');
statusEl.classList.remove('d-none', 'text-danger');
statusEl.classList.add('text-muted');
statusEl.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>' +
'Reading from repeater… (one mesh round-trip per field)';
statusEl.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>'
+ tHtml('rptmgmt.set.reading');
item.querySelector('.sec-refresh').disabled = true;
item.querySelector('.sec-apply').disabled = true;
const mapPickBtn = item.querySelector('.sec-map-pick');
@@ -1352,7 +1355,7 @@ async function loadSettingsSection(secKey) {
if (errCount) {
statusEl.classList.remove('text-muted');
statusEl.classList.add('text-danger');
statusEl.textContent = `${errCount} field${errCount > 1 ? 's' : ''} failed to load — Refresh retries the whole section.`;
statusEl.textContent = tn('rptmgmt.set.load_errors', errCount);
} else {
statusEl.classList.add('d-none');
}
@@ -1381,9 +1384,7 @@ async function applySettingsSection(secKey) {
}
if ('radio' in dirty && !window.confirm(
`Change radio parameters to ${dirty.radio}?\n\n` +
'Wrong values can make the repeater unreachable over the mesh. ' +
'The change takes effect after a reboot.')) {
t('rptmgmt.confirm.radio', { params: dirty.radio }))) {
return;
}
@@ -1637,8 +1638,9 @@ function renderActionsPane(body) {
async function runRepeaterAction(action) {
if (_actionPending) return;
if (action === 'reboot' && !window.confirm(
`Reboot ${(_repeater && _repeater.name) || 'this repeater'}?\n\n` +
'It will drop off the mesh for a few seconds. The firmware does not reply to this command.')) {
t('rptmgmt.confirm.reboot', {
name: (_repeater && _repeater.name) || t('rptmgmt.this_repeater'),
}))) {
return;
}
@@ -1698,8 +1700,8 @@ async function fetchRepeater() {
}
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)`);
const name = (_repeater && _repeater.name) || t('rptmgmt.login.repeater_fallback');
showLoading(t('rptmgmt.login.progress', { name }));
let data = null;
try {
@@ -1726,24 +1728,24 @@ async function doLogin(password, save) {
permissions: data.permissions
};
const role = data.is_admin ? 'ADMIN' : 'GUEST';
showNotification(`Logged in as ${role}`, 'success');
showNotification(t('rptmgmt.login.logged_in', { role }), 'success');
showPanel();
} else {
const error = (data && data.error) || 'Login failed';
const error = (data && data.error) || t('rptmgmt.login.failed');
openPasswordModal(error);
}
}
function openPasswordModal(errorHint = '') {
// Keep the loading screen behind the modal but stop the spinner text
showLoading('Waiting for password…');
showLoading(t('rptmgmt.login.waiting'));
const name = (_repeater && _repeater.name) || _pubkey.substring(0, 12);
document.getElementById('passwordModalTitle').textContent = `Log in${name}`;
document.getElementById('passwordModalTitle').textContent = t('rptmgmt.login.modal_title', { 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.';
? `<span class="text-danger">${esc(errorHint)}</span><br>${tHtml('rptmgmt.login.retry_hint')}`
: tHtml('rptmgmt.login.prompt');
const input = document.getElementById('passwordInput');
input.value = '';
input.type = 'password';
@@ -1757,7 +1759,7 @@ async function submitPasswordModal() {
const input = document.getElementById('passwordInput');
const password = input.value;
if (!password) {
showNotification('Password cannot be empty', 'warning');
showNotification(t('rptmgmt.login.empty_password'), 'warning');
return;
}
const save = document.getElementById('savePasswordCheck').checked;
@@ -1772,7 +1774,7 @@ async function logout() {
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');
showNotification(data.error || t('rptmgmt.logout_failed'), 'danger');
logoutBtn.disabled = false;
return;
}
@@ -1790,11 +1792,11 @@ 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.');
showError(t('rptmgmt.invalid_pubkey'));
return;
}
showLoading('Loading');
showLoading(t('common.loading'));
try {
await fetchRepeater();
} catch (e) {
@@ -1803,7 +1805,7 @@ async function init() {
}
if (!_repeater.on_device) {
showError('This repeater is not stored on the device — it cannot be managed.');
showError(t('rptmgmt.not_on_device'));
return;
}
@@ -1849,9 +1851,9 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('copyPubkeyBtn').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(_repeater ? _repeater.public_key : _pubkey);
showNotification('Public key copied', 'info');
showNotification(t('rptmgmt.pubkey_copied'), 'info');
} catch (e) {
showNotification('Copy failed', 'warning');
showNotification(t('common.copy_failed'), 'warning');
}
});
+2 -2
View File
@@ -262,7 +262,7 @@ async function doLogin(pubkey, password, save) {
data = await response.json();
} catch (e) {
console.error('Login request failed:', e);
data = { success: false, error: 'Login request failed' };
data = { success: false, error: t('rptmgmt.login_request_failed') };
}
_loginPubkey = null;
@@ -989,7 +989,7 @@ async function loadRepeaterMapMarkers() {
const data = await response.json();
if (data.success) _repeatersCache = data.repeaters;
} catch (e) {
if (countEl) countEl.textContent = 'Failed to load';
if (countEl) countEl.textContent = t('common.load_failed');
return;
}
}
+98
View File
@@ -160,6 +160,7 @@
"chat.unnamed_channel": "Channel {index}",
"chat.updated": "Updated: {time}",
"common.add": "Add",
"common.apply": "Apply",
"common.back": "Back",
"common.cancel": "Cancel",
"common.cannot_undo": "This action cannot be undone.",
@@ -168,6 +169,7 @@
"common.connecting": "Connecting...",
"common.copied": "Copied!",
"common.copy": "Copy",
"common.copy_failed": "Copy failed",
"common.copy_route": "Copy route",
"common.days_ago": {
"one": "{count}d ago",
@@ -190,6 +192,7 @@
"other": "{count} hours ago"
},
"common.just_now": "just now",
"common.load_failed": "Failed to load",
"common.loading": "Loading...",
"common.minutes_ago": "{count} min ago",
"common.minutes_ago_long": {
@@ -245,6 +248,7 @@
"contacts.add.preview": "Preview:",
"contacts.add.pubkey": "Public Key (64 hex chars):",
"contacts.add.pubkey_ph": "e.g. a1b2c3d4...",
"contacts.add.qr_invalid_uri": "QR code does not contain a valid meshcore:// URI.",
"contacts.add.qr_label": "Or upload a QR code image:",
"contacts.add.scanned": "Scanned:",
"contacts.add.start_camera": "Start Camera",
@@ -276,6 +280,9 @@
"contacts.card.add_desc": "Add from URI, QR code, or manual entry",
"contacts.card.existing_desc": "Manage all stored contacts",
"contacts.card.pending_desc": "Contacts awaiting manual approval",
"contacts.cleanup.auto_enabled": "Enabled (runs daily at {time} {tz})",
"contacts.cleanup.completed": "Cleanup completed: {count} deleted",
"contacts.cleanup.completed_failed": ", {count} failed",
"contacts.cleanup.confirm": "Remove all contacts inactive for more than {hours} hours?",
"contacts.cleanup.confirm_title": "Confirm Contact Cleanup",
"contacts.cleanup.date_field": "Date Field:",
@@ -291,6 +298,7 @@
"contacts.cleanup.last_modified": "Last Modified",
"contacts.cleanup.name_filter": "Name Filter (optional):",
"contacts.cleanup.name_filter_ph": "Enter partial name to search...",
"contacts.cleanup.network_error": "Network error during cleanup",
"contacts.cleanup.preview": "Preview Cleanup",
"contacts.cleanup.run_at": "Run at:",
"contacts.cleanup.status_label": "Status:",
@@ -299,6 +307,8 @@
"contacts.cleanup.types": "Contact Types:",
"contacts.cleanup.undo_warning": "This action cannot be undone!",
"contacts.confirm.block": "Block this contact? Their messages will be hidden from chat.",
"contacts.confirm.push": "Push \"{name}\" to device?",
"contacts.confirm.to_cache": "Move \"{name}\" from device to cache?",
"contacts.copy_title": "Click to copy",
"contacts.delete.confirm": "Delete Contact",
"contacts.delete.question": "Are you sure you want to delete this contact?",
@@ -315,6 +325,8 @@
"contacts.filter.blocked": "Blocked",
"contacts.filter.cache_only": "Cache only",
"contacts.filter.ignored": "Ignored",
"contacts.filter.no_match": "No contacts match filters",
"contacts.filter.no_match_hint": "Try changing your filter criteria",
"contacts.filter.on_device": "On device",
"contacts.hops": {
"one": "{count} hop",
@@ -339,6 +351,8 @@
"contacts.pending.none": "No pending requests",
"contacts.pending.none_hint": "New contact requests will appear here when manual approval is enabled",
"contacts.pending.title": "Pending Contacts",
"contacts.progress.approving": "Approving {done}/{total}...",
"contacts.progress.ignoring": "Ignoring {done}/{total}...",
"contacts.protected_cannot_block": "Cannot block protected contact",
"contacts.protected_cannot_delete": "Cannot delete protected contact",
"contacts.protected_cannot_ignore": "Cannot ignore protected contact",
@@ -383,9 +397,11 @@
"contacts.toast.protect_failed": "Failed to update protection: {error}",
"contacts.toast.pubkey_copied": "Public key copied to clipboard",
"contacts.toast.push_failed": "Failed to push contact",
"contacts.toast.pushed": "{name} pushed to device",
"contacts.toast.settings_load_error": "Error loading settings",
"contacts.toast.settings_network_error": "Network error saving settings",
"contacts.toast.settings_save_failed": "Failed to save settings: {error}",
"contacts.toast.to_cache": "{name} moved to cache",
"coord.confirm": "Confirm",
"coord.hint": "Click on the map to select coordinates",
"coord.title": "Pick Coordinates",
@@ -582,6 +598,7 @@
"observer.use_tls": "Use TLS",
"observer.username": "Username",
"observer.verify_tls": "Verify TLS certificate",
"pa.ambiguous": "ambiguous ({count})",
"pa.any_hb": "Any HB",
"pa.any_hops": "Any hops",
"pa.byte_2_3": "2/3-byte",
@@ -617,6 +634,7 @@
"pa.col.time": "Time",
"pa.content_ph": "Message text",
"pa.content_title": "Filter by message content",
"pa.copy_hash_title": "Copy repeater hash {hash}",
"pa.copy_route_title": "Click to copy route",
"pa.copy_title": "Click to copy",
"pa.counter.filtered": "{shown} of {total} messages",
@@ -641,6 +659,7 @@
"pa.empty_no_echoes": "No routed echoes in the current selection.",
"pa.empty_range": "No messages in the selected time range.",
"pa.filter_by_segment_title": "Filter messages by this segment",
"pa.filter_by_title": "Filter messages by {token}",
"pa.filters": "Filters",
"pa.filters_toggle_title": "Show/hide filters",
"pa.hb_filter_title": "Filter by path hash size (bytes per hop, any routed echo)",
@@ -651,9 +670,13 @@
"other": "Last {count} days"
},
"pa.loading": "Loading messages…",
"pa.map.alt_path": "Alternative path",
"pa.map.candidate": "Candidate for hop {n}: {token}",
"pa.map.clear_title": "Clear drawn path",
"pa.map.hint": "Click a message, then an echo to draw its path.",
"pa.map.hop": "Hop {n}: {token}",
"pa.map.no_routed": "No messages with routed echoes match the current filters.",
"pa.map.origin": "Origin (sender)",
"pa.me": "Me",
"pa.n_byte": {
"one": "{count}-byte",
@@ -664,6 +687,7 @@
"other": "{count} hops"
},
"pa.no_path_data": "no path data",
"pa.no_paths_min_hops": "No paths with at least {count} hops in the current selection.",
"pa.packet_hash": "Packet hash",
"pa.reload_title": "Reload",
"pa.repeater_hash": "Repeater hash",
@@ -682,6 +706,8 @@
"pa.toast.load_failed": "Failed to load messages: {error}",
"pa.toast.msg_gone": "This message is no longer in the analyzer data (max 7 days).",
"pa.toast.no_echoes": "This message has no routed echoes to draw.",
"pa.toggle_all_repeaters": "All repeaters",
"pa.toggle_alt_paths": "Alternative paths",
"pa.token_ph": "Repeater (3B or name)",
"pa.token_title": "Match a repeater anywhere in a path: hash prefix (hex) or contact name. Chain with '>' for a consecutive sequence, e.g. AFE6>6E9A",
"pa.undo_pick_title": "Undo this manual assignment",
@@ -828,30 +854,67 @@
"rptmgmt.act.zerohop": "Send zero-hop advert",
"rptmgmt.act.zerohop_desc": "Announce this repeater to its direct neighbours only.",
"rptmgmt.action_failed": "Action failed",
"rptmgmt.admin_required": "Admin login required",
"rptmgmt.ago_dh": "{d}d {h}h ago",
"rptmgmt.ago_hm": "{h}h {m}m ago",
"rptmgmt.ago_ms": "{m}m {s}s ago",
"rptmgmt.ago_s": "{s}s ago",
"rptmgmt.back_title": "Back to My Repeaters",
"rptmgmt.back_to_list": "Back to list",
"rptmgmt.back_tools_title": "Back to tools",
"rptmgmt.cli.empty_reply": "(empty reply)",
"rptmgmt.cli.input_ph": "Enter command (e.g. get name)",
"rptmgmt.cli.intro": "Commands go to {name}. One command at a time — replies travel over the mesh.",
"rptmgmt.cli.the_repeater": "the repeater",
"rptmgmt.cli.waiting": "Waiting for reply…",
"rptmgmt.command_failed": "Command failed",
"rptmgmt.confirm.radio": "Change radio parameters to {params}?\n\nWrong values can make the repeater unreachable over the mesh. The change takes effect after a reboot.",
"rptmgmt.confirm.reboot": "Reboot {name}?\n\nIt will drop off the mesh for a few seconds. The firmware does not reply to this command.",
"rptmgmt.copy_pubkey_title": "Copy full public key",
"rptmgmt.danger_zone": "Danger zone",
"rptmgmt.done": "Done",
"rptmgmt.erase_fs_note": "Erase file system is not available over the mesh — the firmware only accepts it on the USB serial console (use the MeshCore flasher instead).",
"rptmgmt.error_generic": "Something went wrong.",
"rptmgmt.failed": "Failed",
"rptmgmt.invalid_pubkey": "Invalid repeater public key in URL.",
"rptmgmt.load_failed": "Failed to load repeater",
"rptmgmt.loc.hint": "Click the map to set the repeater location.",
"rptmgmt.loc.title": "Pick Location",
"rptmgmt.loc.use": "Use this location",
"rptmgmt.login.empty_password": "Password cannot be empty",
"rptmgmt.login.failed": "Login failed",
"rptmgmt.login.logged_in": "Logged in as {role}",
"rptmgmt.login.modal_title": "Log in — {name}",
"rptmgmt.login.progress": "Logging in to {name}… (may take up to 60 s on flood paths)",
"rptmgmt.login.prompt": "Enter the repeater password to log in.",
"rptmgmt.login.repeater_fallback": "repeater",
"rptmgmt.login.retry_hint": "Check the password and try again.",
"rptmgmt.login.waiting": "Waiting for password…",
"rptmgmt.login_request_failed": "Login request failed",
"rptmgmt.logout": "Logout",
"rptmgmt.logout_failed": "Logout failed",
"rptmgmt.logout_title": "Log out of this repeater",
"rptmgmt.managed_repeater": "managed repeater",
"rptmgmt.neigh.count": {
"one": "{count} neighbor",
"other": "{count} neighbors"
},
"rptmgmt.neigh.heard": "Heard",
"rptmgmt.neigh.heard_at": "Heard: {time}",
"rptmgmt.neigh.list": "List",
"rptmgmt.neigh.loading": "Requesting neighbours from the repeater…",
"rptmgmt.neigh.loading_hint": "Long lists are fetched in pages and can take a while.",
"rptmgmt.neigh.map": "Map",
"rptmgmt.neigh.position_title": "Position known",
"rptmgmt.neigh.refresh_title": "Refresh neighbours",
"rptmgmt.neigh.repeater": "Repeater",
"rptmgmt.neigh.showing": "(showing {count})",
"rptmgmt.neighbours_failed": "Failed to get neighbours",
"rptmgmt.neighbours_none": "No neighbours reported yet.",
"rptmgmt.neighbours_none_hint": "Neighbours are learned from zero-hop repeater adverts.",
"rptmgmt.no_result": "No result",
"rptmgmt.not_on_device": "This repeater is not stored on the device — it cannot be managed.",
"rptmgmt.pubkey_copied": "Public key copied",
"rptmgmt.read_failed": "Failed to read",
"rptmgmt.reboot_required": "reboot required",
"rptmgmt.request_failed": "Request failed",
@@ -862,6 +925,7 @@
"rptmgmt.set.agc_reset": "AGC reset interval",
"rptmgmt.set.agc_reset_help": "Multiple of 4; 0 disables periodic AGC resets.",
"rptmgmt.set.allow_read_only": "Allow read-only (guest) access",
"rptmgmt.set.apply_count": "Apply ({count})",
"rptmgmt.set.basic": "Basic",
"rptmgmt.set.direct_txdelay": "Direct TX delay factor (02)",
"rptmgmt.set.dutycycle": "Duty cycle (%)",
@@ -871,7 +935,12 @@
"rptmgmt.set.guest_password": "Guest password",
"rptmgmt.set.guest_password_help": "Password for read-only guest logins.",
"rptmgmt.set.int_thresh": "Interference threshold",
"rptmgmt.set.intro": "Settings are read live from the repeater. Expand a section to load it — every field is one mesh round-trip, so a section can take a few seconds.",
"rptmgmt.set.lat": "Latitude",
"rptmgmt.set.load_errors": {
"one": "{count} field failed to load — Refresh retries the whole section.",
"other": "{count} fields failed to load — Refresh retries the whole section."
},
"rptmgmt.set.location": "Location",
"rptmgmt.set.lon": "Longitude",
"rptmgmt.set.loop_detect": "Loop detection",
@@ -886,9 +955,11 @@
"rptmgmt.set.password": "Admin password",
"rptmgmt.set.password_help": "Write-only — the current password is never shown. Changing it does not log out already active sessions.",
"rptmgmt.set.path_hash_mode": "Path hash mode (02)",
"rptmgmt.set.pw_unchanged_ph": "(unchanged)",
"rptmgmt.set.radio": "Radio",
"rptmgmt.set.radio_note": "Frequency, bandwidth, SF and CR are applied after a reboot. TX power applies immediately.",
"rptmgmt.set.radio_params": "Radio parameters",
"rptmgmt.set.reading": "Reading from repeater… (one mesh round-trip per field)",
"rptmgmt.set.reboot_note": "Some changes take effect after a reboot — use Actions → Reboot.",
"rptmgmt.set.repeat": "Repeat packets",
"rptmgmt.set.rxgain": "RX boosted gain",
@@ -896,7 +967,33 @@
"rptmgmt.set.txdelay": "TX delay factor (02)",
"rptmgmt.settings_apply_failed": "Failed to apply settings",
"rptmgmt.settings_read_failed": "Failed to read settings",
"rptmgmt.status.battery": "Battery",
"rptmgmt.status.clock": "Clock",
"rptmgmt.status.clock_fetch": "fetch",
"rptmgmt.status.duplicates": "Duplicates",
"rptmgmt.status.events": "Debug / error events",
"rptmgmt.status.last_rssi": "Last RSSI",
"rptmgmt.status.last_snr": "Last SNR",
"rptmgmt.status.loading": "Requesting status from the repeater…",
"rptmgmt.status.noise": "Noise floor",
"rptmgmt.status.packets": "Packet Statistics",
"rptmgmt.status.queue": "Queue length",
"rptmgmt.status.radio": "Radio Statistics",
"rptmgmt.status.received": "Received",
"rptmgmt.status.refresh_title": "Refresh status",
"rptmgmt.status.rx_airtime": "RX airtime",
"rptmgmt.status.rx_errors": "RX errors",
"rptmgmt.status.sent": "Sent",
"rptmgmt.status.system": "System Information",
"rptmgmt.status.tx_airtime": "TX airtime",
"rptmgmt.status.uptime": "Uptime",
"rptmgmt.status.utilization": "Channel utilization",
"rptmgmt.status_failed": "Failed to get status",
"rptmgmt.tel.channel": "Channel {n}",
"rptmgmt.tel.device_suffix": "device",
"rptmgmt.tel.loading": "Requesting telemetry from the repeater…",
"rptmgmt.tel.loading_hint": "Multi-hop paths can take up to a minute.",
"rptmgmt.tel.refresh_title": "Refresh telemetry",
"rptmgmt.telemetry_failed": "Failed to get telemetry",
"rptmgmt.telemetry_none": "No telemetry data reported.",
"rptmgmt.this_repeater": "This repeater",
@@ -924,6 +1021,7 @@
"rptmgmt.tools.telemetry": "Telemetry",
"rptmgmt.tools.telemetry_desc": "Sensor channels (Cayenne LPP)",
"rptmgmt.tools_heading": "Management Tools",
"rptmgmt.updated_ago": "Updated {time} ago",
"rptmgmt.updated_just_now": "Updated just now",
"search.count": {
"one": "{count} result",
+102
View File
@@ -162,6 +162,7 @@
"chat.unnamed_channel": "Kanał {index}",
"chat.updated": "Aktualizacja: {time}",
"common.add": "Dodaj",
"common.apply": "Zastosuj",
"common.back": "Wstecz",
"common.cancel": "Anuluj",
"common.cannot_undo": "Tej operacji nie można cofnąć.",
@@ -170,6 +171,7 @@
"common.connecting": "Łączenie...",
"common.copied": "Skopiowano!",
"common.copy": "Kopiuj",
"common.copy_failed": "Kopiowanie nie powiodło się",
"common.copy_route": "Kopiuj trasę",
"common.days_ago": {
"one": "{count} dzień temu",
@@ -198,6 +200,7 @@
"other": "{count} godziny temu"
},
"common.just_now": "przed chwilą",
"common.load_failed": "Nie udało się wczytać",
"common.loading": "Ładowanie...",
"common.minutes_ago": "{count} min temu",
"common.minutes_ago_long": {
@@ -259,6 +262,7 @@
"contacts.add.preview": "Podgląd:",
"contacts.add.pubkey": "Klucz publiczny (64 znaki hex):",
"contacts.add.pubkey_ph": "np. a1b2c3d4...",
"contacts.add.qr_invalid_uri": "Kod QR nie zawiera prawidłowego URI meshcore://.",
"contacts.add.qr_label": "Albo wgraj obraz z kodem QR:",
"contacts.add.scanned": "Zeskanowano:",
"contacts.add.start_camera": "Włącz kamerę",
@@ -290,6 +294,9 @@
"contacts.card.add_desc": "Dodaj z URI, kodu QR lub ręcznie",
"contacts.card.existing_desc": "Zarządzaj wszystkimi zapisanymi kontaktami",
"contacts.card.pending_desc": "Kontakty oczekujące na ręczne zatwierdzenie",
"contacts.cleanup.auto_enabled": "Włączone (codziennie o {time} {tz})",
"contacts.cleanup.completed": "Czyszczenie zakończone: usunięto {count}",
"contacts.cleanup.completed_failed": ", nieudanych: {count}",
"contacts.cleanup.confirm": "Usunąć wszystkie kontakty nieaktywne dłużej niż {hours} godz.?",
"contacts.cleanup.confirm_title": "Potwierdź czyszczenie kontaktów",
"contacts.cleanup.date_field": "Pole daty:",
@@ -305,6 +312,7 @@
"contacts.cleanup.last_modified": "Ostatnia zmiana",
"contacts.cleanup.name_filter": "Filtr nazwy (opcjonalnie):",
"contacts.cleanup.name_filter_ph": "Wpisz fragment nazwy do wyszukania...",
"contacts.cleanup.network_error": "Błąd sieci podczas czyszczenia",
"contacts.cleanup.preview": "Podgląd czyszczenia",
"contacts.cleanup.run_at": "Uruchom o:",
"contacts.cleanup.status_label": "Status:",
@@ -313,6 +321,8 @@
"contacts.cleanup.types": "Typy kontaktów:",
"contacts.cleanup.undo_warning": "Tej operacji nie można cofnąć!",
"contacts.confirm.block": "Zablokować ten kontakt? Jego wiadomości będą ukryte w czacie.",
"contacts.confirm.push": "Wysłać \"{name}\" na urządzenie?",
"contacts.confirm.to_cache": "Przenieść \"{name}\" z urządzenia do pamięci?",
"contacts.copy_title": "Kliknij, aby skopiować",
"contacts.delete.confirm": "Usuń kontakt",
"contacts.delete.question": "Czy na pewno chcesz usunąć ten kontakt?",
@@ -329,6 +339,8 @@
"contacts.filter.blocked": "Zablokowane",
"contacts.filter.cache_only": "Tylko w pamięci",
"contacts.filter.ignored": "Ignorowane",
"contacts.filter.no_match": "Żaden kontakt nie pasuje do filtrów",
"contacts.filter.no_match_hint": "Spróbuj zmienić kryteria filtrowania",
"contacts.filter.on_device": "Na urządzeniu",
"contacts.hops": {
"one": "{count} hop",
@@ -355,6 +367,8 @@
"contacts.pending.none": "Brak oczekujących zgłoszeń",
"contacts.pending.none_hint": "Nowe zgłoszenia kontaktów pojawią się tutaj, gdy włączone jest ręczne zatwierdzanie",
"contacts.pending.title": "Oczekujące kontakty",
"contacts.progress.approving": "Zatwierdzanie {done}/{total}...",
"contacts.progress.ignoring": "Ignorowanie {done}/{total}...",
"contacts.protected_cannot_block": "Nie można zablokować chronionego kontaktu",
"contacts.protected_cannot_delete": "Nie można usunąć chronionego kontaktu",
"contacts.protected_cannot_ignore": "Nie można zignorować chronionego kontaktu",
@@ -403,9 +417,11 @@
"contacts.toast.protect_failed": "Nie udało się zmienić ochrony: {error}",
"contacts.toast.pubkey_copied": "Klucz publiczny skopiowany do schowka",
"contacts.toast.push_failed": "Nie udało się wysłać kontaktu",
"contacts.toast.pushed": "{name} wysłany na urządzenie",
"contacts.toast.settings_load_error": "Błąd wczytywania ustawień",
"contacts.toast.settings_network_error": "Błąd sieci przy zapisie ustawień",
"contacts.toast.settings_save_failed": "Nie udało się zapisać ustawień: {error}",
"contacts.toast.to_cache": "{name} przeniesiony do pamięci",
"coord.confirm": "Zatwierdź",
"coord.hint": "Kliknij na mapie, aby wybrać współrzędne",
"coord.title": "Wybierz współrzędne",
@@ -614,6 +630,7 @@
"observer.use_tls": "Użyj TLS",
"observer.username": "Nazwa użytkownika",
"observer.verify_tls": "Weryfikuj certyfikat TLS",
"pa.ambiguous": "niejednoznaczne ({count})",
"pa.any_hb": "Dowolne HB",
"pa.any_hops": "Dowolne hopy",
"pa.byte_2_3": "2/3 bajty",
@@ -651,6 +668,7 @@
"pa.col.time": "Czas",
"pa.content_ph": "Treść wiadomości",
"pa.content_title": "Filtruj po treści wiadomości",
"pa.copy_hash_title": "Kopiuj hash repeatera {hash}",
"pa.copy_route_title": "Kliknij, aby skopiować trasę",
"pa.copy_title": "Kliknij, aby skopiować",
"pa.counter.filtered": "{shown} z {total} wiadomości",
@@ -683,6 +701,7 @@
"pa.empty_no_echoes": "Brak ech z trasą w bieżącym wyborze.",
"pa.empty_range": "Brak wiadomości w wybranym zakresie czasu.",
"pa.filter_by_segment_title": "Filtruj wiadomości po tym segmencie",
"pa.filter_by_title": "Filtruj wiadomości według {token}",
"pa.filters": "Filtry",
"pa.filters_toggle_title": "Pokaż/ukryj filtry",
"pa.hb_filter_title": "Filtruj po rozmiarze hasha ścieżki (bajtów na hop, dowolne echo z trasą)",
@@ -695,9 +714,13 @@
"other": "Ostatnie {count} dnia"
},
"pa.loading": "Wczytywanie wiadomości…",
"pa.map.alt_path": "Ścieżka alternatywna",
"pa.map.candidate": "Kandydat na hop {n}: {token}",
"pa.map.clear_title": "Wyczyść narysowaną ścieżkę",
"pa.map.hint": "Kliknij wiadomość, a potem echo, aby narysować jej ścieżkę.",
"pa.map.hop": "Hop {n}: {token}",
"pa.map.no_routed": "Żadna wiadomość z echami z trasą nie pasuje do bieżących filtrów.",
"pa.map.origin": "Źródło (nadawca)",
"pa.me": "Ja",
"pa.n_byte": {
"one": "{count} bajt",
@@ -712,6 +735,7 @@
"other": "{count} hopa"
},
"pa.no_path_data": "brak danych o ścieżce",
"pa.no_paths_min_hops": "Brak ścieżek o co najmniej {count} hopach w bieżącym wyborze.",
"pa.packet_hash": "Hash pakietu",
"pa.reload_title": "Odśwież",
"pa.repeater_hash": "Hash repeatera",
@@ -730,6 +754,8 @@
"pa.toast.load_failed": "Nie udało się wczytać wiadomości: {error}",
"pa.toast.msg_gone": "Tej wiadomości nie ma już w danych analizatora (maks. 7 dni).",
"pa.toast.no_echoes": "Ta wiadomość nie ma ech z trasą do narysowania.",
"pa.toggle_all_repeaters": "Wszystkie repeatery",
"pa.toggle_alt_paths": "Ścieżki alternatywne",
"pa.token_ph": "Repeater (3B lub nazwa)",
"pa.token_title": "Dopasuj repeater w dowolnym miejscu ścieżki: prefiks hasha (hex) lub nazwa kontaktu. Połącz znakiem '>', aby wskazać kolejność, np. AFE6>6E9A",
"pa.undo_pick_title": "Cofnij to ręczne przypisanie",
@@ -890,30 +916,69 @@
"rptmgmt.act.zerohop": "Wyślij advert zero-hop",
"rptmgmt.act.zerohop_desc": "Ogłoś ten repeater wyłącznie bezpośrednim sąsiadom.",
"rptmgmt.action_failed": "Akcja nie powiodła się",
"rptmgmt.admin_required": "Wymagane logowanie administratora",
"rptmgmt.ago_dh": "{d}d {h}h temu",
"rptmgmt.ago_hm": "{h}h {m}m temu",
"rptmgmt.ago_ms": "{m}m {s}s temu",
"rptmgmt.ago_s": "{s}s temu",
"rptmgmt.back_title": "Powrót do Moich repeaterów",
"rptmgmt.back_to_list": "Powrót do listy",
"rptmgmt.back_tools_title": "Powrót do narzędzi",
"rptmgmt.cli.empty_reply": "(pusta odpowiedź)",
"rptmgmt.cli.input_ph": "Wpisz polecenie (np. get name)",
"rptmgmt.cli.intro": "Polecenia trafiają do {name}. Jedno polecenie naraz — odpowiedzi wędrują przez sieć mesh.",
"rptmgmt.cli.the_repeater": "repeatera",
"rptmgmt.cli.waiting": "Czekam na odpowiedź…",
"rptmgmt.command_failed": "Polecenie nie powiodło się",
"rptmgmt.confirm.radio": "Zmienić parametry radia na {params}?\n\nBłędne wartości mogą sprawić, że repeater stanie się nieosiągalny w sieci mesh. Zmiana działa po restarcie.",
"rptmgmt.confirm.reboot": "Zrestartować {name}?\n\nRepeater zniknie z sieci mesh na kilka sekund. Firmware nie odpowiada na to polecenie.",
"rptmgmt.copy_pubkey_title": "Kopiuj pełny klucz publiczny",
"rptmgmt.danger_zone": "Strefa ryzyka",
"rptmgmt.done": "Gotowe",
"rptmgmt.erase_fs_note": "Kasowanie systemu plików nie jest dostępne przez sieć mesh — firmware przyjmuje je wyłącznie na konsoli szeregowej USB (użyj flashera MeshCore).",
"rptmgmt.error_generic": "Coś poszło nie tak.",
"rptmgmt.failed": "Niepowodzenie",
"rptmgmt.invalid_pubkey": "Nieprawidłowy klucz publiczny repeatera w adresie URL.",
"rptmgmt.load_failed": "Nie udało się wczytać repeatera",
"rptmgmt.loc.hint": "Kliknij mapę, aby ustawić lokalizację repeatera.",
"rptmgmt.loc.title": "Wybierz lokalizację",
"rptmgmt.loc.use": "Użyj tej lokalizacji",
"rptmgmt.login.empty_password": "Hasło nie może być puste",
"rptmgmt.login.failed": "Logowanie nie powiodło się",
"rptmgmt.login.logged_in": "Zalogowano jako {role}",
"rptmgmt.login.modal_title": "Zaloguj się — {name}",
"rptmgmt.login.progress": "Logowanie do {name}… (na ścieżkach flood może potrwać do 60 s)",
"rptmgmt.login.prompt": "Podaj hasło repeatera, aby się zalogować.",
"rptmgmt.login.repeater_fallback": "repeatera",
"rptmgmt.login.retry_hint": "Sprawdź hasło i spróbuj ponownie.",
"rptmgmt.login.waiting": "Czekam na hasło…",
"rptmgmt.login_request_failed": "Żądanie logowania nie powiodło się",
"rptmgmt.logout": "Wyloguj",
"rptmgmt.logout_failed": "Wylogowanie nie powiodło się",
"rptmgmt.logout_title": "Wyloguj się z tego repeatera",
"rptmgmt.managed_repeater": "zarządzany repeater",
"rptmgmt.neigh.count": {
"one": "{count} sąsiad",
"few": "{count} sąsiadów",
"many": "{count} sąsiadów",
"other": "{count} sąsiada"
},
"rptmgmt.neigh.heard": "Słyszany",
"rptmgmt.neigh.heard_at": "Usłyszany: {time}",
"rptmgmt.neigh.list": "Lista",
"rptmgmt.neigh.loading": "Pobieranie sąsiadów z repeatera…",
"rptmgmt.neigh.loading_hint": "Długie listy są pobierane stronami i może to chwilę potrwać.",
"rptmgmt.neigh.map": "Mapa",
"rptmgmt.neigh.position_title": "Pozycja znana",
"rptmgmt.neigh.refresh_title": "Odśwież sąsiadów",
"rptmgmt.neigh.repeater": "Repeater",
"rptmgmt.neigh.showing": "(pokazano {count})",
"rptmgmt.neighbours_failed": "Nie udało się pobrać sąsiadów",
"rptmgmt.neighbours_none": "Nie zgłoszono jeszcze żadnych sąsiadów.",
"rptmgmt.neighbours_none_hint": "Sąsiedzi są poznawani z advertów repeaterów zero-hop.",
"rptmgmt.no_result": "Brak wyniku",
"rptmgmt.not_on_device": "Ten repeater nie jest zapisany na urządzeniu — nie można nim zarządzać.",
"rptmgmt.pubkey_copied": "Klucz publiczny skopiowany",
"rptmgmt.read_failed": "Nie udało się odczytać",
"rptmgmt.reboot_required": "wymagany restart",
"rptmgmt.request_failed": "Żądanie nie powiodło się",
@@ -924,6 +989,7 @@
"rptmgmt.set.agc_reset": "Interwał resetu AGC",
"rptmgmt.set.agc_reset_help": "Wielokrotność 4; 0 wyłącza okresowe resety AGC.",
"rptmgmt.set.allow_read_only": "Zezwól na dostęp gościa (tylko odczyt)",
"rptmgmt.set.apply_count": "Zastosuj ({count})",
"rptmgmt.set.basic": "Podstawowe",
"rptmgmt.set.direct_txdelay": "Współczynnik opóźnienia TX direct (02)",
"rptmgmt.set.dutycycle": "Duty cycle (%)",
@@ -933,7 +999,14 @@
"rptmgmt.set.guest_password": "Hasło gościa",
"rptmgmt.set.guest_password_help": "Hasło do logowania gościa w trybie tylko do odczytu.",
"rptmgmt.set.int_thresh": "Próg zakłóceń",
"rptmgmt.set.intro": "Ustawienia są odczytywane na żywo z repeatera. Rozwiń sekcję, aby ją wczytać — każde pole to jedna podróż przez sieć mesh, więc sekcja może się ładować kilka sekund.",
"rptmgmt.set.lat": "Szerokość geogr.",
"rptmgmt.set.load_errors": {
"one": "Nie udało się wczytać {count} pola — Odśwież ponawia całą sekcję.",
"few": "Nie udało się wczytać {count} pól — Odśwież ponawia całą sekcję.",
"many": "Nie udało się wczytać {count} pól — Odśwież ponawia całą sekcję.",
"other": "Nie udało się wczytać {count} pola — Odśwież ponawia całą sekcję."
},
"rptmgmt.set.location": "Lokalizacja",
"rptmgmt.set.lon": "Długość geogr.",
"rptmgmt.set.loop_detect": "Wykrywanie pętli",
@@ -948,9 +1021,11 @@
"rptmgmt.set.password": "Hasło administratora",
"rptmgmt.set.password_help": "Tylko do zapisu — bieżące hasło nigdy nie jest pokazywane. Zmiana nie wylogowuje aktywnych sesji.",
"rptmgmt.set.path_hash_mode": "Tryb hasha ścieżki (02)",
"rptmgmt.set.pw_unchanged_ph": "(bez zmian)",
"rptmgmt.set.radio": "Radio",
"rptmgmt.set.radio_note": "Częstotliwość, bandwidth, SF i CR działają po restarcie. Moc TX działa natychmiast.",
"rptmgmt.set.radio_params": "Parametry radia",
"rptmgmt.set.reading": "Odczyt z repeatera… (jedna podróż przez sieć na pole)",
"rptmgmt.set.reboot_note": "Część zmian działa dopiero po restarcie — użyj Akcje → Restart.",
"rptmgmt.set.repeat": "Przekazuj pakiety",
"rptmgmt.set.rxgain": "Wzmocnienie RX (boosted gain)",
@@ -958,7 +1033,33 @@
"rptmgmt.set.txdelay": "Współczynnik opóźnienia TX (02)",
"rptmgmt.settings_apply_failed": "Nie udało się zastosować ustawień",
"rptmgmt.settings_read_failed": "Nie udało się odczytać ustawień",
"rptmgmt.status.battery": "Bateria",
"rptmgmt.status.clock": "Zegar",
"rptmgmt.status.clock_fetch": "pobierz",
"rptmgmt.status.duplicates": "Duplikaty",
"rptmgmt.status.events": "Zdarzenia debug / błędów",
"rptmgmt.status.last_rssi": "Ostatni RSSI",
"rptmgmt.status.last_snr": "Ostatni SNR",
"rptmgmt.status.loading": "Pobieranie statusu z repeatera…",
"rptmgmt.status.noise": "Poziom szumów",
"rptmgmt.status.packets": "Statystyki pakietów",
"rptmgmt.status.queue": "Długość kolejki",
"rptmgmt.status.radio": "Statystyki radia",
"rptmgmt.status.received": "Odebrane",
"rptmgmt.status.refresh_title": "Odśwież status",
"rptmgmt.status.rx_airtime": "Czas antenowy RX",
"rptmgmt.status.rx_errors": "Błędy RX",
"rptmgmt.status.sent": "Wysłane",
"rptmgmt.status.system": "Informacje systemowe",
"rptmgmt.status.tx_airtime": "Czas antenowy TX",
"rptmgmt.status.uptime": "Czas pracy",
"rptmgmt.status.utilization": "Wykorzystanie kanału",
"rptmgmt.status_failed": "Nie udało się pobrać statusu",
"rptmgmt.tel.channel": "Kanał {n}",
"rptmgmt.tel.device_suffix": "urządzenie",
"rptmgmt.tel.loading": "Pobieranie telemetrii z repeatera…",
"rptmgmt.tel.loading_hint": "Ścieżki wielohopowe mogą zająć nawet minutę.",
"rptmgmt.tel.refresh_title": "Odśwież telemetrię",
"rptmgmt.telemetry_failed": "Nie udało się pobrać telemetrii",
"rptmgmt.telemetry_none": "Brak danych telemetrycznych.",
"rptmgmt.this_repeater": "Ten repeater",
@@ -988,6 +1089,7 @@
"rptmgmt.tools.telemetry": "Telemetria",
"rptmgmt.tools.telemetry_desc": "Kanały czujników (Cayenne LPP)",
"rptmgmt.tools_heading": "Narzędzia zarządzania",
"rptmgmt.updated_ago": "Zaktualizowano {time} temu",
"rptmgmt.updated_just_now": "Zaktualizowano przed chwilą",
"search.count": {
"one": "{count} wynik",