feat(devices): manage rename and delete device tags

Add scoped tag rename/delete APIs and Devices UI (Manage tags modal, context menu, edit/detail hints) so operators can retire or rename tags without editing every peer.

Refs #470

Thanks: INSOLVE (Honorary); Marco Jakobs (@jacotec); MyNameisStitch (@MyNameisStitch); Redspin (@playerumpknow); muopc (@muopc)
This commit is contained in:
UNITRONIX
2026-10-23 11:20:03 +02:00
parent f513edf617
commit ece564ea77
36 changed files with 1388 additions and 56 deletions
+3
View File
@@ -1,5 +1,8 @@
## [Unreleased]
### Added
- **Device tags management (#470):** Operators can rename or delete tags across all visible devices from Devices → Manage tags, unassign tags per device in Edit / Details / context menu, and matching dynamic group filters update on rename. Ships via panel update.
### Changed
- _(none yet)_
+22 -2
View File
@@ -442,7 +442,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "إدارة الوسوم",
"manage_device_tags": "إدارة الوسوم",
"rename_tag": "إعادة تسمية الوسم",
"delete_tag": "حذف الوسم",
"rename_tag_prompt": "اسم الوسم الجديد",
"rename_tag_hint": "يعيد تسمية هذا الوسم على كل الأجهزة المرئية ويحدّث المجموعات الديناميكية المطابقة.",
"delete_tag_confirm": "إزالة الوسم \"{tag}\" من {count} جهاز/أجهزة؟ ستصبح المجموعات الديناميكية التي تستخدمه فارغة حتى التحديث.",
"tag_renamed": "تمت إعادة تسمية الوسم",
"tag_deleted": "تم حذف الوسم",
"tag_name_required": "اسم الوسم مطلوب",
"tag_rename_identical": "يجب أن يكون الاسم الجديد مختلفاً",
"tags_backend_unsupported": "وسوم الأجهزة غير مدعومة مع الخلفية الحالية",
"tags_manage_hint": "أعد تسمية الوسوم أو احذفها على كل الأجهزة المرئية. الإزالة من جهاز واحد تتم في التعديل أو التفاصيل.",
"tags_device_hint": "أزل وسماً هنا لإلغاء تعيينه من هذا الجهاز فقط. استخدم إدارة الوسوم لإعادة التسمية أو الحذف في كل مكان.",
"unassign_tag": "إزالة من هذا الجهاز",
"tag_device_one": "جهاز",
"tag_device_many": "أجهزة",
"tag_group_one": "مجموعة ديناميكية",
"tag_group_many": "مجموعات ديناميكية"
},
"keys": {
"title": "مفاتيح الخادم",
@@ -1776,7 +1795,8 @@
"sessions_col_duration": "المدة",
"sessions_active": "نشطة",
"sessions_transport_punch": "مباشر / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "إزالة وسم هنا تلغي تعيينه من هذا الجهاز فقط. استخدم إدارة الوسوم في صفحة الأجهزة لإعادة التسمية أو الحذف في كل مكان."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Spravovat štítky",
"manage_device_tags": "Spravovat štítky",
"rename_tag": "Přejmenovat štítek",
"delete_tag": "Smazat štítek",
"rename_tag_prompt": "Nový název štítku",
"rename_tag_hint": "Přejmenuje tento štítek na všech viditelných zařízeních a aktualizuje odpovídající dynamické skupiny.",
"delete_tag_confirm": "Odebrat štítek \"{tag}\" z {count} zařízení? Dynamické skupiny používající tento štítek budou prázdné do aktualizace.",
"tag_renamed": "Štítek přejmenován",
"tag_deleted": "Štítek smazán",
"tag_name_required": "Název štítku je povinný",
"tag_rename_identical": "Nový název musí být jiný",
"tags_backend_unsupported": "Štítky zařízení nejsou s aktuálním backendem podporovány",
"tags_manage_hint": "Přejmenujte nebo smažte štítky na všech viditelných zařízeních. Odebrání z jednoho zařízení je v Upravit nebo Podrobnostech.",
"tags_device_hint": "Odeberte štítek zde, abyste jej odpojili jen od tohoto zařízení. Pro globální změnu použijte Spravovat štítky.",
"unassign_tag": "Odebrat z tohoto zařízení",
"tag_device_one": "zařízení",
"tag_device_many": "zařízení",
"tag_group_one": "dynamická skupina",
"tag_group_many": "dynamických skupin"
},
"keys": {
"title": "Klíče serveru",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "Trvání",
"sessions_active": "Aktivní",
"sessions_transport_punch": "Přímé / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Odebrání štítku zde jej odpojí jen od tohoto zařízení. Pro globální změnu použijte Spravovat štítky na stránce Zařízení."
},
"inventory": {
"title": "Inventář",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Administrer tags",
"manage_device_tags": "Administrer tags",
"rename_tag": "Omdøb tag",
"delete_tag": "Slet tag",
"rename_tag_prompt": "Nyt tagnavn",
"rename_tag_hint": "Omdøber dette tag på alle synlige enheder og opdaterer matchende dynamiske grupper.",
"delete_tag_confirm": "Fjern tagget \"{tag}\" fra {count} enhed(er)? Dynamiske grupper der bruger det bliver tomme indtil opdatering.",
"tag_renamed": "Tag omdøbt",
"tag_deleted": "Tag slettet",
"tag_name_required": "Tagnavn er påkrævet",
"tag_rename_identical": "Det nye tagnavn skal være anderledes",
"tags_backend_unsupported": "Enhedstags understøttes ikke med den aktuelle backend",
"tags_manage_hint": "Omdøb eller slet tags på alle synlige enheder. Fjernelse fra én enhed sker under Rediger eller Detaljer.",
"tags_device_hint": "Fjern et tag her for kun at fjerne det fra denne enhed. Brug Administrer tags til at omdøbe eller slette overalt.",
"unassign_tag": "Fjern fra denne enhed",
"tag_device_one": "enhed",
"tag_device_many": "enheder",
"tag_group_one": "dynamisk gruppe",
"tag_group_many": "dynamiske grupper"
},
"keys": {
"title": "Servernøgler",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Varighed",
"sessions_active": "Aktiv",
"sessions_transport_punch": "Direkte / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Fjernelse af et tag her fjerner det kun fra denne enhed. Brug Administrer tags på Enheder til at omdøbe eller slette overalt."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Tags verwalten",
"manage_device_tags": "Tags verwalten",
"rename_tag": "Tag umbenennen",
"delete_tag": "Tag löschen",
"rename_tag_prompt": "Neuer Tagname",
"rename_tag_hint": "Benennt diesen Tag auf allen sichtbaren Geräten um und aktualisiert passende dynamische Gruppen.",
"delete_tag_confirm": "Tag \"{tag}\" von {count} Gerät(en) entfernen? Dynamische Gruppen mit diesem Tag bleiben bis zur Aktualisierung leer.",
"tag_renamed": "Tag umbenannt",
"tag_deleted": "Tag gelöscht",
"tag_name_required": "Tagname ist erforderlich",
"tag_rename_identical": "Der neue Tagname muss anders sein",
"tags_backend_unsupported": "Geräte-Tags werden mit dem aktuellen Backend nicht unterstützt",
"tags_manage_hint": "Tags auf allen sichtbaren Geräten umbenennen oder löschen. Entfernen von einem Gerät erfolgt in Bearbeiten oder Gerätedetails.",
"tags_device_hint": "Entfernen Sie einen Tag hier, um ihn nur von diesem Gerät zu lösen. Nutzen Sie Tags verwalten zum globalen Umbenennen oder Löschen.",
"unassign_tag": "Von diesem Gerät entfernen",
"tag_device_one": "Gerät",
"tag_device_many": "Geräte",
"tag_group_one": "dynamische Gruppe",
"tag_group_many": "dynamische Gruppen"
},
"keys": {
"title": "Dashboard",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "Dauer",
"sessions_active": "Aktiv",
"sessions_transport_punch": "Direkt / Punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Das Entfernen eines Tags hier hebt die Zuweisung nur für dieses Gerät auf. Nutzen Sie Tags verwalten auf der Geräte-Seite zum globalen Umbenennen oder Löschen."
},
"inventory": {
"title": "Dashboard",
+20
View File
@@ -320,6 +320,25 @@
"access_policy": "Access Policy",
"filter_by_tags": "Filter by tags",
"no_tags": "No tags",
"manage_tags": "Manage tags",
"manage_device_tags": "Manage tags",
"rename_tag": "Rename tag",
"delete_tag": "Delete tag",
"rename_tag_prompt": "New tag name",
"rename_tag_hint": "Renames this tag on all devices you can see and updates matching dynamic groups.",
"delete_tag_confirm": "Remove tag \"{tag}\" from {count} device(s)? Dynamic groups that use this tag will become empty until updated.",
"tag_renamed": "Tag renamed",
"tag_deleted": "Tag deleted",
"tag_name_required": "Tag name is required",
"tag_rename_identical": "New tag name must be different",
"tags_backend_unsupported": "Device tags are not supported with the current backend",
"tags_manage_hint": "Rename or delete tags across all devices you can see. Removing a tag from one device is done in Edit or Device details.",
"tags_device_hint": "Remove a tag here to unassign it from this device only. Use Manage Tags to rename or delete a tag everywhere.",
"unassign_tag": "Remove from this device",
"tag_device_one": "device",
"tag_device_many": "devices",
"tag_group_one": "dynamic group",
"tag_group_many": "dynamic groups",
"all_groups": "All groups",
"create_group": "Create group",
"manage_groups": "Manage Groups",
@@ -1736,6 +1755,7 @@
"tag_placeholder": "Enter a tag name...",
"add_tag": "Add",
"tag_exists": "This tag already exists",
"tags_unassign_hint": "Removing a tag here unassigns it from this device only. Use Manage Tags on the Devices page to rename or delete a tag everywhere.",
"notes": "Notes",
"notes_placeholder": "Add notes about this device...",
"danger_zone": "Danger Zone",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Administrar etiquetas",
"manage_device_tags": "Administrar etiquetas",
"rename_tag": "Renombrar etiqueta",
"delete_tag": "Eliminar etiqueta",
"rename_tag_prompt": "Nuevo nombre de etiqueta",
"rename_tag_hint": "Renombra esta etiqueta en todos los dispositivos visibles y actualiza los grupos dinámicos coincidentes.",
"delete_tag_confirm": "¿Quitar la etiqueta \"{tag}\" de {count} dispositivo(s)? Los grupos dinámicos que la usan quedarán vacíos hasta actualizarlos.",
"tag_renamed": "Etiqueta renombrada",
"tag_deleted": "Etiqueta eliminada",
"tag_name_required": "El nombre de etiqueta es obligatorio",
"tag_rename_identical": "El nuevo nombre debe ser distinto",
"tags_backend_unsupported": "Las etiquetas de dispositivos no son compatibles con el backend actual",
"tags_manage_hint": "Renombre o elimine etiquetas en todos los dispositivos visibles. Quitar una etiqueta de un dispositivo se hace en Editar o Detalles.",
"tags_device_hint": "Quite una etiqueta aquí para desasignarla solo de este dispositivo. Use Administrar etiquetas para renombrar o eliminar en todas partes.",
"unassign_tag": "Quitar de este dispositivo",
"tag_device_one": "dispositivo",
"tag_device_many": "dispositivos",
"tag_group_one": "grupo dinámico",
"tag_group_many": "grupos dinámicos"
},
"keys": {
"title": "Panel",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "Duración",
"sessions_active": "Activa",
"sessions_transport_punch": "Directo / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Quitar una etiqueta aquí la desasigna solo de este dispositivo. Use Administrar etiquetas en Dispositivos para renombrar o eliminar en todas partes."
},
"inventory": {
"title": "Panel",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Hallitse tageja",
"manage_device_tags": "Hallitse tageja",
"rename_tag": "Nimeä tagi uudelleen",
"delete_tag": "Poista tagi",
"rename_tag_prompt": "Uusi tagin nimi",
"rename_tag_hint": "Nimeää tämän tagin uudelleen kaikilla näkyvillä laitteilla ja päivittää vastaavat dynaamiset ryhmät.",
"delete_tag_confirm": "Poistetaanko tagi \"{tag}\" {count} laitteelta? Dynaamiset ryhmät, jotka käyttävät sitä, ovat tyhjiä päivitykseen asti.",
"tag_renamed": "Tagi nimetty uudelleen",
"tag_deleted": "Tagi poistettu",
"tag_name_required": "Tagin nimi on pakollinen",
"tag_rename_identical": "Uuden nimen on oltava eri",
"tags_backend_unsupported": "Laitetageja ei tueta nykyisellä backendillä",
"tags_manage_hint": "Nimeä uudelleen tai poista tageja kaikilta näkyviltä laitteilta. Poisto yhdeltä laitteelta tehdään Muokkaa- tai Tiedot-näkymässä.",
"tags_device_hint": "Poista tagi täällä irrottaaksesi sen vain tältä laitteelta. Käytä Hallitse tageja globaaliin uudelleennimeämiseen tai poistoon.",
"unassign_tag": "Poista tältä laitteelta",
"tag_device_one": "laite",
"tag_device_many": "laitetta",
"tag_group_one": "dynaaminen ryhmä",
"tag_group_many": "dynaamista ryhmää"
},
"keys": {
"title": "Palvelimen avaimet",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Kesto",
"sessions_active": "Aktiivinen",
"sessions_transport_punch": "Suora / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Tagin poisto täällä irrottaa sen vain tältä laitteelta. Käytä Hallitse tageja Laitteet-sivulla globaaliin muutokseen."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -439,7 +439,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Gérer les balises",
"manage_device_tags": "Gérer les balises",
"rename_tag": "Renommer la balise",
"delete_tag": "Supprimer la balise",
"rename_tag_prompt": "Nouveau nom de balise",
"rename_tag_hint": "Renomme cette balise sur tous les appareils visibles et met à jour les groupes dynamiques correspondants.",
"delete_tag_confirm": "Retirer la balise \"{tag}\" de {count} appareil(s) ? Les groupes dynamiques qui l’utilisent seront vides jusqu’à mise à jour.",
"tag_renamed": "Balise renommée",
"tag_deleted": "Balise supprimée",
"tag_name_required": "Le nom de balise est requis",
"tag_rename_identical": "Le nouveau nom doit être différent",
"tags_backend_unsupported": "Les balises d’appareils ne sont pas prises en charge avec le backend actuel",
"tags_manage_hint": "Renommez ou supprimez des balises sur tous les appareils visibles. Retirer une balise d’un appareil se fait dans Modifier ou Détails.",
"tags_device_hint": "Retirez une balise ici pour la désassigner de cet appareil uniquement. Utilisez Gérer les balises pour renommer ou supprimer partout.",
"unassign_tag": "Retirer de cet appareil",
"tag_device_one": "appareil",
"tag_device_many": "appareils",
"tag_group_one": "groupe dynamique",
"tag_group_many": "groupes dynamiques"
},
"keys": {
"title": "Tableau de bord",
@@ -1786,7 +1805,8 @@
"sessions_col_duration": "Durée",
"sessions_active": "Active",
"sessions_transport_punch": "Direct / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Retirer une balise ici la désassigne uniquement de cet appareil. Utilisez Gérer les balises sur la page Appareils pour renommer ou supprimer partout."
},
"inventory": {
"title": "Tableau de bord",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "टैग प्रबंधित करें",
"manage_device_tags": "टैग प्रबंधित करें",
"rename_tag": "टैग का नाम बदलें",
"delete_tag": "टैग हटाएँ",
"rename_tag_prompt": "नया टैग नाम",
"rename_tag_hint": "इस टैग का नाम सभी दिखाई देने वाले उपकरणों पर बदलता है और मेल खाते गतिशील समूह अपडेट करता है।",
"delete_tag_confirm": "टैग \"{tag}\" को {count} उपकरण(ों) से हटाएँ? इसे उपयोग करने वाले गतिशील समूह अपडेट तक खाली रहेंगे।",
"tag_renamed": "टैग का नाम बदला गया",
"tag_deleted": "टैग हटाया गया",
"tag_name_required": "टैग नाम आवश्यक है",
"tag_rename_identical": "नया नाम अलग होना चाहिए",
"tags_backend_unsupported": "वर्तमान बैकएंड के साथ उपकरण टैग समर्थित नहीं हैं",
"tags_manage_hint": "सभी दिखाई देने वाले उपकरणों पर टैग का नाम बदलें या हटाएँ। एक उपकरण से हटाना संपादन या विवरण में होता है।",
"tags_device_hint": "यहाँ टैग हटाकर केवल इस उपकरण से अनअसाइन करें। सर्वत्र बदलने के लिए टैग प्रबंधित करें उपयोग करें।",
"unassign_tag": "इस उपकरण से हटाएँ",
"tag_device_one": "उपकरण",
"tag_device_many": "उपकरण",
"tag_group_one": "गतिशील समूह",
"tag_group_many": "गतिशील समूह"
},
"keys": {
"title": "सर्वर कुंजी",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "अवधि",
"sessions_active": "सक्रिय",
"sessions_transport_punch": "प्रत्यक्ष / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "यहाँ टैग हटाने से केवल इस उपकरण से अनअसाइन होता है। सर्वत्र बदलने के लिए उपकरण पृष्ठ पर टैग प्रबंधित करें उपयोग करें।"
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Címkék kezelése",
"manage_device_tags": "Címkék kezelése",
"rename_tag": "Címke átnevezése",
"delete_tag": "Címke törlése",
"rename_tag_prompt": "Új címkenév",
"rename_tag_hint": "Átnevezi ezt a címkét minden látható eszközön, és frissíti a megfelelő dinamikus csoportokat.",
"delete_tag_confirm": "Eltávolítja a(z) \"{tag}\" címkét {count} eszközről? A címkét használó dinamikus csoportok üresek maradnak a frissítésig.",
"tag_renamed": "Címke átnevezve",
"tag_deleted": "Címke törölve",
"tag_name_required": "A címkenév kötelező",
"tag_rename_identical": "Az új névnek különböznie kell",
"tags_backend_unsupported": "Az eszközcímkék nem támogatottak a jelenlegi backenddel",
"tags_manage_hint": "Nevezzen át vagy töröljön címkéket minden látható eszközön. Egy eszközről az Edit / Részletek alatt vehető le.",
"tags_device_hint": "Itt távolítsa el a címkét, hogy csak erről az eszközről legyen leválasztva. Globális műveletekhez használja a Címkék kezelése gombot.",
"unassign_tag": "Eltávolítás erről az eszközről",
"tag_device_one": "eszköz",
"tag_device_many": "eszköz",
"tag_group_one": "dinamikus csoport",
"tag_group_many": "dinamikus csoport"
},
"keys": {
"title": "Szerverkulcsok",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Időtartam",
"sessions_active": "Aktív",
"sessions_transport_punch": "Közvetlen / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "A címke eltávolítása itt csak erről az eszközről veszi le. Globális műveletekhez használja a Címkék kezelése gombot az Eszközök oldalon."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Kelola tag",
"manage_device_tags": "Kelola tag",
"rename_tag": "Ganti nama tag",
"delete_tag": "Hapus tag",
"rename_tag_prompt": "Nama tag baru",
"rename_tag_hint": "Mengganti nama tag ini pada semua perangkat yang terlihat dan memperbarui grup dinamis yang cocok.",
"delete_tag_confirm": "Hapus tag \"{tag}\" dari {count} perangkat? Grup dinamis yang memakai tag ini akan kosong hingga diperbarui.",
"tag_renamed": "Tag diganti nama",
"tag_deleted": "Tag dihapus",
"tag_name_required": "Nama tag wajib diisi",
"tag_rename_identical": "Nama baru harus berbeda",
"tags_backend_unsupported": "Tag perangkat tidak didukung dengan backend saat ini",
"tags_manage_hint": "Ganti nama atau hapus tag di semua perangkat yang terlihat. Lepas dari satu perangkat lewat Edit atau Detail.",
"tags_device_hint": "Hapus tag di sini untuk melepasnya hanya dari perangkat ini. Gunakan Kelola tag untuk perubahan global.",
"unassign_tag": "Hapus dari perangkat ini",
"tag_device_one": "perangkat",
"tag_device_many": "perangkat",
"tag_group_one": "grup dinamis",
"tag_group_many": "grup dinamis"
},
"keys": {
"title": "Kunci Server",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Durasi",
"sessions_active": "Aktif",
"sessions_transport_punch": "Langsung / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Menghapus tag di sini hanya melepasnya dari perangkat ini. Gunakan Kelola tag di halaman Perangkat untuk perubahan global."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Gestisci tag",
"manage_device_tags": "Gestisci tag",
"rename_tag": "Rinomina tag",
"delete_tag": "Elimina tag",
"rename_tag_prompt": "Nuovo nome tag",
"rename_tag_hint": "Rinomina questo tag su tutti i dispositivi visibili e aggiorna i gruppi dinamici corrispondenti.",
"delete_tag_confirm": "Rimuovere il tag \"{tag}\" da {count} dispositivo/i? I gruppi dinamici che lo usano resteranno vuoti fino all’aggiornamento.",
"tag_renamed": "Tag rinominato",
"tag_deleted": "Tag eliminato",
"tag_name_required": "Il nome del tag è obbligatorio",
"tag_rename_identical": "Il nuovo nome deve essere diverso",
"tags_backend_unsupported": "I tag dei dispositivi non sono supportati con il backend attuale",
"tags_manage_hint": "Rinomina o elimina i tag su tutti i dispositivi visibili. Rimuovere un tag da un dispositivo si fa in Modifica o Dettagli.",
"tags_device_hint": "Rimuovi un tag qui per disassegnarlo solo da questo dispositivo. Usa Gestisci tag per rinominare o eliminare ovunque.",
"unassign_tag": "Rimuovi da questo dispositivo",
"tag_device_one": "dispositivo",
"tag_device_many": "dispositivi",
"tag_group_one": "gruppo dinamico",
"tag_group_many": "gruppi dinamici"
},
"keys": {
"title": "Chiavi del server",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "Durata",
"sessions_active": "Attiva",
"sessions_transport_punch": "Diretto / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Rimuovere un tag qui lo disassegna solo da questo dispositivo. Usa Gestisci tag nella pagina Dispositivi per rinominare o eliminare ovunque."
},
"inventory": {
"title": "Inventario",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "タグを管理",
"manage_device_tags": "タグを管理",
"rename_tag": "タグ名を変更",
"delete_tag": "タグを削除",
"rename_tag_prompt": "新しいタグ名",
"rename_tag_hint": "表示中のすべてのデバイスでこのタグ名を変更し、一致する動的グループを更新します。",
"delete_tag_confirm": "タグ \"{tag}\" を {count} 台のデバイスから削除しますか?このタグを使う動的グループは更新まで空になります。",
"tag_renamed": "タグ名を変更しました",
"tag_deleted": "タグを削除しました",
"tag_name_required": "タグ名は必須です",
"tag_rename_identical": "新しい名前は別である必要があります",
"tags_backend_unsupported": "現在のバックエンドではデバイスタグはサポートされていません",
"tags_manage_hint": "表示中のすべてのデバイスでタグを変更または削除します。1台からの解除は編集または詳細で行います。",
"tags_device_hint": "ここでタグを削除すると、このデバイスからのみ割り当てが解除されます。全体の変更にはタグを管理を使います。",
"unassign_tag": "このデバイスから削除",
"tag_device_one": "デバイス",
"tag_device_many": "デバイス",
"tag_group_one": "動的グループ",
"tag_group_many": "動的グループ"
},
"keys": {
"title": "サーバーキー",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "所要時間",
"sessions_active": "進行中",
"sessions_transport_punch": "直接 / パンチ",
"sessions_transport_relay": "リレー"
"sessions_transport_relay": "リレー",
"tags_unassign_hint": "ここでタグを削除すると、このデバイスからのみ割り当てが解除されます。全体の変更にはデバイスページのタグを管理を使います。"
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "태그 관리",
"manage_device_tags": "태그 관리",
"rename_tag": "태그 이름 바꾸기",
"delete_tag": "태그 삭제",
"rename_tag_prompt": "새 태그 이름",
"rename_tag_hint": "볼 수 있는 모든 장치에서 이 태그 이름을 바꾸고 일치하는 동적 그룹을 업데이트합니다.",
"delete_tag_confirm": "태그 \"{tag}\"을(를) {count}개 장치에서 제거할까요? 이 태그를 쓰는 동적 그룹은 업데이트될 때까지 비어 있습니다.",
"tag_renamed": "태그 이름이 바뀌었습니다",
"tag_deleted": "태그가 삭제되었습니다",
"tag_name_required": "태그 이름이 필요합니다",
"tag_rename_identical": "새 이름은 달라야 합니다",
"tags_backend_unsupported": "현재 백엔드에서는 장치 태그가 지원되지 않습니다",
"tags_manage_hint": "볼 수 있는 모든 장치에서 태그를 바꾸거나 삭제합니다. 한 장치에서 제거는 편집 또는 세부정보에서 합니다.",
"tags_device_hint": "여기서 태그를 제거하면 이 장치에서만 할당이 해제됩니다. 전역 변경은 태그 관리를 사용하세요.",
"unassign_tag": "이 장치에서 제거",
"tag_device_one": "장치",
"tag_device_many": "장치",
"tag_group_one": "동적 그룹",
"tag_group_many": "동적 그룹"
},
"keys": {
"title": "서버 키",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "지속 시간",
"sessions_active": "진행 중",
"sessions_transport_punch": "직접 / 펀치",
"sessions_transport_relay": "릴레이"
"sessions_transport_relay": "릴레이",
"tags_unassign_hint": "여기서 태그를 제거하면 이 장치에서만 할당이 해제됩니다. 전역 변경은 장치 페이지의 태그 관리를 사용하세요."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Administrer tagger",
"manage_device_tags": "Administrer tagger",
"rename_tag": "Gi tag nytt navn",
"delete_tag": "Slett tag",
"rename_tag_prompt": "Nytt tagnavn",
"rename_tag_hint": "Gir denne tagen nytt navn på alle synlige enheter og oppdaterer matchende dynamiske grupper.",
"delete_tag_confirm": "Fjern tagen \"{tag}\" fra {count} enhet(er)? Dynamiske grupper som bruker den blir tomme til de oppdateres.",
"tag_renamed": "Tag omdøpt",
"tag_deleted": "Tag slettet",
"tag_name_required": "Tagnavn er påkrevd",
"tag_rename_identical": "Det nye tagnavnet må være annerledes",
"tags_backend_unsupported": "Enhetstagger støttes ikke med gjeldende backend",
"tags_manage_hint": "Gi nytt navn eller slett tagger på alle synlige enheter. Fjerning fra én enhet gjøres i Rediger eller Detaljer.",
"tags_device_hint": "Fjern en tag her for å frakoble den kun fra denne enheten. Bruk Administrer tagger for global endring.",
"unassign_tag": "Fjern fra denne enheten",
"tag_device_one": "enhet",
"tag_device_many": "enheter",
"tag_group_one": "dynamisk gruppe",
"tag_group_many": "dynamiske grupper"
},
"keys": {
"title": "Servernøkler",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Varighet",
"sessions_active": "Aktiv",
"sessions_transport_punch": "Direkte / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Fjerning av en tag her frakobler den kun fra denne enheten. Bruk Administrer tagger på Enheter for global endring."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Tags beheren",
"manage_device_tags": "Tags beheren",
"rename_tag": "Tag hernoemen",
"delete_tag": "Tag verwijderen",
"rename_tag_prompt": "Nieuwe tagnaam",
"rename_tag_hint": "Hernoemt deze tag op alle zichtbare apparaten en werkt bijpassende dynamische groepen bij.",
"delete_tag_confirm": "Tag \"{tag}\" van {count} apparaat/apparaten verwijderen? Dynamische groepen die deze tag gebruiken blijven leeg tot bijgewerkt.",
"tag_renamed": "Tag hernoemd",
"tag_deleted": "Tag verwijderd",
"tag_name_required": "Tagnaam is verplicht",
"tag_rename_identical": "Nieuwe tagnaam moet anders zijn",
"tags_backend_unsupported": "Apparaattags worden niet ondersteund met de huidige backend",
"tags_manage_hint": "Hernoem of verwijder tags op alle zichtbare apparaten. Verwijderen van één apparaat gebeurt in Bewerken of Details.",
"tags_device_hint": "Verwijder hier een tag om deze alleen van dit apparaat te ontkoppelen. Gebruik Tags beheren om overal te hernoemen of te verwijderen.",
"unassign_tag": "Van dit apparaat verwijderen",
"tag_device_one": "apparaat",
"tag_device_many": "apparaten",
"tag_group_one": "dynamische groep",
"tag_group_many": "dynamische groepen"
},
"keys": {
"title": "Serversleutels",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "Duur",
"sessions_active": "Actief",
"sessions_transport_punch": "Direct / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Een tag hier verwijderen ontkoppelt deze alleen van dit apparaat. Gebruik Tags beheren op Apparaten om overal te hernoemen of te verwijderen."
},
"inventory": {
"title": "Inventaris",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Przypisz istniejący pakiet",
"group_commercialization_create_new": "Utwórz nowy pakiet i przypisz",
"group_commercialization_saved": "Pakiet przypisany do grupy",
"group_one_only": "Urządzenie może należeć tylko do jednej grupy"
"group_one_only": "Urządzenie może należeć tylko do jednej grupy",
"manage_tags": "Zarządzaj tagami",
"manage_device_tags": "Zarządzaj tagami",
"rename_tag": "Zmień nazwę tagu",
"delete_tag": "Usuń tag",
"rename_tag_prompt": "Nowa nazwa tagu",
"rename_tag_hint": "Zmienia nazwę tego tagu na wszystkich widocznych urządzeniach i aktualizuje pasujące grupy dynamiczne.",
"delete_tag_confirm": "Usunąć tag \"{tag}\" z {count} urządzeń? Grupy dynamiczne używające tego tagu będą puste do czasu aktualizacji.",
"tag_renamed": "Zmieniono nazwę tagu",
"tag_deleted": "Usunięto tag",
"tag_name_required": "Nazwa tagu jest wymagana",
"tag_rename_identical": "Nowa nazwa tagu musi być inna",
"tags_backend_unsupported": "Tagi urządzeń nie są obsługiwane w bieżącym backendzie",
"tags_manage_hint": "Zmieniaj nazwy lub usuwaj tagi na wszystkich widocznych urządzeniach. Usunięcie tagu z jednego urządzenia odbywa się w Edycji lub szczegółach urządzenia.",
"tags_device_hint": "Usuń tag tutaj, aby odpiąć go tylko od tego urządzenia. Użyj Zarządzaj tagami, aby zmienić nazwę lub usunąć tag wszędzie.",
"unassign_tag": "Usuń z tego urządzenia",
"tag_device_one": "urządzenie",
"tag_device_many": "urządzeń",
"tag_group_one": "grupa dynamiczna",
"tag_group_many": "grup dynamicznych"
},
"keys": {
"title": "Klucze serwera",
@@ -1782,7 +1801,8 @@
"sessions_col_duration": "Czas",
"sessions_active": "Aktywna",
"sessions_transport_punch": "Bezpośrednie / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Usunięcie tagu tutaj odłącza go tylko od tego urządzenia. Użyj Zarządzaj tagami na stronie Urządzenia, aby zmienić nazwę lub usunąć tag wszędzie."
},
"inventory": {
"title": "Inwentaryzacja",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Gerir etiquetas",
"manage_device_tags": "Gerir etiquetas",
"rename_tag": "Mudar nome da etiqueta",
"delete_tag": "Eliminar etiqueta",
"rename_tag_prompt": "Novo nome da etiqueta",
"rename_tag_hint": "Altera o nome desta etiqueta em todos os dispositivos visíveis e atualiza grupos dinâmicos correspondentes.",
"delete_tag_confirm": "Remover a etiqueta \"{tag}\" de {count} dispositivo(s)? Grupos dinâmicos que a usam ficarão vazios até serem atualizados.",
"tag_renamed": "Etiqueta renomeada",
"tag_deleted": "Etiqueta eliminada",
"tag_name_required": "O nome da etiqueta é obrigatório",
"tag_rename_identical": "O novo nome deve ser diferente",
"tags_backend_unsupported": "Etiquetas de dispositivos não são suportadas com o backend atual",
"tags_manage_hint": "Renomeie ou elimine etiquetas em todos os dispositivos visíveis. Remover de um dispositivo faz-se em Editar ou Detalhes.",
"tags_device_hint": "Remova uma etiqueta aqui para a desassociar apenas deste dispositivo. Use Gerir etiquetas para renomear ou eliminar em todo o lado.",
"unassign_tag": "Remover deste dispositivo",
"tag_device_one": "dispositivo",
"tag_device_many": "dispositivos",
"tag_group_one": "grupo dinâmico",
"tag_group_many": "grupos dinâmicos"
},
"keys": {
"title": "Chaves do servidor",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "Duração",
"sessions_active": "Ativa",
"sessions_transport_punch": "Direto / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Remover uma etiqueta aqui desassocia-a apenas deste dispositivo. Use Gerir etiquetas na página Dispositivos para renomear ou eliminar em todo o lado."
},
"inventory": {
"title": "Inventário",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Gestionează etichetele",
"manage_device_tags": "Gestionează etichetele",
"rename_tag": "Redenumește eticheta",
"delete_tag": "Șterge eticheta",
"rename_tag_prompt": "Nume nou pentru etichetă",
"rename_tag_hint": "Redenumește această etichetă pe toate dispozitivele vizibile și actualizează grupurile dinamice potrivite.",
"delete_tag_confirm": "Eliminați eticheta \"{tag}\" de pe {count} dispozitiv(e)? Grupurile dinamice care o folosesc vor fi goale până la actualizare.",
"tag_renamed": "Etichetă redenumită",
"tag_deleted": "Etichetă ștearsă",
"tag_name_required": "Numele etichetei este obligatoriu",
"tag_rename_identical": "Noul nume trebuie să fie diferit",
"tags_backend_unsupported": "Etichetele dispozitivelor nu sunt acceptate cu backend-ul curent",
"tags_manage_hint": "Redenumiți sau ștergeți etichete pe toate dispozitivele vizibile. Eliminarea de pe un dispozitiv se face în Editare sau Detalii.",
"tags_device_hint": "Eliminați o etichetă aici pentru a o dezasocia doar de acest dispozitiv. Folosiți Gestionează etichetele pentru redenumire/ștergere globală.",
"unassign_tag": "Elimină de pe acest dispozitiv",
"tag_device_one": "dispozitiv",
"tag_device_many": "dispozitive",
"tag_group_one": "grup dinamic",
"tag_group_many": "grupuri dinamice"
},
"keys": {
"title": "Cheile serverului",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Durată",
"sessions_active": "Activă",
"sessions_transport_punch": "Direct / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Eliminarea unei etichete aici o dezasociază doar de acest dispozitiv. Folosiți Gestionează etichetele pe pagina Dispozitive pentru redenumire/ștergere globală."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Hantera taggar",
"manage_device_tags": "Hantera taggar",
"rename_tag": "Byt namn på tagg",
"delete_tag": "Ta bort tagg",
"rename_tag_prompt": "Nytt taggnamn",
"rename_tag_hint": "Byter namn på denna tagg på alla synliga enheter och uppdaterar matchande dynamiska grupper.",
"delete_tag_confirm": "Ta bort taggen \"{tag}\" från {count} enhet(er)? Dynamiska grupper som använder den blir tomma tills de uppdateras.",
"tag_renamed": "Tagg omdöpt",
"tag_deleted": "Tagg borttagen",
"tag_name_required": "Taggnamn krävs",
"tag_rename_identical": "Det nya namnet måste vara annorlunda",
"tags_backend_unsupported": "Enhetstaggar stöds inte med aktuell backend",
"tags_manage_hint": "Byt namn på eller ta bort taggar på alla synliga enheter. Borttagning från en enhet görs i Redigera eller Detaljer.",
"tags_device_hint": "Ta bort en tagg här för att koppla bort den endast från denna enhet. Använd Hantera taggar för global ändring.",
"unassign_tag": "Ta bort från denna enhet",
"tag_device_one": "enhet",
"tag_device_many": "enheter",
"tag_group_one": "dynamisk grupp",
"tag_group_many": "dynamiska grupper"
},
"keys": {
"title": "Servernycklar",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Varaktighet",
"sessions_active": "Aktiv",
"sessions_transport_punch": "Direkt / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Att ta bort en tagg här kopplar bort den endast från denna enhet. Använd Hantera taggar på Enheter för global ändring."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "จัดการแท็ก",
"manage_device_tags": "จัดการแท็ก",
"rename_tag": "เปลี่ยนชื่อแท็ก",
"delete_tag": "ลบแท็ก",
"rename_tag_prompt": "ชื่อแท็กใหม่",
"rename_tag_hint": "เปลี่ยนชื่อแท็กนี้บนอุปกรณ์ที่มองเห็นทั้งหมด และอัปเดตกลุ่มไดนามิกที่ตรงกัน",
"delete_tag_confirm": "ลบแท็ก \"{tag}\" ออกจาก {count} อุปกรณ์หรือไม่ กลุ่มไดนามิกที่ใช้แท็กนี้จะว่างจนกว่าจะอัปเดต",
"tag_renamed": "เปลี่ยนชื่อแท็กแล้ว",
"tag_deleted": "ลบแท็กแล้ว",
"tag_name_required": "ต้องระบุชื่อแท็ก",
"tag_rename_identical": "ชื่อใหม่ต้องต่างจากเดิม",
"tags_backend_unsupported": "แท็กอุปกรณ์ไม่รองรับกับแบ็กเอนด์ปัจจุบัน",
"tags_manage_hint": "เปลี่ยนชื่อหรือลบแท็กบนอุปกรณ์ที่มองเห็นทั้งหมด การลบจากอุปกรณ์เดียวทำในแก้ไขหรือรายละเอียด",
"tags_device_hint": "ลบแท็กที่นี่เพื่อยกเลิกการกำหนดเฉพาะอุปกรณ์นี้ ใช้จัดการแท็กเพื่อเปลี่ยนชื่อหรือลบทั้งหมด",
"unassign_tag": "ลบออกจากอุปกรณ์นี้",
"tag_device_one": "อุปกรณ์",
"tag_device_many": "อุปกรณ์",
"tag_group_one": "กลุ่มไดนามิก",
"tag_group_many": "กลุ่มไดนามิก"
},
"keys": {
"title": "คีย์เซิร์ฟเวอร์",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "ระยะเวลา",
"sessions_active": "กำลังทำงาน",
"sessions_transport_punch": "ตรง / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "ลบแท็กที่นี่จะยกเลิกการกำหนดเฉพาะอุปกรณ์นี้ ใช้จัดการแท็กในหน้าอุปกรณ์เพื่อเปลี่ยนชื่อหรือลบทั้งหมด"
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Etiketleri yönet",
"manage_device_tags": "Etiketleri yönet",
"rename_tag": "Etiketi yeniden adlandır",
"delete_tag": "Etiketi sil",
"rename_tag_prompt": "Yeni etiket adı",
"rename_tag_hint": "Bu etiketi görebildiğiniz tüm cihazlarda yeniden adlandırır ve eşleşen dinamik grupları günceller.",
"delete_tag_confirm": "\"{tag}\" etiketi {count} cihazdan kaldırılsın mı? Bu etiketi kullanan dinamik gruplar güncellenene kadar boş kalır.",
"tag_renamed": "Etiket yeniden adlandırıldı",
"tag_deleted": "Etiket silindi",
"tag_name_required": "Etiket adı gerekli",
"tag_rename_identical": "Yeni ad farklı olmalı",
"tags_backend_unsupported": "Cihaz etiketleri mevcut backend ile desteklenmiyor",
"tags_manage_hint": "Görünür tüm cihazlarda etiketleri yeniden adlandırın veya silin. Bir cihazdan kaldırma Düzenle veya Ayrıntılar’da yapılır.",
"tags_device_hint": "Burada bir etiketi kaldırarak yalnızca bu cihazdan ayırırsınız. Her yerde yeniden adlandırmak/silmek için Etiketleri yönet’i kullanın.",
"unassign_tag": "Bu cihazdan kaldır",
"tag_device_one": "cihaz",
"tag_device_many": "cihaz",
"tag_group_one": "dinamik grup",
"tag_group_many": "dinamik grup"
},
"keys": {
"title": "Sunucu Anahtarları",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Süre",
"sessions_active": "Aktif",
"sessions_transport_punch": "Doğrudan / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Burada bir etiketi kaldırmak yalnızca bu cihazdan ayırır. Her yerde yeniden adlandırmak/silmek için Cihazlar sayfasındaki Etiketleri yönet’i kullanın."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Керувати тегами",
"manage_device_tags": "Керувати тегами",
"rename_tag": "Перейменувати тег",
"delete_tag": "Видалити тег",
"rename_tag_prompt": "Нова назва тегу",
"rename_tag_hint": "Перейменовує цей тег на всіх видимих пристроях і оновлює відповідні динамічні групи.",
"delete_tag_confirm": "Видалити тег \"{tag}\" з {count} пристрою(їв)? Динамічні групи з цим тегом будуть порожніми до оновлення.",
"tag_renamed": "Тег перейменовано",
"tag_deleted": "Тег видалено",
"tag_name_required": "Назва тегу обов’язкова",
"tag_rename_identical": "Нова назва має відрізнятися",
"tags_backend_unsupported": "Теги пристроїв не підтримуються з поточним бекендом",
"tags_manage_hint": "Перейменовуйте або видаляйте теги на всіх видимих пристроях. Зняття з одного пристрою — в Редагуванні або Деталях.",
"tags_device_hint": "Видаліть тег тут, щоб відв’язати його лише від цього пристрою. Для глобальних змін використовуйте Керувати тегами.",
"unassign_tag": "Видалити з цього пристрою",
"tag_device_one": "пристрій",
"tag_device_many": "пристроїв",
"tag_group_one": "динамічна група",
"tag_group_many": "динамічних груп"
},
"keys": {
"title": "Ключі сервера",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Тривалість",
"sessions_active": "Активна",
"sessions_transport_punch": "Пряме / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Видалення тегу тут відв’язує його лише від цього пристрою. Для глобальних змін використайте Керувати тегами на сторінці Пристрої."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -441,7 +441,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "Quản lý thẻ",
"manage_device_tags": "Quản lý thẻ",
"rename_tag": "Đổi tên thẻ",
"delete_tag": "Xóa thẻ",
"rename_tag_prompt": "Tên thẻ mới",
"rename_tag_hint": "Đổi tên thẻ này trên tất cả thiết bị bạn thấy và cập nhật nhóm động khớp.",
"delete_tag_confirm": "Gỡ thẻ \"{tag}\" khỏi {count} thiết bị? Nhóm động dùng thẻ này sẽ trống cho đến khi cập nhật.",
"tag_renamed": "Đã đổi tên thẻ",
"tag_deleted": "Đã xóa thẻ",
"tag_name_required": "Tên thẻ là bắt buộc",
"tag_rename_identical": "Tên mới phải khác",
"tags_backend_unsupported": "Thẻ thiết bị không được hỗ trợ với backend hiện tại",
"tags_manage_hint": "Đổi tên hoặc xóa thẻ trên tất cả thiết bị hiển thị. Gỡ khỏi một thiết bị trong Sửa hoặc Chi tiết.",
"tags_device_hint": "Gỡ thẻ tại đây chỉ bỏ gán khỏi thiết bị này. Dùng Quản lý thẻ để đổi tên/xóa toàn cục.",
"unassign_tag": "Gỡ khỏi thiết bị này",
"tag_device_one": "thiết bị",
"tag_device_many": "thiết bị",
"tag_group_one": "nhóm động",
"tag_group_many": "nhóm động"
},
"keys": {
"title": "Khóa máy chủ",
@@ -1775,7 +1794,8 @@
"sessions_col_duration": "Thời lượng",
"sessions_active": "Đang hoạt động",
"sessions_transport_punch": "Trực tiếp / punch",
"sessions_transport_relay": "Relay"
"sessions_transport_relay": "Relay",
"tags_unassign_hint": "Gỡ thẻ tại đây chỉ bỏ gán khỏi thiết bị này. Dùng Quản lý thẻ trên trang Thiết bị để đổi tên/xóa toàn cục."
},
"inventory": {
"title": "Inventory",
+22 -2
View File
@@ -439,7 +439,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "管理標籤",
"manage_device_tags": "管理標籤",
"rename_tag": "重新命名標籤",
"delete_tag": "刪除標籤",
"rename_tag_prompt": "新標籤名稱",
"rename_tag_hint": "在您可見的所有裝置上重新命名此標籤,並更新相符的動態群組。",
"delete_tag_confirm": "從 {count} 台裝置移除標籤 \"{tag}\"?使用此標籤的動態群組在更新前將為空。",
"tag_renamed": "標籤已重新命名",
"tag_deleted": "標籤已刪除",
"tag_name_required": "標籤名稱為必填",
"tag_rename_identical": "新名稱必須不同",
"tags_backend_unsupported": "目前後端不支援裝置標籤",
"tags_manage_hint": "在您可見的所有裝置上重新命名或刪除標籤。從單一裝置移除請在編輯或詳細資料中操作。",
"tags_device_hint": "在此移除標籤僅取消本裝置的指派。全域重新命名或刪除請使用管理標籤。",
"unassign_tag": "從此裝置移除",
"tag_device_one": "台裝置",
"tag_device_many": "台裝置",
"tag_group_one": "個動態群組",
"tag_group_many": "個動態群組"
},
"keys": {
"title": "服務器密鑰",
@@ -1786,7 +1805,8 @@
"sessions_col_duration": "時長",
"sessions_active": "進行中",
"sessions_transport_punch": "直連 / 打洞",
"sessions_transport_relay": "中繼"
"sessions_transport_relay": "中繼",
"tags_unassign_hint": "在此移除標籤僅取消本裝置的指派。全域重新命名或刪除請在裝置頁使用管理標籤。"
},
"inventory": {
"title": "資產清單",
+22 -2
View File
@@ -435,7 +435,26 @@
"group_commercialization_assign_existing": "Assign existing package",
"group_commercialization_create_new": "Create new package and assign",
"group_commercialization_saved": "Package assigned to group",
"group_one_only": "A device can belong to only one group"
"group_one_only": "A device can belong to only one group",
"manage_tags": "管理标签",
"manage_device_tags": "管理标签",
"rename_tag": "重命名标签",
"delete_tag": "删除标签",
"rename_tag_prompt": "新标签名称",
"rename_tag_hint": "在您可见的所有设备上重命名此标签,并更新匹配的动态组。",
"delete_tag_confirm": "从 {count} 台设备移除标签 \"{tag}\"?使用此标签的动态组在更新前将为空。",
"tag_renamed": "标签已重命名",
"tag_deleted": "标签已删除",
"tag_name_required": "标签名称为必填项",
"tag_rename_identical": "新名称必须不同",
"tags_backend_unsupported": "当前后端不支持设备标签",
"tags_manage_hint": "在您可见的所有设备上重命名或删除标签。从单台设备移除请在编辑或详情中操作。",
"tags_device_hint": "在此移除标签仅取消本设备的分配。全局重命名或删除请使用管理标签。",
"unassign_tag": "从此设备移除",
"tag_device_one": "台设备",
"tag_device_many": "台设备",
"tag_group_one": "个动态组",
"tag_group_many": "个动态组"
},
"keys": {
"title": "服务器密钥",
@@ -1769,7 +1788,8 @@
"sessions_col_duration": "时长",
"sessions_active": "进行中",
"sessions_transport_punch": "直连 / 打洞",
"sessions_transport_relay": "中继"
"sessions_transport_relay": "中继",
"tags_unassign_hint": "在此移除标签仅取消本设备的分配。全局重命名或删除请在设备页使用管理标签。"
},
"inventory": {
"title": "资产清单",
+6
View File
@@ -481,6 +481,12 @@ body.device-detail-open {
TAGS TAB
═══════════════════════════════════════════════════════════════════════════ */
.device-panel-tags-hint {
margin: 0 0 var(--space-sm, 0.5rem);
color: var(--text-tertiary, #8b949e);
font-size: var(--font-size-sm, 0.875rem);
}
.device-panel-tags-container {
display: flex;
flex-wrap: wrap;
+68
View File
@@ -434,6 +434,74 @@
border-color: var(--accent-blue) !important;
}
.manage-tags-hint {
margin: 0 0 var(--space-md);
}
.manage-tags-empty {
padding: var(--space-md);
color: var(--text-tertiary);
font-size: var(--font-size-sm);
text-align: center;
}
.manage-tags-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(50vh, 420px);
overflow-y: auto;
}
.manage-tags-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--bg-secondary, transparent);
}
.manage-tags-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.manage-tags-name {
font-weight: 600;
color: var(--text-primary);
word-break: break-word;
}
.manage-tags-meta {
font-size: var(--font-size-sm);
color: var(--text-tertiary);
}
.manage-tags-actions {
display: flex;
flex-shrink: 0;
gap: 6px;
}
.manage-tags-actions .btn .material-icons {
font-size: 16px;
}
.device-edit-tags-focus {
outline: 1px solid var(--accent-blue);
outline-offset: 4px;
border-radius: var(--radius-md);
padding: 8px;
}
/* ==================== Toolbar ==================== */
.devices-toolbar {
+2 -1
View File
@@ -840,12 +840,13 @@ const DeviceDetail = (function () {
html += `
<div class="device-panel-section">
<div class="device-panel-section-title"><span class="material-icons">sell</span> ${_('device_detail.tags')}</div>
<p class="form-hint device-panel-tags-hint">${_('device_detail.tags_unassign_hint') || _('devices.tags_device_hint') || 'Removing a tag here unassigns it from this device only. Use Manage Tags on the Devices page to rename or delete a tag everywhere.'}</p>
<div class="device-panel-tags-container" id="dp-tags-list">
${hasTags
? tags.map(t => `
<span class="device-panel-tag">
${Utils.escapeHtml(t)}
<button class="device-panel-tag-remove" data-tag="${Utils.escapeHtml(t)}" title="${_('actions.delete')}">
<button class="device-panel-tag-remove" data-tag="${Utils.escapeHtml(t)}" title="${_('devices.unassign_tag') || _('actions.delete')}">
<span class="material-icons">close</span>
</button>
</span>`).join('')
+186 -4
View File
@@ -141,6 +141,7 @@
initFilters();
initShowDeletedToggle();
initTagFilter();
initManageTags();
initSorting();
initSync();
document.getElementById('add-remote-target-btn')?.addEventListener('click', () => showRemoteTargetModal());
@@ -401,6 +402,10 @@
<span class="material-icons">edit</span>
<span>${_('actions.edit')}</span>
</button>
<button type="button" class="kebab-menu-item" data-action="manage-tags" data-id="${eid}">
<span class="material-icons">sell</span>
<span>${_('devices.manage_device_tags') || 'Manage tags'}</span>
</button>
<button type="button" class="kebab-menu-item" data-action="groups" data-id="${eid}">
<span class="material-icons">hub</span>
<span>${_('devices.manage_groups') || 'Manage Groups'}</span>
@@ -509,6 +514,10 @@
<span class="material-icons">edit</span>
<span>${_('actions.edit')}</span>
</button>
<button type="button" class="kebab-menu-item" data-action="manage-tags" data-id="${eid}">
<span class="material-icons">sell</span>
<span>${_('devices.manage_device_tags') || 'Manage tags'}</span>
</button>
<button type="button" class="kebab-menu-item" data-action="groups" data-id="${eid}">
<span class="material-icons">hub</span>
<span>${_('devices.manage_groups') || 'Manage Groups'}</span>
@@ -1286,6 +1295,14 @@
}
break;
case 'manage-tags':
if (devices.find(d => d.id === deviceId)?.remote_target) {
showEditModal(deviceId);
} else {
showEditModal(deviceId, { focusTags: true });
}
break;
case 'groups':
showDeviceMembershipModal(deviceId);
break;
@@ -2284,9 +2301,10 @@
/**
* Show edit modal
*/
function showEditModal(deviceId) {
function showEditModal(deviceId, options = {}) {
const device = devices.find(d => d.id === deviceId);
if (!device) return;
const focusTags = !!(options && options.focusTags);
const editTags = new Set(normalizeTags(device.tags));
const editTagInputId = 'edit-tag-input';
@@ -2304,7 +2322,7 @@
${Utils.escapeHtml(tag)}
<button type="button" class="device-edit-tag-remove"
data-edit-tag-remove="${Utils.escapeHtml(tag)}"
title="${Utils.escapeHtml(_('actions.delete'))}">
title="${Utils.escapeHtml(_('devices.unassign_tag') || _('actions.delete'))}">
<span class="material-icons">close</span>
</button>
</span>
@@ -2347,7 +2365,9 @@
};
Modal.show({
title: _('devices.edit_title'),
title: focusTags
? (_('devices.manage_device_tags') || _('devices.edit_title'))
: _('devices.edit_title'),
content: `
<div class="device-info-grid">
<div class="device-info-item">
@@ -2369,8 +2389,9 @@
<textarea id="edit-note" class="form-input" rows="2"
placeholder="${_('devices.note_placeholder')}" maxlength="512">${Utils.escapeHtml(device.note || '')}</textarea>
</div>
<div class="device-info-item full-width device-edit-tags-field">
<div class="device-info-item full-width device-edit-tags-field${focusTags ? ' device-edit-tags-focus' : ''}">
<label for="${editTagInputId}">${_('device_detail.tags')}</label>
<p class="form-hint">${Utils.escapeHtml(_('devices.tags_device_hint') || 'Remove a tag here to unassign it from this device only. Use Manage Tags to rename or delete a tag everywhere.')}</p>
<div id="${editTagsSelectedId}" class="device-edit-tags-selected"></div>
<div id="${editTagOptionsId}" class="device-edit-tag-options"></div>
<div class="device-edit-tag-input-row">
@@ -2480,12 +2501,173 @@
addEditTag();
}
});
if (focusTags) {
const field = document.querySelector('.device-edit-tags-field');
field?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
setTimeout(() => input?.focus(), 100);
}
}
});
}
window.BetterDeskDevices = window.BetterDeskDevices || {};
window.BetterDeskDevices.showEditModal = showEditModal;
function countDevicesWithTag(tag) {
const expected = String(tag || '').trim().toLowerCase();
if (!expected) return 0;
return devices.filter(device =>
normalizeTags(device.tags).some(t => t.toLowerCase() === expected)
).length;
}
function countGroupsWithTag(tag) {
const expected = String(tag || '').trim().toLowerCase();
if (!expected) return 0;
return deviceGroups.filter(group =>
(group.source_type || 'manual') === 'tag'
&& String(group.tag_filter || '').trim().toLowerCase() === expected
).length;
}
function showManageTagsModal() {
const renderList = () => {
if (!availableTags.length) {
return `<div class="manage-tags-empty">${Utils.escapeHtml(_('devices.no_tags') || 'No tags')}</div>`;
}
return `<ul class="manage-tags-list">${availableTags.map(tag => {
const deviceCount = countDevicesWithTag(tag);
const groupCount = countGroupsWithTag(tag);
const metaParts = [
`${deviceCount} ${deviceCount === 1 ? (_('devices.tag_device_one') || 'device') : (_('devices.tag_device_many') || 'devices')}`
];
if (groupCount > 0) {
metaParts.push(`${groupCount} ${groupCount === 1 ? (_('devices.tag_group_one') || 'dynamic group') : (_('devices.tag_group_many') || 'dynamic groups')}`);
}
return `<li class="manage-tags-row" data-tag="${Utils.escapeHtml(tag)}">
<div class="manage-tags-info">
<span class="manage-tags-name">${Utils.escapeHtml(tag)}</span>
<span class="manage-tags-meta">${Utils.escapeHtml(metaParts.join(' · '))}</span>
</div>
<div class="manage-tags-actions">
<button type="button" class="btn btn-secondary btn-sm manage-tag-rename" data-tag="${Utils.escapeHtml(tag)}" title="${Utils.escapeHtml(_('devices.rename_tag') || 'Rename')}">
<span class="material-icons">edit</span>
<span>${Utils.escapeHtml(_('devices.rename_tag') || 'Rename')}</span>
</button>
<button type="button" class="btn btn-danger btn-sm manage-tag-delete" data-tag="${Utils.escapeHtml(tag)}" title="${Utils.escapeHtml(_('devices.delete_tag') || 'Delete')}">
<span class="material-icons">delete</span>
<span>${Utils.escapeHtml(_('devices.delete_tag') || 'Delete')}</span>
</button>
</div>
</li>`;
}).join('')}</ul>`;
};
const bindActions = () => {
document.querySelectorAll('.manage-tag-rename').forEach(button => {
button.addEventListener('click', async () => {
const from = button.dataset.tag;
if (!from) return;
const to = await Modal.prompt({
title: _('devices.rename_tag') || 'Rename tag',
label: _('devices.rename_tag_prompt') || 'New tag name',
value: from,
placeholder: from,
hint: _('devices.rename_tag_hint') || 'Renames this tag on all devices you can see and updates matching dynamic groups.',
confirmLabel: _('actions.save')
});
if (to === null) return;
const cleaned = String(to || '').trim();
if (!cleaned) {
Notifications.error(_('devices.tag_name_required') || 'Tag name is required');
return;
}
if (cleaned === from) return;
try {
const result = await Utils.api('/api/tags/rename', {
method: 'POST',
body: { from, to: cleaned }
});
if (selectedTags.has(from)) {
selectedTags.delete(from);
selectedTags.add(result?.to || cleaned);
}
Notifications.success(
(_('devices.tag_renamed') || 'Tag renamed')
+ ` (${result?.updatedDevices || 0})`
);
Modal.close();
await loadTags();
await loadDeviceGroups();
await loadDevices();
showManageTagsModal();
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
}
});
});
document.querySelectorAll('.manage-tag-delete').forEach(button => {
button.addEventListener('click', async () => {
const tag = button.dataset.tag;
if (!tag) return;
const deviceCount = countDevicesWithTag(tag);
const groupCount = countGroupsWithTag(tag);
const confirmMsg = (_('devices.delete_tag_confirm') || 'Remove tag "{tag}" from {count} device(s)? Dynamic groups that use this tag will become empty until updated.')
.replace('{tag}', tag)
.replace('{count}', String(deviceCount))
.replace('{groups}', String(groupCount));
const confirmed = await Modal.confirm({
title: _('devices.delete_tag') || 'Delete tag',
message: confirmMsg,
danger: true,
confirmLabel: _('actions.delete')
});
if (!confirmed) return;
try {
const result = await Utils.api('/api/tags/delete', {
method: 'POST',
body: { tag }
});
selectedTags.delete(tag);
Notifications.success(
(_('devices.tag_deleted') || 'Tag deleted')
+ ` (${result?.updatedDevices || 0})`
);
Modal.close();
await loadTags();
await loadDeviceGroups();
await loadDevices();
showManageTagsModal();
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
}
});
});
};
Modal.show({
title: _('devices.manage_tags') || 'Manage tags',
content: `
<p class="form-hint manage-tags-hint">${Utils.escapeHtml(_('devices.tags_manage_hint') || 'Rename or delete tags across all devices you can see. Removing a tag from one device is done in Edit / Device details.')}</p>
${renderList()}
`,
size: 'medium',
buttons: [
{ label: _('actions.close'), class: 'btn-secondary', onClick: () => Modal.close() }
],
onOpen: bindActions
});
}
function initManageTags() {
const btn = document.getElementById('manage-tags-btn');
if (!btn) return;
btn.addEventListener('click', (e) => {
e.preventDefault();
showManageTagsModal();
});
}
/**
* Render pagination
+129
View File
@@ -9,6 +9,7 @@ const serverBackend = require('../services/serverBackend');
const betterdeskApi = require('../services/betterdeskApi');
const addressBookSync = require('../services/rustdeskAddressBookSync');
const deviceGroupService = require('../services/deviceGroupService');
const tagManagementService = require('../services/tagManagementService');
const { requireAuth, requirePermission, requestHasPermission } = require('../middleware/auth');
const { bodyInt, bodyString, bodyBool, plainBodyObject } = require('../lib/bodyScalars');
@@ -250,6 +251,134 @@ router.get('/api/tags', requireAuth, requirePermission('device.view'), async (re
}
});
/**
* POST /api/tags/rename - Rename a tag on all visible devices (and matching dynamic groups).
*/
router.post('/api/tags/rename', requireAuth, requirePermission('device.edit'), async (req, res) => {
try {
if (!(await serverBackend.isBetterDesk())) {
return res.status(400).json({
success: false,
error: req.t('devices.tags_backend_unsupported')
});
}
const from = tagManagementService.sanitizeTagName(bodyString(req.body && req.body.from));
const to = tagManagementService.sanitizeTagName(bodyString(req.body && req.body.to));
if (!from || !to) {
return res.status(400).json({
success: false,
error: req.t('devices.tag_name_required')
});
}
if (from === to) {
return res.status(400).json({
success: false,
error: req.t('devices.tag_rename_identical')
});
}
const devices = await getVisibleDevicesForRequest(req);
const groups = await getVisibleDeviceGroupsForRequest(req);
const result = await tagManagementService.renameTagAcrossScope({
devices,
groups,
fromTag: from,
toTag: to,
setPeerTags: (id, tags) => serverBackend.setPeerTags(id, tags),
updateDeviceGroup: (guid, data) => db.updateDeviceGroup(guid, data)
});
if (result.errors && result.errors.includes('identical')) {
return res.status(400).json({
success: false,
error: req.t('devices.tag_rename_identical')
});
}
await db.logAction(
req.session.userId,
'device_tag_renamed',
`Tag "${from}" renamed to "${to}" on ${result.updatedDevices} device(s), ${result.updatedGroups} group(s)`,
req.ip
);
res.json({
success: true,
data: {
from: result.from,
to: result.to,
updatedDevices: result.updatedDevices,
updatedGroups: result.updatedGroups,
skipped: result.skipped,
failedDevices: result.errors || []
}
});
} catch (err) {
console.error('Rename tag error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
/**
* POST /api/tags/delete - Remove a tag from all visible devices.
*/
router.post('/api/tags/delete', requireAuth, requirePermission('device.edit'), async (req, res) => {
try {
if (!(await serverBackend.isBetterDesk())) {
return res.status(400).json({
success: false,
error: req.t('devices.tags_backend_unsupported')
});
}
const tag = tagManagementService.sanitizeTagName(bodyString(req.body && req.body.tag));
if (!tag) {
return res.status(400).json({
success: false,
error: req.t('devices.tag_name_required')
});
}
const devices = await getVisibleDevicesForRequest(req);
const groups = await getVisibleDeviceGroupsForRequest(req);
const result = await tagManagementService.deleteTagAcrossScope({
devices,
groups,
tag,
setPeerTags: (id, tags) => serverBackend.setPeerTags(id, tags)
});
await db.logAction(
req.session.userId,
'device_tag_deleted',
`Tag "${tag}" removed from ${result.updatedDevices} device(s) (${result.affectedGroups} dynamic group(s) reference it)`,
req.ip
);
res.json({
success: true,
data: {
tag: result.tag,
updatedDevices: result.updatedDevices,
skipped: result.skipped,
affectedGroups: result.affectedGroups,
failedDevices: result.errors || []
}
});
} catch (err) {
console.error('Delete tag error:', err);
res.status(500).json({
success: false,
error: req.t('errors.server_error')
});
}
});
function isValidGroupGuid(guid) {
return typeof guid === 'string' && guid.length > 0 && guid.length <= 80 && /^[A-Za-z0-9_.:-]+$/.test(guid);
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Device tag rename / delete helpers.
* Tags remain free-form strings on peers; these helpers rewrite visible peers only.
*/
'use strict';
const addressBookSync = require('./rustdeskAddressBookSync');
const MAX_TAG_LENGTH = 50;
const MAX_TAGS_PER_DEVICE = 20;
const SET_PEER_TAGS_CONCURRENCY = 4;
function sanitizeTagName(value) {
if (typeof value !== 'string') return '';
return value
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
.trim()
.slice(0, MAX_TAG_LENGTH);
}
function tagsEqual(a, b) {
return String(a || '').trim().toLowerCase() === String(b || '').trim().toLowerCase();
}
function deviceHasTag(device, tag) {
const expected = sanitizeTagName(tag).toLowerCase();
if (!expected) return false;
return addressBookSync.normalizeTags(device && device.tags).some(t => t.toLowerCase() === expected);
}
function rewriteTagsRename(tags, fromTag, toTag) {
const from = sanitizeTagName(fromTag);
const to = sanitizeTagName(toTag);
if (!from || !to) return addressBookSync.normalizeTags(tags);
const next = [];
const seen = new Set();
let replaced = false;
for (const tag of addressBookSync.normalizeTags(tags)) {
let value = tag;
if (tagsEqual(tag, from)) {
value = to;
replaced = true;
}
const key = value.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
next.push(value);
if (next.length >= MAX_TAGS_PER_DEVICE) break;
}
return { tags: next, changed: replaced };
}
function rewriteTagsDelete(tags, tagToRemove) {
const remove = sanitizeTagName(tagToRemove);
if (!remove) return { tags: addressBookSync.normalizeTags(tags), changed: false };
const before = addressBookSync.normalizeTags(tags);
const next = before.filter(t => !tagsEqual(t, remove));
return { tags: next, changed: next.length !== before.length };
}
async function mapWithConcurrency(items, concurrency, mapper) {
const list = Array.isArray(items) ? items : [];
const limit = Math.max(1, Number(concurrency) || 1);
const results = new Array(list.length);
let index = 0;
async function worker() {
while (index < list.length) {
const current = index++;
results[current] = await mapper(list[current], current);
}
}
const workers = [];
for (let i = 0; i < Math.min(limit, list.length); i++) {
workers.push(worker());
}
await Promise.all(workers);
return results;
}
/**
* Rename a tag across visible devices and matching dynamic groups.
* @returns {{ updatedDevices: number, updatedGroups: number, skipped: number, errors: string[] }}
*/
async function renameTagAcrossScope({
devices,
groups,
fromTag,
toTag,
setPeerTags,
updateDeviceGroup
}) {
const from = sanitizeTagName(fromTag);
const to = sanitizeTagName(toTag);
const errors = [];
if (!from || !to) {
return { updatedDevices: 0, updatedGroups: 0, skipped: 0, errors: ['invalid_tag'], from, to };
}
// Exact same string after sanitize — nothing to do (case-only renames still proceed)
if (from === to) {
return { updatedDevices: 0, updatedGroups: 0, skipped: 0, errors: ['identical'], from, to };
}
const targets = (devices || []).filter(d => deviceHasTag(d, from));
let updatedDevices = 0;
let skipped = 0;
await mapWithConcurrency(targets, SET_PEER_TAGS_CONCURRENCY, async (device) => {
const { tags, changed } = rewriteTagsRename(device.tags, from, to);
if (!changed) {
skipped += 1;
return;
}
const result = await setPeerTags(device.id, tags);
if (!result || result.success === false) {
errors.push(String(device.id));
skipped += 1;
return;
}
device.tags = tags;
updatedDevices += 1;
});
let updatedGroups = 0;
for (const group of groups || []) {
if ((group.source_type || 'manual') !== 'tag') continue;
const filter = String(group.tag_filter || '').trim();
if (!filter || !tagsEqual(filter, from)) continue;
if (typeof updateDeviceGroup === 'function') {
await updateDeviceGroup(group.guid, { tag_filter: to });
group.tag_filter = to;
updatedGroups += 1;
}
}
return { updatedDevices, updatedGroups, skipped, errors, from, to };
}
/**
* Remove a tag from all visible devices (dynamic group filters left unchanged).
* @returns {{ updatedDevices: number, skipped: number, errors: string[], affectedGroups: number }}
*/
async function deleteTagAcrossScope({
devices,
groups,
tag,
setPeerTags
}) {
const target = sanitizeTagName(tag);
const errors = [];
if (!target) {
return { updatedDevices: 0, skipped: 0, errors: ['invalid_tag'], affectedGroups: 0, tag: target };
}
const peers = (devices || []).filter(d => deviceHasTag(d, target));
let updatedDevices = 0;
let skipped = 0;
await mapWithConcurrency(peers, SET_PEER_TAGS_CONCURRENCY, async (device) => {
const { tags, changed } = rewriteTagsDelete(device.tags, target);
if (!changed) {
skipped += 1;
return;
}
const result = await setPeerTags(device.id, tags);
if (!result || result.success === false) {
errors.push(String(device.id));
skipped += 1;
return;
}
device.tags = tags;
updatedDevices += 1;
});
const affectedGroups = (groups || []).filter(group =>
(group.source_type || 'manual') === 'tag'
&& tagsEqual(group.tag_filter, target)
).length;
return { updatedDevices, skipped, errors, affectedGroups, tag: target };
}
module.exports = {
MAX_TAG_LENGTH,
sanitizeTagName,
tagsEqual,
deviceHasTag,
rewriteTagsRename,
rewriteTagsDelete,
renameTagAcrossScope,
deleteTagAcrossScope
};
+137 -1
View File
@@ -43,7 +43,9 @@ jest.mock('../services/serverBackend', () => ({
updateDevice: jest.fn().mockResolvedValue(undefined),
deleteDevice: jest.fn().mockResolvedValue(undefined),
restoreDevice: jest.fn().mockResolvedValue({ success: true }),
changePeerId: jest.fn().mockResolvedValue({ success: true })
changePeerId: jest.fn().mockResolvedValue({ success: true }),
isBetterDesk: jest.fn().mockResolvedValue(true),
setPeerTags: jest.fn().mockResolvedValue({ success: true })
}));
const serverBackend = require('../services/serverBackend');
@@ -238,6 +240,140 @@ describe('Devices Routes', () => {
});
});
describe('POST /api/tags/rename', () => {
it('should rename a tag on visible devices and update dynamic group filters', async () => {
serverBackend.getAllDevices.mockResolvedValue([
{ id: 'A1', tags: ['Linux', 'Kiosk'] },
{ id: 'A2', tags: ['Windows'] },
{ id: 'A3', tags: 'linux,Lab' }
]);
db.getAllDeviceGroups.mockResolvedValue([
{ guid: 'linux', name: 'Linux Devices', source_type: 'tag', tag_filter: 'Linux', allowed_users: [] }
]);
serverBackend.setPeerTags.mockResolvedValue({ success: true });
const res = await request(app)
.post('/api/tags/rename')
.send({ from: 'Linux', to: 'GNU/Linux' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.updatedDevices).toBe(2);
expect(res.body.data.updatedGroups).toBe(1);
expect(serverBackend.setPeerTags).toHaveBeenCalledWith('A1', ['GNU/Linux', 'Kiosk']);
expect(serverBackend.setPeerTags).toHaveBeenCalledWith('A3', ['GNU/Linux', 'Lab']);
expect(serverBackend.setPeerTags).not.toHaveBeenCalledWith('A2', expect.anything());
expect(db.updateDeviceGroup).toHaveBeenCalledWith('linux', { tag_filter: 'GNU/Linux' });
expect(db.logAction).toHaveBeenCalledWith(
1,
'device_tag_renamed',
expect.stringContaining('Linux'),
expect.anything()
);
});
it('should reject empty or identical rename', async () => {
const empty = await request(app).post('/api/tags/rename').send({ from: '', to: 'X' });
expect(empty.status).toBe(400);
const identical = await request(app).post('/api/tags/rename').send({ from: 'Same', to: 'Same' });
expect(identical.status).toBe(400);
expect(serverBackend.setPeerTags).not.toHaveBeenCalled();
});
it('should reject rename when backend is not BetterDesk', async () => {
serverBackend.isBetterDesk.mockResolvedValueOnce(false);
const res = await request(app).post('/api/tags/rename').send({ from: 'A', to: 'B' });
expect(res.status).toBe(400);
expect(serverBackend.setPeerTags).not.toHaveBeenCalled();
});
it('should only rewrite devices in operator scope', async () => {
app = createTestApp();
app.use((req, _res, next) => {
req.session.userId = 2;
req.session.user = { id: 2, username: 'operator1', role: 'operator' };
next();
});
app.use('/', devicesRoutes);
serverBackend.getAllDevices.mockResolvedValue([
{ id: 'VISIBLE', tags: ['Fleet'] },
{ id: 'HIDDEN', tags: ['Fleet'] }
]);
db.getAllDeviceGroups.mockResolvedValue([
{
guid: 'ops',
name: 'Ops',
source_type: 'manual',
tag_filter: '',
allowed_users: ['operator1']
},
{
guid: 'other',
name: 'Other',
source_type: 'manual',
tag_filter: '',
allowed_users: ['someone_else']
}
]);
db.getDeviceGroupMembers.mockImplementation(async (guid) => {
if (guid === 'ops') return ['VISIBLE'];
if (guid === 'other') return ['HIDDEN'];
return [];
});
serverBackend.setPeerTags.mockResolvedValue({ success: true });
const res = await request(app)
.post('/api/tags/rename')
.send({ from: 'Fleet', to: 'Prod' });
expect(res.status).toBe(200);
expect(res.body.data.updatedDevices).toBe(1);
expect(serverBackend.setPeerTags).toHaveBeenCalledTimes(1);
expect(serverBackend.setPeerTags).toHaveBeenCalledWith('VISIBLE', ['Prod']);
expect(serverBackend.setPeerTags).not.toHaveBeenCalledWith('HIDDEN', expect.anything());
});
});
describe('POST /api/tags/delete', () => {
it('should remove a tag from all visible devices', async () => {
serverBackend.getAllDevices.mockResolvedValue([
{ id: 'A1', tags: ['Temp', 'Keep'] },
{ id: 'A2', tags: ['Keep'] },
{ id: 'A3', tags: 'temp' }
]);
db.getAllDeviceGroups.mockResolvedValue([
{ guid: 'temp-g', name: 'Temp', source_type: 'tag', tag_filter: 'Temp', allowed_users: [] }
]);
serverBackend.setPeerTags.mockResolvedValue({ success: true });
const res = await request(app)
.post('/api/tags/delete')
.send({ tag: 'Temp' });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.updatedDevices).toBe(2);
expect(res.body.data.affectedGroups).toBe(1);
expect(serverBackend.setPeerTags).toHaveBeenCalledWith('A1', ['Keep']);
expect(serverBackend.setPeerTags).toHaveBeenCalledWith('A3', []);
expect(db.updateDeviceGroup).not.toHaveBeenCalled();
expect(db.logAction).toHaveBeenCalledWith(
1,
'device_tag_deleted',
expect.stringContaining('Temp'),
expect.anything()
);
});
it('should reject empty tag delete', async () => {
const res = await request(app).post('/api/tags/delete').send({ tag: ' ' });
expect(res.status).toBe(400);
expect(serverBackend.setPeerTags).not.toHaveBeenCalled();
});
});
describe('Device groups', () => {
it('should count dynamic tag groups from visible devices', async () => {
serverBackend.getAllDevices.mockResolvedValue([
@@ -0,0 +1,84 @@
/**
* Tag management helper unit tests
*/
const {
sanitizeTagName,
rewriteTagsRename,
rewriteTagsDelete,
renameTagAcrossScope,
deleteTagAcrossScope
} = require('../services/tagManagementService');
describe('tagManagementService', () => {
it('sanitizes tag names', () => {
expect(sanitizeTagName(' Hello\x00 ')).toBe('Hello');
expect(sanitizeTagName('x'.repeat(80))).toHaveLength(50);
expect(sanitizeTagName(null)).toBe('');
});
it('renames tags case-insensitively and dedupes', () => {
expect(rewriteTagsRename(['Linux', 'Kiosk'], 'linux', 'GNU')).toEqual({
tags: ['GNU', 'Kiosk'],
changed: true
});
expect(rewriteTagsRename(['GNU', 'Linux'], 'Linux', 'GNU')).toEqual({
tags: ['GNU'],
changed: true
});
expect(rewriteTagsRename(['Windows'], 'Linux', 'GNU').changed).toBe(false);
});
it('deletes tags case-insensitively', () => {
expect(rewriteTagsDelete(['Temp', 'Keep'], 'temp')).toEqual({
tags: ['Keep'],
changed: true
});
expect(rewriteTagsDelete(['Keep'], 'Temp').changed).toBe(false);
});
it('renameTagAcrossScope updates devices and groups', async () => {
const setPeerTags = jest.fn().mockResolvedValue({ success: true });
const updateDeviceGroup = jest.fn().mockResolvedValue(undefined);
const devices = [
{ id: '1', tags: ['A', 'B'] },
{ id: '2', tags: ['C'] }
];
const groups = [
{ guid: 'g1', source_type: 'tag', tag_filter: 'A' },
{ guid: 'g2', source_type: 'manual', tag_filter: 'A' }
];
const result = await renameTagAcrossScope({
devices,
groups,
fromTag: 'A',
toTag: 'Z',
setPeerTags,
updateDeviceGroup
});
expect(result.updatedDevices).toBe(1);
expect(result.updatedGroups).toBe(1);
expect(setPeerTags).toHaveBeenCalledWith('1', ['Z', 'B']);
expect(updateDeviceGroup).toHaveBeenCalledWith('g1', { tag_filter: 'Z' });
expect(groups[0].tag_filter).toBe('Z');
});
it('deleteTagAcrossScope strips tags and reports affected groups', async () => {
const setPeerTags = jest.fn().mockResolvedValue({ success: true });
const result = await deleteTagAcrossScope({
devices: [
{ id: '1', tags: ['Temp'] },
{ id: '2', tags: ['Keep'] }
],
groups: [{ guid: 'g', source_type: 'tag', tag_filter: 'Temp' }],
tag: 'Temp',
setPeerTags
});
expect(result.updatedDevices).toBe(1);
expect(result.affectedGroups).toBe(1);
expect(setPeerTags).toHaveBeenCalledWith('1', []);
});
});
+3
View File
@@ -108,6 +108,9 @@
<div class="tag-filter-empty">${_('devices.no_tags')}</div>
</div>
</div>
<button class="btn-icon toolbar-icon" id="manage-tags-btn" title="${_('devices.manage_tags')}">
<span class="material-icons">label</span>
</button>
<div class="column-visibility-dropdown">
<button class="btn-icon toolbar-icon" id="columns-btn" title="${_('devices.toggle_columns')}">
<span class="material-icons">view_column</span>