diff --git a/frontend/src/locales/bg.json b/frontend/src/locales/bg.json index 830447a..144cfd1 100644 --- a/frontend/src/locales/bg.json +++ b/frontend/src/locales/bg.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Няма записи OpenBridge от сървъра.", "opb_activity": "Активни потоци", + "opb_status": "Статус", + "opb_status_online": "Онлайн", + "opb_status_offline": "Офлайн", "toptg_no_data": "Все още няма данни за брой TG. Данните се записват при приключване на гласови QSO (продължителност > 5с).", "mon_wait": "Изчакване на регистрация...", "sslog_login_ip": "Вход по IP", diff --git a/frontend/src/locales/ca.json b/frontend/src/locales/ca.json index a61fe92..a2306b3 100644 --- a/frontend/src/locales/ca.json +++ b/frontend/src/locales/ca.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Sense entrades OpenBridge del servidor.", "opb_activity": "Fluxos actius", + "opb_status": "Estat", + "opb_status_online": "En línia", + "opb_status_offline": "Fora de línia", "toptg_no_data": "Encara no hi ha dades de recompte TG. Les dades es desen quan acaben els QSO de veu (durada > 5s).", "mon_wait": "Esperant registre...", "sslog_login_ip": "Entrar per IP", diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index d781806..bb2aa17 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -330,6 +330,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Keine OpenBridge-Einträge vom Server.", "opb_activity": "Aktive Streams", + "opb_status": "Status", + "opb_status_online": "Online", + "opb_status_offline": "Offline", "toptg_no_data": "Noch keine TG-Zähldaten. Daten werden gespeichert, wenn Sprach-QSOs enden (Dauer > 5s).", "mon_wait": "Waiting for log...", "tbl_count_bridges": "Number of Bridges", diff --git a/frontend/src/locales/el.json b/frontend/src/locales/el.json index c349d98..5ef669f 100644 --- a/frontend/src/locales/el.json +++ b/frontend/src/locales/el.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Δεν υπάρχουν εγγραφές OpenBridge από τον διακομιστή.", "opb_activity": "Ενεργές ροές", + "opb_status": "Κατάσταση", + "opb_status_online": "Σε σύνδεση", + "opb_status_offline": "Εκτός σύνδεσης", "toptg_no_data": "Δεν υπάρχουν ακόμη δεδομένα αριθμού TG. Οι δεδομένα αποθηκεύονται όταν τερματίζουν οι φωνητικές QSO (διάρκεια > 5δ).", "mon_wait": "Αναμονή καταγραφής...", "sslog_login_ip": "Σύνδεση με IP", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8602c2a..49e9fd4 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -282,6 +282,9 @@ "opb_title": "OpenBridge", "opb_no_data": "No OpenBridge entries from server.", "opb_activity": "Active streams", + "opb_status": "Status", + "opb_status_online": "Online", + "opb_status_offline": "Offline", "toptg_no_data": "No TG count data yet. Data is saved when voice QSOs end (duration > 5s).", "mon_wait": "Waiting for log...", "sslog_login_ip": "Login by IP", diff --git a/frontend/src/locales/es.json b/frontend/src/locales/es.json index 926b854..b22d70a 100644 --- a/frontend/src/locales/es.json +++ b/frontend/src/locales/es.json @@ -282,6 +282,9 @@ "opb_title": "OpenBridge", "opb_no_data": "No hay entradas OpenBridge del servidor.", "opb_activity": "Flujos activos", + "opb_status": "Estado", + "opb_status_online": "En línea", + "opb_status_offline": "Fuera de línea", "toptg_no_data": "Aún no hay datos de conteo TG. Se guardan cuando terminan QSO de voz (duración > 5s).", "mon_wait": "Esperando registro...", "sslog_login_ip": "Entrar por IP", diff --git a/frontend/src/locales/et.json b/frontend/src/locales/et.json index 63ef19b..4275f08 100644 --- a/frontend/src/locales/et.json +++ b/frontend/src/locales/et.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Serverist OpenBridge kirjeid pole.", "opb_activity": "Aktiivsed voogud", + "opb_status": "Olek", + "opb_status_online": "Võrgus", + "opb_status_offline": "Võrgust väljas", "toptg_no_data": "TG loenduse andmeid pole. Andmed salvestatakse, kui hääle QSO-d lõpevad (kestus > 5s).", "mon_wait": "Ootan logi...", "sslog_login_ip": "Logi sisse IP kaudu", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index cbf525d..e62e8ea 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -330,6 +330,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Aucune entrée OpenBridge du serveur.", "opb_activity": "Flux actifs", + "opb_status": "Statut", + "opb_status_online": "En ligne", + "opb_status_offline": "Hors ligne", "toptg_no_data": "Pas encore de données de décompte TG. Les données sont enregistrées à la fin des QSO voix (durée > 5 s).", "mon_wait": "Waiting for log...", "tbl_count_bridges": "Number of Bridges", diff --git a/frontend/src/locales/it.json b/frontend/src/locales/it.json index 4d9d1da..e991c84 100644 --- a/frontend/src/locales/it.json +++ b/frontend/src/locales/it.json @@ -330,6 +330,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Nessuna voce OpenBridge dal server.", "opb_activity": "Stream attivi", + "opb_status": "Stato", + "opb_status_online": "Online", + "opb_status_offline": "Offline", "toptg_no_data": "Ancora nessun dato di conteggio TG. I dati vengono salvati quando le QSO vocali terminano (durata > 5s).", "mon_wait": "Waiting for log...", "tbl_count_bridges": "Number of Bridges", diff --git a/frontend/src/locales/nl.json b/frontend/src/locales/nl.json index 9018384..010fc5c 100644 --- a/frontend/src/locales/nl.json +++ b/frontend/src/locales/nl.json @@ -330,6 +330,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Geen OpenBridge-vermeldingen van de server.", "opb_activity": "Actieve streams", + "opb_status": "Status", + "opb_status_online": "Online", + "opb_status_offline": "Offline", "toptg_no_data": "Nog geen TG-telgegevens. Gegevens worden opgeslagen wanneer spraak-QSO's eindigen (duur > 5s).", "mon_wait": "Waiting for log...", "tbl_count_bridges": "Number of Bridges", diff --git a/frontend/src/locales/pl.json b/frontend/src/locales/pl.json index 25c1ff4..74bc014 100644 --- a/frontend/src/locales/pl.json +++ b/frontend/src/locales/pl.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Brak wpisów OpenBridge z serwera.", "opb_activity": "Aktywne strumienie", + "opb_status": "Status", + "opb_status_online": "Online", + "opb_status_offline": "Offline", "toptg_no_data": "Brak danych liczby TG. Dane są zapisywane po zakończeniu QSO głosowych (czas > 5s).", "mon_wait": "Oczekiwanie na dziennik...", "sslog_login_ip": "Zaloguj po IP", diff --git a/frontend/src/locales/pt.json b/frontend/src/locales/pt.json index 2a27a7a..40afaf5 100644 --- a/frontend/src/locales/pt.json +++ b/frontend/src/locales/pt.json @@ -330,6 +330,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Sem entradas OpenBridge do servidor.", "opb_activity": "Fluxos ativos", + "opb_status": "Estado", + "opb_status_online": "Online", + "opb_status_offline": "Offline", "toptg_no_data": "Ainda sem dados de contagem TG. Os dados são guardados quando terminam QSO de voz (duração > 5s).", "mon_wait": "Waiting for log...", "tbl_count_bridges": "Number of Bridges", diff --git a/frontend/src/locales/sr.json b/frontend/src/locales/sr.json index a5ed5cd..269bba4 100644 --- a/frontend/src/locales/sr.json +++ b/frontend/src/locales/sr.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Нема уноса OpenBridge са сервера.", "opb_activity": "Активни токови", + "opb_status": "Статус", + "opb_status_online": "На мрежи", + "opb_status_offline": "Ван мреже", "toptg_no_data": "Још нема података броја TG. Подаци се чувају када гласовни QSO заврше (трајање > 5с).", "mon_wait": "Чекање дневника...", "sslog_login_ip": "Пријава по IP", diff --git a/frontend/src/locales/th.json b/frontend/src/locales/th.json index 7f9f803..3dfb72f 100644 --- a/frontend/src/locales/th.json +++ b/frontend/src/locales/th.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "ไม่มีรายการ OpenBridge จากเซิร์ฟเวอร์", "opb_activity": "สตรีมที่ใช้งาน", + "opb_status": "สถานะ", + "opb_status_online": "ออนไลน์", + "opb_status_offline": "ออฟไลน์", "toptg_no_data": "ยังไม่มีข้อมูลจำนวน TG ข้อมูลจะถูกบันทึกเมื่อ QSO เสียงสิ้นสุด (ระยะเวลา > 5 วินาที)", "mon_wait": "กำลังรอบันทึก...", "sslog_login_ip": "เข้าสู่ระบบด้วย IP", diff --git a/frontend/src/locales/tr.json b/frontend/src/locales/tr.json index c40086b..7f2e232 100644 --- a/frontend/src/locales/tr.json +++ b/frontend/src/locales/tr.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Sunucudan OpenBridge girişi yok.", "opb_activity": "Aktif akışlar", + "opb_status": "Durum", + "opb_status_online": "Çevrimiçi", + "opb_status_offline": "Çevrimdışı", "toptg_no_data": "Henüz TG sayım verisi yok. Veriler ses QSO'ları bittiğinde (süre > 5sn) kaydedilir.", "mon_wait": "Günlük bekleniyor...", "sslog_login_ip": "IP ile giriş", diff --git a/frontend/src/locales/uk.json b/frontend/src/locales/uk.json index b09980a..9dbf591 100644 --- a/frontend/src/locales/uk.json +++ b/frontend/src/locales/uk.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "Немає записів OpenBridge від сервера.", "opb_activity": "Активні потоки", + "opb_status": "Статус", + "opb_status_online": "У мережі", + "opb_status_offline": "Поза мережею", "toptg_no_data": "Ще немає даних підрахунку TG. Дані зберігаються після завершення голосових QSO (тривалість > 5с).", "mon_wait": "Очікування журналу...", "sslog_login_ip": "Увійти за IP", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 69288ec..2b06e10 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -275,6 +275,9 @@ "opb_title": "OpenBridge", "opb_no_data": "服务器暂无 OpenBridge 条目。", "opb_activity": "活动流", + "opb_status": "状态", + "opb_status_online": "在线", + "opb_status_offline": "离线", "toptg_no_data": "暂无 TG 计数数据。语音 QSO 结束后(时长 > 5 秒)会保存数据。", "mon_wait": "正在等待记录...", "sslog_login_ip": "按 IP 登录", diff --git a/frontend/src/pages/OpenBridge.tsx b/frontend/src/pages/OpenBridge.tsx index 993939c..0804bc4 100644 --- a/frontend/src/pages/OpenBridge.tsx +++ b/frontend/src/pages/OpenBridge.tsx @@ -45,6 +45,7 @@ type OpenBridgeEntry = { NETWORK_ID?: number | string; TARGET_IP?: string; TARGET_PORT?: string; + CONNECTED?: boolean; STREAMS?: Record; }; type Ctable = { @@ -56,9 +57,30 @@ type Ctable = { type OpbPayload = { ctable?: Ctable; dbridges?: boolean }; /** Suppress BCSQ fan-out flashes (START then END with Time: 0s in the same second). */ -const OBP_CHIP_MIN_AGE_SEC = 0.1; +const OBP_CHIP_MIN_AGE_SEC = 0.5; const OBP_CHIP_TICK_MS = 1000; +/** Country code from numeric network id (first 2-3 digits). */ +function getCountryCodeFromId(id: number | string): string { + const n = String(id).replace(/\D/g, ''); + if (n.length >= 3) return n.slice(0, 3); + if (n.length >= 2) return n.slice(0, 2); + return n || ''; +} + +function FlagImg({ code, fallback = 'world' }: { code: string; fallback?: string }) { + const src = code ? `/img/flags/${code}.png` : `/img/flags/${fallback}.png`; + return ( + { (e.target as HTMLImageElement).src = `/img/flags/world.png`; }} + sx={{ width: 20, height: 14, objectFit: 'contain', flexShrink: 0 }} + /> + ); +} + /** One visible row per (direction, callsign, TG); report may still carry duplicate stream_ids. */ function dedupeStreamRows(streams: Record) { type Row = { id: string; dir: string; subCall: string; tg: string; timeout: number }; @@ -90,6 +112,29 @@ function visibleStreamRows(streams: Record, nowSec: number) ); } +function ObpStatusDot({ connected, t }: { connected: boolean; t: (key: string, opts?: { defaultValue?: string }) => string }) { + const label = connected + ? t('opb_status_online', { defaultValue: 'Online' }) + : t('opb_status_offline', { defaultValue: 'Offline' }); + const color = connected ? 'success.main' : 'error.main'; + return ( + + ); +} + export default function OpenBridge() { const { t } = useTranslation(); const [nowSec, setNowSec] = useState(() => Date.now() / 1000); @@ -128,8 +173,8 @@ export default function OpenBridge() { {t('opb_title', { defaultValue: 'OpenBridge' })} - {t('lnksys_system', { defaultValue: 'OpenBridge' })} - {t('lnksys_network_id', { defaultValue: 'Network ID' })} + {t('lnksys_system', { defaultValue: 'Service' })} + {t('lnksys_network_id', { defaultValue: 'Network ID' })} {t('opb_activity', { defaultValue: 'Active streams' })} @@ -137,6 +182,8 @@ export default function OpenBridge() { {Object.entries(openbridges).map(([name, ob]) => { const streams = ob?.STREAMS ?? {}; const streamList = visibleStreamRows(streams, nowSec); + const networkId = ob?.NETWORK_ID ?? ''; + const flagCode = networkId !== '' ? getCountryCodeFromId(networkId) : ''; return ( - {name} + + {typeof ob?.CONNECTED === 'boolean' && ( + + )} + {name} + - - {String(ob?.NETWORK_ID ?? '')} + + + {networkId !== '' && } + {String(networkId)} + {/* diff --git a/monitor/src/adn_monitor/application/hblink_table.py b/monitor/src/adn_monitor/application/hblink_table.py index 80f6fc4..5d30320 100644 --- a/monitor/src/adn_monitor/application/hblink_table.py +++ b/monitor/src/adn_monitor/application/hblink_table.py @@ -223,6 +223,15 @@ def build_hblink_table_impl( stats_table["OPENBRIDGES"][name]["NETWORK_ID"] = int_id_fn(data.get("NETWORK_ID", 0)) # TARGET_IP/TARGET_PORT omitted on purpose: not sent over WebSocket, not shown in UI stats_table["OPENBRIDGES"][name]["STREAMS"] = {} + _apply_openbridge_connected(stats_table["OPENBRIDGES"][name], data) + + +def _apply_openbridge_connected(obp_row: dict[str, Any], data: dict[str, Any]) -> None: + """Sync ENHANCED OBP keepalive status from CONFIG into CTABLE (boolean CONNECTED).""" + if "OBP_CONNECTED" in data: + obp_row["CONNECTED"] = bool(data["OBP_CONNECTED"]) + else: + obp_row.pop("CONNECTED", None) def clean_te(stats_table: dict) -> None: @@ -277,6 +286,9 @@ def update_hblink_table_impl( "NETWORK_ID": int_id_fn(data.get("NETWORK_ID", 0)), "STREAMS": {}, } + obp_row = stats_table["OPENBRIDGES"][name] + obp_row["NETWORK_ID"] = int_id_fn(data.get("NETWORK_ID", 0)) + _apply_openbridge_connected(obp_row, data) masters_table = stats_table.setdefault("MASTERS", {}) for name in config: if config[name].get("MODE") != "MASTER": diff --git a/monitor/src/adn_monitor/application/report_mapper.py b/monitor/src/adn_monitor/application/report_mapper.py index 84807e5..92e6790 100644 --- a/monitor/src/adn_monitor/application/report_mapper.py +++ b/monitor/src/adn_monitor/application/report_mapper.py @@ -227,6 +227,8 @@ def dashboard_state_to_config(doc: dict[str, Any], *, ts: float | None = None) - entry["NETWORK_ID"] = _bytes_4(int(obp["network_id"])) if obp.get("enhanced_obp"): entry["ENHANCED_OBP"] = True + if isinstance(obp.get("connected"), bool): + entry["OBP_CONNECTED"] = obp["connected"] config[str(name)] = entry return config diff --git a/monitor/src/adn_monitor/application/ws_ctable_views.py b/monitor/src/adn_monitor/application/ws_ctable_views.py index db3fa82..caa6daa 100644 --- a/monitor/src/adn_monitor/application/ws_ctable_views.py +++ b/monitor/src/adn_monitor/application/ws_ctable_views.py @@ -137,7 +137,10 @@ def opb_semantic_fingerprint(ctable: dict[str, Any], dbridges: bool) -> str: if not isinstance(sys_ob, dict): continue streams = sys_ob.get("STREAMS") or {} - ob_out[sys_name] = {"STREAMS": _streams_semantic(streams)} + ob_entry: dict[str, Any] = {"STREAMS": _streams_semantic(streams)} + if isinstance(sys_ob.get("CONNECTED"), bool): + ob_entry["CONNECTED"] = sys_ob["CONNECTED"] + ob_out[sys_name] = ob_entry return _fingerprint_dumps({"ctable": {"OPENBRIDGES": ob_out}, "dbridges": dbridges}) diff --git a/monitor/tests/test_dashboard_state.py b/monitor/tests/test_dashboard_state.py index 7ef2f4b..74f2acc 100644 --- a/monitor/tests/test_dashboard_state.py +++ b/monitor/tests/test_dashboard_state.py @@ -124,6 +124,57 @@ def test_dashboard_state_to_config_maps_linked_systems(): assert cfg["OBP-CL"]["ENHANCED_OBP"] is True +def test_dashboard_state_to_config_maps_obp_connected(): + payload = { + "type": "dashboard_state", + "ts": 1.0, + "ctable": { + "MASTERS": {}, + "PEERS": {}, + "OPENBRIDGES": { + "OBP-CL": { + "mode": "OPENBRIDGE", + "network_id": 73010, + "enhanced_obp": True, + "connected": True, + "streams": {}, + } + }, + }, + } + cfg = dashboard_state_to_config(payload) + assert cfg["OBP-CL"]["OBP_CONNECTED"] is True + + +def test_process_state_snd_applies_obp_connected(): + state = MonitorState() + state.server_mode = ServerMode.V2 + state.report_protocol = 2 + deps = _deps() + deps["report_decoder"] = PickleJsonReportPayloadDecoder() + payload = { + "type": "dashboard_state", + "ts": 1.0, + "ctable": { + "MASTERS": {}, + "PEERS": {}, + "OPENBRIDGES": { + "OBP-CL": { + "mode": "OPENBRIDGE", + "network_id": 73010, + "enhanced_obp": True, + "connected": False, + "streams": {}, + } + }, + }, + } + frame = Opcode.STATE_SND + json.dumps(payload, separators=(",", ":")).encode() + result = process_message(frame, state, **deps) + assert not is_fail(result) + assert state.CTABLE["OPENBRIDGES"]["OBP-CL"]["CONNECTED"] is False + + def test_process_state_snd_applies_ctable(): state = MonitorState() state.server_mode = ServerMode.V2 diff --git a/monitor/tests/test_report_mapper.py b/monitor/tests/test_report_mapper.py index fdec449..7e3dcb0 100644 --- a/monitor/tests/test_report_mapper.py +++ b/monitor/tests/test_report_mapper.py @@ -9,6 +9,7 @@ from adn_monitor.application.report_mapper import ( REPORT_PROTOCOL, + dashboard_state_to_config, decode_report_payload, merge_routing_delta, merge_topology_delta, @@ -117,6 +118,26 @@ def test_topology_to_config_maps_rf_mode_and_ua_multi(): assert peer["UA_MULTI_TGS"] == {"2": [730444]} +def test_dashboard_state_to_config_omits_obp_connected_when_absent(): + payload = { + "type": "dashboard_state", + "ts": 1.0, + "ctable": { + "MASTERS": {}, + "PEERS": {}, + "OPENBRIDGES": { + "OBP-CL": { + "mode": "OPENBRIDGE", + "network_id": 73010, + "streams": {}, + } + }, + }, + } + cfg = dashboard_state_to_config(payload) + assert "OBP_CONNECTED" not in cfg["OBP-CL"] + + def test_routing_table_to_bridges_accepts_legacy_bridge_key(): routing = { "type": "routing_table", diff --git a/monitor/tests/test_ws_ctable_views.py b/monitor/tests/test_ws_ctable_views.py index 28e992c..37da020 100644 --- a/monitor/tests/test_ws_ctable_views.py +++ b/monitor/tests/test_ws_ctable_views.py @@ -35,6 +35,28 @@ def test_main_dashboard_fingerprint_ignores_timeout() -> None: assert fp1 == fp2 +def test_opb_semantic_fingerprint_includes_connected() -> None: + ctable_online = { + "OPENBRIDGES": { + "OBP-CL": { + "CONNECTED": True, + "STREAMS": {"1": ["RX", "CE5RPY", "730", 1000.0]}, + }, + }, + } + ctable_offline = { + "OPENBRIDGES": { + "OBP-CL": { + "CONNECTED": False, + "STREAMS": {"1": ["RX", "CE5RPY", "730", 1000.0]}, + }, + }, + } + fp_online = ws_ctable_views.opb_semantic_fingerprint(ctable_online, False) + fp_offline = ws_ctable_views.opb_semantic_fingerprint(ctable_offline, False) + assert fp_online != fp_offline + + def test_lastheard_db_refresh_needed() -> None: assert ws_ctable_views.lastheard_db_refresh_needed( {"call_type": "GROUP VOICE", "action": "START", "direction": "RX"}