fix(presence): clarify post-migration offline state and emit peer_online events.

After RustDesk→BetterDesk migration, HTTP heartbeats can update last seen while
UDP signal registration is still missing. Show a distinct "No signal" badge,
document the two-channel presence model, and publish peer_online on registration.
This commit is contained in:
UNITRONIX
2026-06-07 00:13:54 +02:00
parent 9aee138c1b
commit bb3cc09c3f
35 changed files with 301 additions and 73 deletions
+14
View File
@@ -65,6 +65,19 @@ func signalLimiterKey(kind, clientHost, peerID string) string {
return fmt.Sprintf("%s:%s:%s", kind, clientHost, peerID)
}
func (s *Server) publishPeerOnline(id string) {
if s.eventBus == nil {
return
}
s.eventBus.Publish(events.Event{
Type: events.EventPeerOnline,
Data: map[string]string{
"id": id,
"status": "online",
},
})
}
func (s *Server) allowRegistration(clientHost, peerID string, knownPeer bool) bool {
if s.limiter == nil {
return true
@@ -319,6 +332,7 @@ func (s *Server) handleRegisterPeer(msg *pb.RegisterPeer, raddr *net.UDPAddr) {
log.Printf("[signal] New peer registered: %s from %s (pk_loaded=%v)", id, raddr, len(entry.PK) > 0)
s.db.UpdatePeerStatus(id, "ONLINE", raddr.IP.String())
s.publishPeerOnline(id)
}
// handleRegisterPk processes a public key registration.
+25
View File
@@ -9,6 +9,7 @@ import (
"github.com/unitronix/betterdesk-server/config"
"github.com/unitronix/betterdesk-server/db"
"github.com/unitronix/betterdesk-server/events"
"github.com/unitronix/betterdesk-server/peer"
pb "github.com/unitronix/betterdesk-server/proto"
"github.com/unitronix/betterdesk-server/ratelimit"
@@ -363,3 +364,27 @@ func TestOrgPolicyForcesRelayOnPunchHole(t *testing.T) {
t.Fatal("org block_direct_p2p should force relay")
}
}
func TestPublishPeerOnlineEmitsEvent(t *testing.T) {
srv, _ := newTestSignalServer(t, config.EnrollmentModeOpen)
sub := srv.eventBus.Subscribe(events.EventPeerOnline)
defer srv.eventBus.Unsubscribe(sub)
srv.publishPeerOnline("PEERON1")
select {
case evt := <-sub.Ch:
if evt.Type != events.EventPeerOnline {
t.Fatalf("event type = %s, want %s", evt.Type, events.EventPeerOnline)
}
if evt.Data["id"] != "PEERON1" {
t.Fatalf("event id = %q, want PEERON1", evt.Data["id"])
}
if evt.Data["status"] != "online" {
t.Fatalf("event status = %q, want online", evt.Data["status"])
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for peer_online event")
}
}
+1
View File
@@ -336,6 +336,7 @@ func (s *Server) handleRegisterPeerWS(msg *pb.RegisterPeer, remoteAddr string) *
log.Printf("[signal] New WS peer registered: %s from %s", id, remoteAddr)
s.db.UpdatePeerStatus(id, "ONLINE", remoteAddr)
s.publishPeerOnline(id)
return &pb.RendezvousMessage{
Union: &pb.RendezvousMessage_RegisterPeerResponse{
+64
View File
@@ -0,0 +1,64 @@
# RustDesk → BetterDesk server migration — client presence
## Symptom
After replacing a RustDesk Community server (hbbs/hbbr) with BetterDesk while keeping the same IP, keys, and client URLs:
- Remote sessions may work via the relay server.
- The console shows **Last seen** as recent (e.g. “6 minutes ago”).
- The **Online** badge stays **Offline** until each client is restarted.
## Why this happens
BetterDesk tracks presence in two separate channels:
| Channel | Client config | Server port | Updates | Console column |
|---------|---------------|-------------|---------|----------------|
| HTTP heartbeat | `api_server` | 21114 (`/api/heartbeat`) | DB `last_online`, DB `status` | **Last seen** |
| UDP registration | `rendezvous_server` / `id_server` | **21116** (`RegisterPeer`) | In-memory `live_online` | **Online / Offline** |
The devices table uses **`live_online`** (signal-server memory map), not the DB `status` field alone.
After migration or a BetterDesk server restart:
1. The in-memory peer table starts **empty** (`SetAllOffline()` on boot).
2. RustDesk clients often resume **HTTP** heartbeats immediately (same `api_server` URL).
3. **UDP** registration to port 21116 may stay stale until the client process or service is restarted — even when the hostname/IP did not change.
So “recent last seen + offline” is expected until clients re-register on UDP 21116.
## What to do
1. **Restart** the RustDesk client or its background service on affected machines (or reboot).
2. No need to regenerate keys if connections already work.
3. For large fleets, plan a rolling client/service restart after the server backend swap.
## Verify on the server
```bash
# Signal registration after a client restart
journalctl -u betterdesk-server -f | grep "New peer registered"
# Compare DB last seen vs live online for one peer
curl -s -H "X-API-Key: YOUR_KEY" http://127.0.0.1:21114/api/peers/DEVICE_ID | jq '{live_online, last_online, status}'
```
Checklist:
- [ ] UDP **21116** reachable from clients (firewall, NAT, Docker port mapping).
- [ ] Enrollment mode is `open`, or migrated peers already exist in the database (`managed` / `locked` can block unknown IDs).
- [ ] After restart, `live_online` becomes `true` within ~30 seconds.
## Enrollment modes
| Mode | Re-registration after migration |
|------|-------------------------------|
| `open` | Any peer with valid keys can register on UDP 21116. |
| `managed` | Peers already in the DB re-register freely; unknown IDs need approval. |
| `locked` | Only peers with a valid device token can register. |
## Related
- Go signal handler: `betterdesk-server/signal/handler.go` (UDP `RegisterPeer`)
- HTTP heartbeat (DB only): `betterdesk-server/api/client_api_handlers.go`
- Panel mapping: `web-nodejs/services/betterdeskApi.js` (`online: !!peer.live_online`)
+4 -2
View File
@@ -310,7 +310,8 @@
"group_deleted": "تم حذف مجموعة الأجهزة",
"folder_group_readonly": "تتم إدارة المجموعات المدعومة بالمجلدات من المجلدات.",
"manage_user_groups": "إدارة مجموعات المستخدمين",
"restore_failed": "فشل استعادة الجهاز"
"restore_failed": "فشل استعادة الجهاز",
"no_signal_tooltip": "اتصل العميل بـ API مؤخرًا لكنه غير مسجل على خادم الإشارة (UDP :21116). أعد تشغيل العميل بعد ترحيل الخادم."
},
"keys": {
"title": "مفاتيح الخادم",
@@ -732,7 +733,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "جارٍ التحقق...",
"label": "Status"
"label": "Status",
"no_signal": "لا إشارة"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Skupina zařízení odstraněna",
"folder_group_readonly": "Skupiny založené na složkách se spravují ve složkách.",
"manage_user_groups": "Spravovat skupiny uživatelů",
"restore_failed": "Ne udalo sie przywrocic urzadzenia"
"restore_failed": "Ne udalo sie przywrocic urzadzenia",
"no_signal_tooltip": "Klient nedávno kontaktoval API, ale není zaregistrován na signal serveru (UDP :21116). Po migraci serveru restartujte klienta."
},
"keys": {
"title": "Klíče serveru",
@@ -725,7 +726,8 @@
"stopped": "Zastaveno",
"unknown": "Neznámý",
"checking": "Kontroluji...",
"label": "Stav"
"label": "Stav",
"no_signal": "Bez signálu"
},
"actions": {
"refresh": "Obnovit",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Enhedsgruppen blev slettet",
"folder_group_readonly": "Mappestøttede grupper administreres fra mapper.",
"manage_user_groups": "Administrer brugergrupper",
"restore_failed": "Enhed konnte nicht wiederhergestellt werden"
"restore_failed": "Enhed konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Klienten kontaktede API'et for nylig, men er ikke registreret på signalserveren (UDP :21116). Genstart klienten efter servermigration."
},
"keys": {
"title": "Servernøgler",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Tjekker...",
"label": "Status"
"label": "Status",
"no_signal": "Intet signal"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Gerätegruppe gelöscht",
"folder_group_readonly": "Ordnerbasierte Gruppen werden über Ordner verwaltet.",
"manage_user_groups": "Benutzergruppen verwalten",
"restore_failed": "Gerät konnte nicht wiederhergestellt werden"
"restore_failed": "Gerät konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Der Client hat kürzlich die API kontaktiert, ist aber nicht auf dem Signalserver registriert (UDP :21116). Starten Sie den Client nach der Servermigration neu."
},
"keys": {
"title": "Dashboard",
@@ -725,7 +726,8 @@
"stopped": "Gestoppt",
"unknown": "Unbekannt",
"checking": "Prüfe...",
"label": "Status"
"label": "Status",
"no_signal": "Kein Signal"
},
"actions": {
"refresh": "Aktualisieren",
+2
View File
@@ -178,6 +178,7 @@
"filter_online": "Online",
"filter_offline": "Offline",
"filter_banned": "Banned",
"no_signal_tooltip": "Client contacted the API recently but is not registered on the signal server (UDP :21116). Restart the client after server migration.",
"selected": "selected",
"showing": "Showing",
"of": "of",
@@ -730,6 +731,7 @@
"status": {
"online": "Online",
"offline": "Offline",
"no_signal": "No signal",
"banned": "Banned",
"running": "Running",
"stopped": "Stopped",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Grupo de dispositivos eliminado",
"folder_group_readonly": "Los grupos basados en carpetas se administran desde Carpetas.",
"manage_user_groups": "Administrar grupos de usuarios",
"restore_failed": "Error al restaurar el dispositivo"
"restore_failed": "Error al restaurar el dispositivo",
"no_signal_tooltip": "El cliente contactó la API recientemente, pero no está registrado en el servidor de señal (UDP :21116). Reinicie el cliente tras la migración del servidor."
},
"keys": {
"title": "Panel",
@@ -725,7 +726,8 @@
"stopped": "Detenido",
"unknown": "Desconocido",
"checking": "Verificando...",
"label": "Status"
"label": "Status",
"no_signal": "Sin señal"
},
"actions": {
"refresh": "Actualizar",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Laiteryhmä poistettu",
"folder_group_readonly": "Kansiota tukevia ryhmiä hallitaan kansioista.",
"manage_user_groups": "Hallinnoi käyttäjäryhmiä",
"restore_failed": "Laite konnte nicht wiederhergestellt werden"
"restore_failed": "Laite konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Asiakas otti hiljattain yhteyttä API:in, mutta ei ole rekisteröitynyt signal-palvelimeen (UDP :21116). Käynnistä asiakas uudelleen palvelinsiirron jälkeen."
},
"keys": {
"title": "Palvelimen avaimet",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Tarkistetaan...",
"label": "Status"
"label": "Status",
"no_signal": "Ei signaalia"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Groupe dappareils supprimé",
"folder_group_readonly": "Les groupes associés aux dossiers sont gérés depuis les dossiers.",
"manage_user_groups": "Gérer les groupes dutilisateurs",
"restore_failed": "Échec de la restauration de l'appareil"
"restore_failed": "Échec de la restauration de l'appareil",
"no_signal_tooltip": "Le client a contacté l'API récemment mais n'est pas enregistré sur le serveur de signal (UDP :21116). Redémarrez le client après la migration du serveur."
},
"keys": {
"title": "Tableau de bord",
@@ -725,7 +726,8 @@
"stopped": "Arrêté",
"unknown": "Inconnu",
"checking": "Vérification...",
"label": "Status"
"label": "Status",
"no_signal": "Pas de signal"
},
"actions": {
"refresh": "Actualiser",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "डिवाइस समूह हटा दिया गया",
"folder_group_readonly": "फ़ोल्डर-समर्थित समूहों को फ़ोल्डरों से प्रबंधित किया जाता है।",
"manage_user_groups": "उपयोगकर्ता समूह प्रबंधित करें",
"restore_failed": "डिवाइस पुनर्स्थापित करने में विफल"
"restore_failed": "डिवाइस पुनर्स्थापित करने में विफल",
"no_signal_tooltip": "क्लाइंट ने हाल ही में API से संपर्क किया, लेकिन सिग्नल सर्वर (UDP :21116) पर पंजीकृत नहीं है। सर्वर माइग्रेशन के बाद क्लाइंट को पुनः प्रारंभ करें।"
},
"keys": {
"title": "सर्वर कुंजी",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "जाँच हो रही है...",
"label": "Status"
"label": "Status",
"no_signal": "कोई सिग्नल नहीं"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Eszközcsoport törölve",
"folder_group_readonly": "A mappaalapú csoportok kezelése mappákból történik.",
"manage_user_groups": "Felhasználói csoportok kezelése",
"restore_failed": "Eszköz konnte nicht wiederhergestellt werden"
"restore_failed": "Eszköz konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Az ügyfél nemrég elérte az API-t, de nincs regisztrálva a jelzőszerveren (UDP :21116). A szerver migrációja után indítsa újra az ügyfelet."
},
"keys": {
"title": "Szerverkulcsok",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Ellenőrzés...",
"label": "Status"
"label": "Status",
"no_signal": "Nincs jel"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Grup perangkat dihapus",
"folder_group_readonly": "Grup yang didukung folder dikelola dari folder.",
"manage_user_groups": "Kelola grup pengguna",
"restore_failed": "Gagal memulihkan perangkat"
"restore_failed": "Gagal memulihkan perangkat",
"no_signal_tooltip": "Klien baru saja menghubungi API tetapi tidak terdaftar di server sinyal (UDP :21116). Mulai ulang klien setelah migrasi server."
},
"keys": {
"title": "Kunci Server",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Memeriksa...",
"label": "Status"
"label": "Status",
"no_signal": "Tanpa sinyal"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Gruppo dispositivi eliminato",
"folder_group_readonly": "I gruppi basati su cartelle sono gestiti tramite Cartelle.",
"manage_user_groups": "Gestisci gruppi utente",
"restore_failed": "Èchec de la restauration de il dispositivo"
"restore_failed": "Èchec de la restauration de il dispositivo",
"no_signal_tooltip": "Il client ha contattato l'API di recente ma non è registrato sul server di segnale (UDP :21116). Riavvia il client dopo la migrazione del server."
},
"keys": {
"title": "Chiavi del server",
@@ -725,7 +726,8 @@
"stopped": "Fermato",
"unknown": "Sconosciuto",
"checking": "Verifica...",
"label": "Status"
"label": "Status",
"no_signal": "Nessun segnale"
},
"actions": {
"refresh": "Aggiorna",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "デバイスグループが削除されました",
"folder_group_readonly": "フォルダーベースのグループはフォルダーから管理されます。",
"manage_user_groups": "ユーザーグループの管理",
"restore_failed": "デバイスの復元に失敗しました"
"restore_failed": "デバイスの復元に失敗しました",
"no_signal_tooltip": "クライアントは最近 API に接続しましたが、シグナルサーバー (UDP :21116) に登録されていません。サーバー移行後にクライアントを再起動してください。"
},
"keys": {
"title": "サーバーキー",
@@ -725,7 +726,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "確認中...",
"label": "Status"
"label": "Status",
"no_signal": "シグナルなし"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "기기 그룹이 삭제되었습니다.",
"folder_group_readonly": "폴더 지원 그룹은 폴더에서 관리됩니다.",
"manage_user_groups": "사용자 그룹 관리",
"restore_failed": "기기 복원에 실패했습니다"
"restore_failed": "기기 복원에 실패했습니다",
"no_signal_tooltip": "클라이언트가 최근 API에 연락했지만 시그널 서버(UDP :21116)에 등록되지 않았습니다. 서버 마이그레이션 후 클라이언트를 재시작하세요."
},
"keys": {
"title": "서버 키",
@@ -725,7 +726,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "확인 중...",
"label": "Status"
"label": "Status",
"no_signal": "신호 없음"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Enhetsgruppe slettet",
"folder_group_readonly": "Mappestøttede grupper administreres fra mapper.",
"manage_user_groups": "Administrer brukergrupper",
"restore_failed": "Enhet konnte nicht wiederhergestellt werden"
"restore_failed": "Enhet konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Klienten kontaktet API-et nylig, men er ikke registrert på signalserveren (UDP :21116). Start klienten på nytt etter servermigrering."
},
"keys": {
"title": "Servernøkler",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Sjekker...",
"label": "Status"
"label": "Status",
"no_signal": "Intet signal"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Apparaatgroep verwijderd",
"folder_group_readonly": "Mapgebaseerde groepen worden via Mappen beheerd.",
"manage_user_groups": "Gebruikersgroepen beheren",
"restore_failed": "Gerat konnte nicht wiederhergestellt werden"
"restore_failed": "Gerat konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "De client heeft onlangs de API bereikt maar is niet geregistreerd op de signalserver (UDP :21116). Herstart de client na servermigratie."
},
"keys": {
"title": "Serversleutels",
@@ -725,7 +726,8 @@
"stopped": "Gestopt",
"unknown": "Onbekend",
"checking": "Controleren...",
"label": "Status"
"label": "Status",
"no_signal": "Geen signaal"
},
"actions": {
"refresh": "Vernieuwen",
+4 -2
View File
@@ -303,7 +303,8 @@
"no_data": "Brak danych o sprzęcie",
"title": "Informacje sprzętowe",
"gpu": "Karta graficzna"
}
},
"no_signal_tooltip": "Klient niedawno kontaktował API, ale nie jest zarejestrowany na serwerze sygnałowym (UDP :21116). Uruchom ponownie klienta po migracji serwera."
},
"keys": {
"title": "Klucze serwera",
@@ -735,7 +736,8 @@
"stopped": "Zatrzymany",
"unknown": "Nieznany",
"checking": "Sprawdzanie...",
"label": "Status"
"label": "Status",
"no_signal": "Brak sygnału"
},
"actions": {
"refresh": "Odśwież",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "Grupo de dispositivos excluído",
"folder_group_readonly": "Grupos baseados em pastas são gerenciados em Pastas.",
"manage_user_groups": "Gerenciar grupos de usuários",
"restore_failed": "Erro al restaurar o dispositivo"
"restore_failed": "Erro al restaurar o dispositivo",
"no_signal_tooltip": "O cliente contactou a API recentemente, mas não está registado no servidor de sinal (UDP :21116). Reinicie o cliente após a migração do servidor."
},
"keys": {
"title": "Chaves do servidor",
@@ -725,7 +726,8 @@
"stopped": "Parado",
"unknown": "Desconhecido",
"checking": "A verificar...",
"label": "Status"
"label": "Status",
"no_signal": "Sem sinal"
},
"actions": {
"refresh": "Atualizar",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Grupul de dispozitive a fost șters",
"folder_group_readonly": "Grupurile susținute de foldere sunt gestionate din dosare.",
"manage_user_groups": "Gestionați grupurile de utilizatori",
"restore_failed": "Dispozitiv konnte nicht wiederhergestellt werden"
"restore_failed": "Dispozitiv konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Clientul a contactat API-ul recent, dar nu este înregistrat pe serverul de semnal (UDP :21116). Reporniți clientul după migrarea serverului."
},
"keys": {
"title": "Cheile serverului",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Se verifică...",
"label": "Status"
"label": "Status",
"no_signal": "Fără semnal"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Enhetsgruppen har tagits bort",
"folder_group_readonly": "Mappstödda grupper hanteras från mappar.",
"manage_user_groups": "Hantera användargrupper",
"restore_failed": "Enhet konnte nicht wiederhergestellt werden"
"restore_failed": "Enhet konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Klienten kontaktade API:et nyligen men är inte registrerad på signalservern (UDP :21116). Starta om klienten efter servermigrering."
},
"keys": {
"title": "Servernycklar",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Kontrollerar...",
"label": "Status"
"label": "Status",
"no_signal": "Ingen signal"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "ลบกลุ่มอุปกรณ์แล้ว",
"folder_group_readonly": "กลุ่มที่สำรองโฟลเดอร์ได้รับการจัดการจากโฟลเดอร์",
"manage_user_groups": "จัดการกลุ่มผู้ใช้",
"restore_failed": "กู้คืนอุปกรณ์ไม่สำเร็จ"
"restore_failed": "กู้คืนอุปกรณ์ไม่สำเร็จ",
"no_signal_tooltip": "ไคลเอนต์ติดต่อ API เมื่อเร็ว ๆ นี้ แต่ยังไม่ได้ลงทะเบียนบนเซิร์ฟเวอร์สัญญาณ (UDP :21116) โปรดรีสตาร์ทไคลเอนต์หลังย้ายเซิร์ฟเวอร์"
},
"keys": {
"title": "คีย์เซิร์ฟเวอร์",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "กำลังตรวจสอบ...",
"label": "Status"
"label": "Status",
"no_signal": "ไม่มีสัญญาณ"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Cihaz grubu silindi",
"folder_group_readonly": "Klasör destekli gruplar klasörlerden yönetilir.",
"manage_user_groups": "Kullanıcı gruplarını yönet",
"restore_failed": "Cihaz geri yükleme başarısız"
"restore_failed": "Cihaz geri yükleme başarısız",
"no_signal_tooltip": "İstemci yakın zamanda API ile iletişim kurdu ancak sinyal sunucusuna (UDP :21116) kayıtlı değil. Sunucu geçişinden sonra istemciyi yeniden başlatın."
},
"keys": {
"title": "Sunucu Anahtarları",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Kontrol ediliyor...",
"label": "Status"
"label": "Status",
"no_signal": "Sinyal yok"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Групу пристроїв видалено",
"folder_group_readonly": "Групи з папками керуються з папок.",
"manage_user_groups": "Керуйте групами користувачів",
"restore_failed": "Пристрій konnte nicht wiederhergestellt werden"
"restore_failed": "Пристрій konnte nicht wiederhergestellt werden",
"no_signal_tooltip": "Клієнт нещодавно звернувся до API, але не зареєстрований на сигнальному сервері (UDP :21116). Перезапустіть клієнт після міграції сервера."
},
"keys": {
"title": "Ключі сервера",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Перевірка...",
"label": "Status"
"label": "Status",
"no_signal": "Немає сигналу"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -309,7 +309,8 @@
"group_deleted": "Đã xóa nhóm thiết bị",
"folder_group_readonly": "Các nhóm được hỗ trợ bởi thư mục được quản lý từ các thư mục.",
"manage_user_groups": "Quản lý nhóm người dùng",
"restore_failed": "Không thể khôi phục thiết bị"
"restore_failed": "Không thể khôi phục thiết bị",
"no_signal_tooltip": "Máy khách vừa liên hệ API nhưng chưa đăng ký trên máy chủ tín hiệu (UDP :21116). Khởi động lại máy khách sau khi di chuyển máy chủ."
},
"keys": {
"title": "Khóa máy chủ",
@@ -731,7 +732,8 @@
"stopped": "Stopped",
"unknown": "Unknown",
"checking": "Đang kiểm tra...",
"label": "Status"
"label": "Status",
"no_signal": "Không có tín hiệu"
},
"actions": {
"refresh": "Refresh",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "裝置群組已刪除",
"folder_group_readonly": "資料夾關聯的群組由資料夾管理。",
"manage_user_groups": "管理使用者群組",
"restore_failed": "還原裝置失敗"
"restore_failed": "還原裝置失敗",
"no_signal_tooltip": "用戶端最近聯絡了 API,但未在訊號伺服器(UDP :21116)上註冊。伺服器遷移後請重新啟動用戶端。"
},
"keys": {
"title": "伺服器金鑰",
@@ -735,7 +736,8 @@
"stopped": "已停止",
"unknown": "未知",
"checking": "檢查中...",
"label": "Status"
"label": "Status",
"no_signal": "無信號"
},
"actions": {
"refresh": "重新整理",
+4 -2
View File
@@ -303,7 +303,8 @@
"group_deleted": "设备分组已删除",
"folder_group_readonly": "文件夹关联的分组由文件夹管理。",
"manage_user_groups": "管理用户组",
"restore_failed": "恢复设备失败"
"restore_failed": "恢复设备失败",
"no_signal_tooltip": "客户端最近联系了 API,但未在信号服务器(UDP :21116)上注册。服务器迁移后请重启客户端。"
},
"keys": {
"title": "服务器密钥",
@@ -725,7 +726,8 @@
"stopped": "已停止",
"unknown": "未知",
"checking": "检查中...",
"label": "Status"
"label": "Status",
"no_signal": "无信号"
},
"actions": {
"refresh": "刷新",
+9
View File
@@ -177,6 +177,15 @@
background: var(--text-tertiary, #6e7681);
}
.device-panel-status-badge.no_signal {
background: var(--accent-yellow-muted, rgba(210, 153, 34, 0.15));
color: var(--accent-yellow, #d29922);
}
.device-panel-status-badge.no_signal .status-dot {
background: var(--accent-yellow, #d29922);
}
.device-panel-status-badge.banned {
background: var(--accent-red-muted, rgba(248, 81, 73, 0.15));
color: var(--accent-red, #f85149);
+13
View File
@@ -638,6 +638,10 @@ td.column-hidden {
background: var(--text-tertiary, #6e7681);
}
.device-status-dot.no_signal {
background: var(--accent-yellow, #d29922);
}
.device-status-dot.banned {
background: var(--accent-red, #f85149);
}
@@ -717,6 +721,15 @@ td.column-hidden {
color: var(--text-tertiary);
}
.status-badge.no_signal {
background: var(--accent-yellow-muted, rgba(210, 153, 34, 0.15));
color: var(--accent-yellow, #d29922);
}
.status-badge.no_signal .status-dot {
background: var(--accent-yellow, #d29922);
}
.status-badge.banned {
background: var(--accent-red-muted);
color: var(--accent-red);
+34 -13
View File
@@ -143,15 +143,35 @@ const DeviceDetail = (function () {
// ── Header ──
function _resolveDeviceStatus(d) {
if (d.banned) {
return { className: 'banned', label: _('status.banned'), title: '' };
}
if (d.online) {
const tier = (d.status_tier || 'online').toLowerCase();
if (tier === 'degraded' || tier === 'critical') {
return { className: tier, label: _('status.' + tier), title: '' };
}
return { className: 'online', label: _('status.online'), title: '' };
}
if (d.no_signal) {
return {
className: 'no_signal',
label: _('status.no_signal'),
title: _('devices.no_signal_tooltip')
};
}
return { className: 'offline', label: _('status.offline'), title: '' };
}
function _headerHTML() {
const d = device;
const platformLabel = d.platform || d.os || '-';
const platformIcon = Utils.getPlatformIcon(d.platform || d.os);
const statusRaw = (d.status_tier || (d.online ? 'online' : 'offline')).toLowerCase();
const statusClass = d.banned ? 'banned' : statusRaw;
const statusLabel = d.banned
? _('status.banned')
: (_('status.' + statusRaw));
const statusInfo = _resolveDeviceStatus(d);
const statusClass = statusInfo.className;
const statusLabel = statusInfo.label;
const statusTitle = statusInfo.title;
return `
<div class="device-panel-header">
@@ -174,7 +194,7 @@ const DeviceDetail = (function () {
</button>
</div>
<div class="device-panel-status-bar">
<span class="device-panel-status-badge ${statusClass}">
<span class="device-panel-status-badge ${statusClass}"${statusTitle ? ` title="${Utils.escapeHtml(statusTitle)}"` : ''}>
<span class="status-dot"></span>${statusLabel}
</span>
<span class="device-panel-platform-badge">
@@ -1301,15 +1321,16 @@ const DeviceDetail = (function () {
// Only re-render header status (minimal update, avoid losing form state)
const statusBar = overlayEl.querySelector('.device-panel-status-bar');
if (statusBar) {
const statusRaw2 = (device.status_tier || (device.online ? 'online' : 'offline')).toLowerCase();
const statusClass = device.banned ? 'banned' : statusRaw2;
const statusLabel = device.banned
? _('status.banned')
: (_('status.' + statusRaw2));
const statusInfo = _resolveDeviceStatus(device);
const badge = statusBar.querySelector('.device-panel-status-badge');
if (badge) {
badge.className = 'device-panel-status-badge ' + statusClass;
badge.innerHTML = '<span class="status-dot"></span>' + statusLabel;
badge.className = 'device-panel-status-badge ' + statusInfo.className;
if (statusInfo.title) {
badge.title = statusInfo.title;
} else {
badge.removeAttribute('title');
}
badge.innerHTML = '<span class="status-dot"></span>' + statusInfo.label;
}
}
}
+23 -7
View File
@@ -12,6 +12,23 @@
return '#808080';
}
function deviceStatusInfo(d) {
if (d.banned) {
return { className: 'banned', label: _('status.banned'), title: '' };
}
if (d.online) {
return { className: 'online', label: _('status.online'), title: '' };
}
if (d.no_signal) {
return {
className: 'no_signal',
label: _('status.no_signal'),
title: _('devices.no_signal_tooltip')
};
}
return { className: 'offline', label: _('status.offline'), title: '' };
}
// Map device_type to Material Icons
function getDeviceTypeIcon(type) {
switch ((type || '').toLowerCase()) {
@@ -147,7 +164,7 @@
if (!row) return;
const normalizedStatus = String(status || '').toLowerCase();
const statusClassName = ['online', 'offline', 'degraded', 'critical'].includes(normalizedStatus)
const statusClassName = ['online', 'offline', 'no_signal', 'degraded', 'critical'].includes(normalizedStatus)
? normalizedStatus
: 'offline';
const statusText = _('status.' + statusClassName);
@@ -173,6 +190,7 @@
dev.live_status = statusClassName;
dev.live_online = statusClassName === 'online';
dev.online = statusClassName === 'online';
dev.no_signal = statusClassName === 'no_signal';
}
}
@@ -472,12 +490,10 @@
return;
}
const statusClass = (d) => d.banned ? 'banned' : d.online ? 'online' : 'offline';
const statusLabel = (d) => d.banned ? _('status.banned') : d.online ? _('status.online') : _('status.offline');
tableBody.innerHTML = pageDevices.map(device => {
const eid = Utils.escapeHtml(device.id);
const sc = statusClass(device);
const statusInfo = deviceStatusInfo(device);
const sc = statusInfo.className;
return `
<tr data-id="${eid}" class="${device.banned ? 'banned-row' : ''}" draggable="true">
<td data-column="id">
@@ -506,7 +522,7 @@
<span class="last-seen-text" title="${Utils.formatDate(device.last_online)}">${Utils.formatRelativeTime(device.last_online)}</span>
</td>
<td data-column="status">
<span class="status-badge ${sc}"><span class="status-dot"></span>${statusLabel(device)}</span>
<span class="status-badge ${sc}"${statusInfo.title ? ` title="${Utils.escapeHtml(statusInfo.title)}"` : ''}><span class="status-dot"></span>${statusInfo.label}</span>
</td>
<td data-column="tags">${renderTagsCell(device)}</td>
<td data-column="actions">
@@ -905,7 +921,7 @@
</div>
<div class="detail-row">
<span class="detail-label">${_('status.label')}:</span>
<span class="detail-value">${device.banned ? _('status.banned') : device.online ? _('status.online') : _('status.offline')}</span>
<span class="detail-value">${deviceStatusInfo(device).label}</span>
</div>
<div class="detail-row">
<span class="detail-label">${_('devices.last_online')}:</span>
+16 -3
View File
@@ -462,9 +462,21 @@ async function syncOnlineStatus(/* db */) {
* online (bool), banned (bool), created_at, last_online, ban_reason,
* folder_id, tags[], status_tier, uuid, disabled, os, version
*/
const NO_SIGNAL_THRESHOLD_MS = 5 * 60 * 1000;
function isRecentLastOnline(lastOnline) {
if (!lastOnline) return false;
const t = new Date(lastOnline).getTime();
return Number.isFinite(t) && (Date.now() - t) < NO_SIGNAL_THRESHOLD_MS;
}
function normalisePeer(peer) {
if (!peer) return peer;
const liveOnline = !!(peer.live_online);
const banned = !!(peer.banned);
const lastOnline = peer.last_online || '';
// Parse tags: Go server sends comma-separated string or JSON array
let tags = [];
if (Array.isArray(peer.tags)) {
@@ -488,10 +500,11 @@ function normalisePeer(peer) {
version: peer.version || '',
ip: peer.ip || '',
note: peer.note || '',
online: !!(peer.live_online),
banned: !!(peer.banned),
online: liveOnline,
banned,
no_signal: !liveOnline && !banned && isRecentLastOnline(lastOnline),
created_at: peer.created_at || '',
last_online: peer.last_online || '',
last_online: lastOnline,
ban_reason: peer.ban_reason || '',
banned_at: peer.banned_at || null,
folder_id: peer.folder_id || null,