From ece564ea77ff2a953ee95c6021aaa74181aa7762 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:20:03 +0200 Subject: [PATCH] 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) --- CHANGELOG.md | 3 + web-nodejs/lang/ar.json | 24 ++- web-nodejs/lang/cs.json | 24 ++- web-nodejs/lang/da.json | 24 ++- web-nodejs/lang/de.json | 24 ++- web-nodejs/lang/en.json | 20 ++ web-nodejs/lang/es.json | 24 ++- web-nodejs/lang/fi.json | 24 ++- web-nodejs/lang/fr.json | 24 ++- web-nodejs/lang/hi.json | 24 ++- web-nodejs/lang/hu.json | 24 ++- web-nodejs/lang/id.json | 24 ++- web-nodejs/lang/it.json | 24 ++- web-nodejs/lang/ja.json | 24 ++- web-nodejs/lang/ko.json | 24 ++- web-nodejs/lang/nb.json | 24 ++- web-nodejs/lang/nl.json | 24 ++- web-nodejs/lang/pl.json | 24 ++- web-nodejs/lang/pt.json | 24 ++- web-nodejs/lang/ro.json | 24 ++- web-nodejs/lang/sv.json | 24 ++- web-nodejs/lang/th.json | 24 ++- web-nodejs/lang/tr.json | 24 ++- web-nodejs/lang/uk.json | 24 ++- web-nodejs/lang/vi.json | 24 ++- web-nodejs/lang/zh-TW.json | 24 ++- web-nodejs/lang/zh.json | 24 ++- web-nodejs/public/css/device-detail.css | 6 + web-nodejs/public/css/devices.css | 68 ++++++ web-nodejs/public/js/deviceDetail.js | 3 +- web-nodejs/public/js/devices.js | 190 ++++++++++++++++- web-nodejs/routes/devices.routes.js | 129 +++++++++++ web-nodejs/services/tagManagementService.js | 200 ++++++++++++++++++ web-nodejs/tests/devices.routes.test.js | 138 +++++++++++- web-nodejs/tests/tagManagementService.test.js | 84 ++++++++ web-nodejs/views/devices.ejs | 3 + 36 files changed, 1388 insertions(+), 56 deletions(-) create mode 100644 web-nodejs/services/tagManagementService.js create mode 100644 web-nodejs/tests/tagManagementService.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index a872876e..06bd3118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)_ diff --git a/web-nodejs/lang/ar.json b/web-nodejs/lang/ar.json index 8820b885..ea7522d9 100644 --- a/web-nodejs/lang/ar.json +++ b/web-nodejs/lang/ar.json @@ -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", diff --git a/web-nodejs/lang/cs.json b/web-nodejs/lang/cs.json index f222182c..fc3e4eb9 100644 --- a/web-nodejs/lang/cs.json +++ b/web-nodejs/lang/cs.json @@ -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ář", diff --git a/web-nodejs/lang/da.json b/web-nodejs/lang/da.json index bf8e5a58..12f143bf 100644 --- a/web-nodejs/lang/da.json +++ b/web-nodejs/lang/da.json @@ -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", diff --git a/web-nodejs/lang/de.json b/web-nodejs/lang/de.json index c5011b2a..ca92e127 100644 --- a/web-nodejs/lang/de.json +++ b/web-nodejs/lang/de.json @@ -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", diff --git a/web-nodejs/lang/en.json b/web-nodejs/lang/en.json index 98b29ecc..ac5e46b5 100644 --- a/web-nodejs/lang/en.json +++ b/web-nodejs/lang/en.json @@ -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", diff --git a/web-nodejs/lang/es.json b/web-nodejs/lang/es.json index 2c825e09..4ce2754b 100644 --- a/web-nodejs/lang/es.json +++ b/web-nodejs/lang/es.json @@ -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", diff --git a/web-nodejs/lang/fi.json b/web-nodejs/lang/fi.json index 7c9b0393..81ba741e 100644 --- a/web-nodejs/lang/fi.json +++ b/web-nodejs/lang/fi.json @@ -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", diff --git a/web-nodejs/lang/fr.json b/web-nodejs/lang/fr.json index 44ca4bf3..b54a2be0 100644 --- a/web-nodejs/lang/fr.json +++ b/web-nodejs/lang/fr.json @@ -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", diff --git a/web-nodejs/lang/hi.json b/web-nodejs/lang/hi.json index ef4aaf74..de7a4345 100644 --- a/web-nodejs/lang/hi.json +++ b/web-nodejs/lang/hi.json @@ -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", diff --git a/web-nodejs/lang/hu.json b/web-nodejs/lang/hu.json index becef912..04fde3f0 100644 --- a/web-nodejs/lang/hu.json +++ b/web-nodejs/lang/hu.json @@ -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", diff --git a/web-nodejs/lang/id.json b/web-nodejs/lang/id.json index 9ed7e9e4..1b9ee765 100644 --- a/web-nodejs/lang/id.json +++ b/web-nodejs/lang/id.json @@ -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", diff --git a/web-nodejs/lang/it.json b/web-nodejs/lang/it.json index 0916328b..2fd45f87 100644 --- a/web-nodejs/lang/it.json +++ b/web-nodejs/lang/it.json @@ -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", diff --git a/web-nodejs/lang/ja.json b/web-nodejs/lang/ja.json index 38b721d2..462090aa 100644 --- a/web-nodejs/lang/ja.json +++ b/web-nodejs/lang/ja.json @@ -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", diff --git a/web-nodejs/lang/ko.json b/web-nodejs/lang/ko.json index 3747a7d2..c1434bfd 100644 --- a/web-nodejs/lang/ko.json +++ b/web-nodejs/lang/ko.json @@ -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", diff --git a/web-nodejs/lang/nb.json b/web-nodejs/lang/nb.json index 4da9fa10..3a3ebf1d 100644 --- a/web-nodejs/lang/nb.json +++ b/web-nodejs/lang/nb.json @@ -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", diff --git a/web-nodejs/lang/nl.json b/web-nodejs/lang/nl.json index 709d170c..a1db8cfb 100644 --- a/web-nodejs/lang/nl.json +++ b/web-nodejs/lang/nl.json @@ -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", diff --git a/web-nodejs/lang/pl.json b/web-nodejs/lang/pl.json index bf553197..a1c762fe 100644 --- a/web-nodejs/lang/pl.json +++ b/web-nodejs/lang/pl.json @@ -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", diff --git a/web-nodejs/lang/pt.json b/web-nodejs/lang/pt.json index 2358cbee..de9fcb8a 100644 --- a/web-nodejs/lang/pt.json +++ b/web-nodejs/lang/pt.json @@ -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", diff --git a/web-nodejs/lang/ro.json b/web-nodejs/lang/ro.json index 236267fb..b61e92a4 100644 --- a/web-nodejs/lang/ro.json +++ b/web-nodejs/lang/ro.json @@ -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", diff --git a/web-nodejs/lang/sv.json b/web-nodejs/lang/sv.json index 5a23d457..19e911a6 100644 --- a/web-nodejs/lang/sv.json +++ b/web-nodejs/lang/sv.json @@ -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", diff --git a/web-nodejs/lang/th.json b/web-nodejs/lang/th.json index 29f968d5..f0963907 100644 --- a/web-nodejs/lang/th.json +++ b/web-nodejs/lang/th.json @@ -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", diff --git a/web-nodejs/lang/tr.json b/web-nodejs/lang/tr.json index 1803d3d6..0b916b8b 100644 --- a/web-nodejs/lang/tr.json +++ b/web-nodejs/lang/tr.json @@ -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", diff --git a/web-nodejs/lang/uk.json b/web-nodejs/lang/uk.json index 9e1bff75..6171b1fc 100644 --- a/web-nodejs/lang/uk.json +++ b/web-nodejs/lang/uk.json @@ -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", diff --git a/web-nodejs/lang/vi.json b/web-nodejs/lang/vi.json index d07d828c..714f1c11 100644 --- a/web-nodejs/lang/vi.json +++ b/web-nodejs/lang/vi.json @@ -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", diff --git a/web-nodejs/lang/zh-TW.json b/web-nodejs/lang/zh-TW.json index 846cde1c..c5ff46c6 100644 --- a/web-nodejs/lang/zh-TW.json +++ b/web-nodejs/lang/zh-TW.json @@ -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": "資產清單", diff --git a/web-nodejs/lang/zh.json b/web-nodejs/lang/zh.json index b8c244b6..7c3eeb3c 100644 --- a/web-nodejs/lang/zh.json +++ b/web-nodejs/lang/zh.json @@ -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": "资产清单", diff --git a/web-nodejs/public/css/device-detail.css b/web-nodejs/public/css/device-detail.css index 6a10b5b4..794c4256 100644 --- a/web-nodejs/public/css/device-detail.css +++ b/web-nodejs/public/css/device-detail.css @@ -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; diff --git a/web-nodejs/public/css/devices.css b/web-nodejs/public/css/devices.css index 53a1034a..7817cbc6 100644 --- a/web-nodejs/public/css/devices.css +++ b/web-nodejs/public/css/devices.css @@ -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 { diff --git a/web-nodejs/public/js/deviceDetail.js b/web-nodejs/public/js/deviceDetail.js index d1ef2fae..5f4ee3ba 100644 --- a/web-nodejs/public/js/deviceDetail.js +++ b/web-nodejs/public/js/deviceDetail.js @@ -840,12 +840,13 @@ const DeviceDetail = (function () { html += `
sell ${_('device_detail.tags')}
+

${_('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.'}

${hasTags ? tags.map(t => ` ${Utils.escapeHtml(t)} - `).join('') diff --git a/web-nodejs/public/js/devices.js b/web-nodejs/public/js/devices.js index 87c84274..2f1c0c3a 100644 --- a/web-nodejs/public/js/devices.js +++ b/web-nodejs/public/js/devices.js @@ -141,6 +141,7 @@ initFilters(); initShowDeletedToggle(); initTagFilter(); + initManageTags(); initSorting(); initSync(); document.getElementById('add-remote-target-btn')?.addEventListener('click', () => showRemoteTargetModal()); @@ -401,6 +402,10 @@ edit ${_('actions.edit')} + + @@ -2347,7 +2365,9 @@ }; Modal.show({ - title: _('devices.edit_title'), + title: focusTags + ? (_('devices.manage_device_tags') || _('devices.edit_title')) + : _('devices.edit_title'), content: `
@@ -2369,8 +2389,9 @@
-
+
+

${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.')}

@@ -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 `
${Utils.escapeHtml(_('devices.no_tags') || 'No tags')}
`; + } + return `
    ${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 `
  • +
    + ${Utils.escapeHtml(tag)} + ${Utils.escapeHtml(metaParts.join(' · '))} +
    +
    + + +
    +
  • `; + }).join('')}
`; + }; + + 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: ` +

${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.')}

+ ${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 diff --git a/web-nodejs/routes/devices.routes.js b/web-nodejs/routes/devices.routes.js index 7e7c23fa..87b4d7e4 100644 --- a/web-nodejs/routes/devices.routes.js +++ b/web-nodejs/routes/devices.routes.js @@ -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); } diff --git a/web-nodejs/services/tagManagementService.js b/web-nodejs/services/tagManagementService.js new file mode 100644 index 00000000..b89eb812 --- /dev/null +++ b/web-nodejs/services/tagManagementService.js @@ -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 +}; diff --git a/web-nodejs/tests/devices.routes.test.js b/web-nodejs/tests/devices.routes.test.js index da881d06..73a4c6b9 100644 --- a/web-nodejs/tests/devices.routes.test.js +++ b/web-nodejs/tests/devices.routes.test.js @@ -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([ diff --git a/web-nodejs/tests/tagManagementService.test.js b/web-nodejs/tests/tagManagementService.test.js new file mode 100644 index 00000000..8e8ae308 --- /dev/null +++ b/web-nodejs/tests/tagManagementService.test.js @@ -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', []); + }); +}); diff --git a/web-nodejs/views/devices.ejs b/web-nodejs/views/devices.ejs index c59f9067..09badcf6 100644 --- a/web-nodejs/views/devices.ejs +++ b/web-nodejs/views/devices.ejs @@ -108,6 +108,9 @@
${_('devices.no_tags')}
+