diff --git a/README.md b/README.md index e78828f85..8b7fa44cb 100644 --- a/README.md +++ b/README.md @@ -367,9 +367,9 @@ You can run the Termix server on a VPS instead of inside your own network. If Te ## Telemetry -Termix sends a small anonymous ping once a day so I can see how many instances are running and which features get used. It contains a random instance ID, how many users and hosts you have, the app version, and which features (terminal, file manager, tunnels, docker, etc.) were used in the last 24 hours. It never contains usernames, hostnames, IP addresses, credentials, or anything else that identifies you or your servers. +Termix sends a small anonymous report once a day so I can see how many instances are running and which features get used. It contains a random instance ID, the app version, and how many users and hosts you have. It can also include your platform (OS, architecture, Node.js version, database type, and whether you run Docker, the desktop app or a plain server), how often each kind of tab was opened and how many SSH logins happened, and which built-in features are running. It never contains usernames, hostnames, IP addresses, credentials, or anything else that identifies you or your servers. -It is on by default. Turn it off in Admin Settings under General, or set `ENABLE_TELEMETRY=false` before you ever start Termix. +It is on by default. Turn it off, or choose what it includes, in Admin Settings under Usage Statistics, where you can also preview the exact report. Each user can leave out their own feature usage in their profile. Setting `ENABLE_TELEMETRY=false` turns it off and locks the switch.
diff --git a/docker/Dockerfile b/docker/Dockerfile index 03b9df7a4..a91347d93 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -114,8 +114,7 @@ ENV DATA_DIR=/app/data \ OPKSSH_SHA256_AMD64=$OPKSSH_SHA256_AMD64 \ OPKSSH_SHA256_ARM64=$OPKSSH_SHA256_ARM64 \ PORT=8080 \ - NODE_ENV=production \ - POSTHOG_API_KEY=phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd + NODE_ENV=production RUN apt-get update && apt-get install -y nginx gettext-base openssl ca-certificates gosu wget && \ update-ca-certificates && \ diff --git a/docker/bundled-plugins.json b/docker/bundled-plugins.json index f30efb015..2728154af 100644 --- a/docker/bundled-plugins.json +++ b/docker/bundled-plugins.json @@ -92,6 +92,10 @@ "id": "tailscale", "source": "workspace" }, + { + "id": "telemetry", + "source": "workspace" + }, { "id": "termix-identity", "source": "workspace" diff --git a/docs/readme/README-AR.md b/docs/readme/README-AR.md index dc65b8bc3..50dd79058 100644 --- a/docs/readme/README-AR.md +++ b/docs/readme/README-AR.md @@ -372,9 +372,9 @@ termix ssh 1 ## بيانات الاستخدام -يرسل Termix إشارة صغيرة مجهولة مرة واحدة يوميًا، لأعرف عدد النسخ العاملة والميزات المستخدمة فعلًا. تحتوي على معرّف عشوائي للنسخة، وعدد المستخدمين والمضيفات لديك، وإصدار التطبيق، والميزات التي استُخدمت خلال آخر 24 ساعة (الطرفية ومدير الملفات والأنفاق وdocker وغيرها). ولا تحتوي أبدًا على أسماء مستخدمين أو أسماء مضيفات أو عناوين IP أو بيانات دخول أو أي شيء يعرّف بك أو بخوادمك. +يرسل Termix تقريرًا صغيرًا مجهول الهوية مرة واحدة يوميًا حتى أعرف عدد النسخ العاملة والميزات المستخدمة. يحتوي على معرّف عشوائي للنسخة، وإصدار التطبيق، وعدد المستخدمين والأجهزة لديك. ويمكن أن يتضمن أيضًا منصتك (نظام التشغيل، والمعمارية، وإصدار Node.js، ونوع قاعدة البيانات، وهل تستخدم Docker أو تطبيق سطح المكتب أو خادمًا عاديًا)، وعدد مرات فتح كل نوع من التبويبات وعدد عمليات تسجيل الدخول عبر SSH، والميزات المدمجة التي تعمل. لا يحتوي أبدًا على أسماء المستخدمين أو أسماء الأجهزة أو عناوين IP أو بيانات الدخول أو أي شيء آخر يعرّف بك أو بخوادمك. -وهي مفعّلة افتراضيًا. يمكنك إيقافها من إعدادات المسؤول ضمن قسم عام، أو ضبط `ENABLE_TELEMETRY=false` قبل تشغيل Termix أصلًا. +وهي مفعّلة افتراضيًا. يمكنك إيقافها أو اختيار ما تتضمنه من إعدادات المسؤول ضمن قسم Usage Statistics، وهناك يمكنك أيضًا معاينة التقرير كما سيُرسل تمامًا. ويمكن لكل مستخدم استبعاد استخدامه الشخصي للميزات من ملفه الشخصي. ضبط `ENABLE_TELEMETRY=false` يوقفها ويقفل المفتاح.
diff --git a/docs/readme/README-CN.md b/docs/readme/README-CN.md index 0a70d4fcb..4a2f33d70 100644 --- a/docs/readme/README-CN.md +++ b/docs/readme/README-CN.md @@ -366,9 +366,9 @@ termix ssh 1 ## 遥测 -Termix 每天会发送一次匿名的小型统计信息,让我了解有多少实例在运行、哪些功能被用到。内容包括一个随机的实例 ID、你有多少用户和主机、应用版本,以及过去 24 小时内使用了哪些功能(终端、文件管理器、隧道、Docker 等)。它绝不包含用户名、主机名、IP 地址、凭据,或任何能识别你和你服务器的信息。 +Termix 每天会发送一次匿名的小型报告,让我知道有多少实例在运行、哪些功能被使用。报告包含一个随机的实例 ID、应用版本,以及你的用户数和主机数。它还可以包含你的平台信息(操作系统、架构、Node.js 版本、数据库类型,以及你是用 Docker、桌面应用还是普通服务器运行)、每种标签页被打开的次数和 SSH 登录次数,以及正在运行的内置功能。它绝不会包含用户名、主机名、IP 地址、凭据或任何能识别你或你的服务器的信息。 -该功能默认开启。你可以在管理设置的"通用"中关闭它,或者在启动 Termix 之前设置 `ENABLE_TELEMETRY=false`。 +该功能默认开启。你可以在管理设置的"Usage Statistics"中关闭它或选择要包含的内容,还可以在那里预览将要发送的完整报告。每个用户都可以在个人资料中排除自己的功能使用数据。设置 `ENABLE_TELEMETRY=false` 会关闭它并锁定开关。
diff --git a/docs/readme/README-DE.md b/docs/readme/README-DE.md index 54b03d9f2..1155c9a4b 100644 --- a/docs/readme/README-DE.md +++ b/docs/readme/README-DE.md @@ -367,9 +367,9 @@ Du kannst den Termix-Server auf einem VPS laufen lassen statt im eigenen Netz. L ## Telemetrie -Termix schickt einmal am Tag ein kleines anonymes Signal, damit ich sehen kann, wie viele Instanzen laufen und welche Funktionen genutzt werden. Enthalten sind eine zufällige Instanz-ID, wie viele Benutzer und Hosts du hast, die App-Version und welche Funktionen (Terminal, Dateimanager, Tunnel, Docker usw.) in den letzten 24 Stunden benutzt wurden. Niemals enthalten sind Benutzernamen, Hostnamen, IP-Adressen, Zugangsdaten oder irgendetwas anderes, das dich oder deine Server identifiziert. +Termix schickt einmal am Tag einen kleinen anonymen Bericht, damit ich sehe, wie viele Instanzen laufen und welche Funktionen genutzt werden. Er enthält eine zufällige Instanz-ID, die App-Version und wie viele Benutzer und Hosts du hast. Er kann außerdem deine Plattform enthalten (Betriebssystem, Architektur, Node.js-Version, Datenbanktyp und ob du Docker, die Desktop-App oder einen normalen Server nutzt), wie oft jede Art von Tab geöffnet wurde und wie viele SSH-Logins es gab, sowie welche eingebauten Funktionen laufen. Er enthält nie Benutzernamen, Hostnamen, IP-Adressen, Zugangsdaten oder sonst etwas, das dich oder deine Server erkennbar macht. -Es ist standardmäßig an. Schalte es in den Administrationseinstellungen unter Allgemein aus oder setze `ENABLE_TELEMETRY=false`, bevor du Termix überhaupt startest. +Es ist standardmäßig an. Schalte es in den Administrationseinstellungen unter Usage Statistics aus oder wähle dort, was gesendet wird. Dort kannst du dir auch den genauen Bericht vorab ansehen. Jeder Benutzer kann seine eigene Funktionsnutzung in seinem Profil ausschließen. `ENABLE_TELEMETRY=false` schaltet es aus und sperrt den Schalter.
diff --git a/docs/readme/README-ES.md b/docs/readme/README-ES.md index 7ebe5b5cc..d59a642f6 100644 --- a/docs/readme/README-ES.md +++ b/docs/readme/README-ES.md @@ -367,9 +367,9 @@ Puedes ejecutar el servidor de Termix en un VPS en lugar de dentro de tu propia ## Telemetría -Termix envía una vez al día un pequeño aviso anónimo para que pueda ver cuántas instancias hay funcionando y qué funciones se usan. Contiene un identificador de instancia aleatorio, cuántos usuarios y hosts tienes, la versión de la aplicación y qué funciones (terminal, gestor de archivos, túneles, docker, etc.) se han usado en las últimas 24 horas. Nunca contiene nombres de usuario, nombres de host, direcciones IP, credenciales ni nada que te identifique a ti o a tus servidores. +Termix envía una vez al día un pequeño informe anónimo para que pueda ver cuántas instancias hay funcionando y qué funciones se usan. Incluye un ID de instancia aleatorio, la versión de la app y cuántos usuarios y hosts tienes. También puede incluir tu plataforma (sistema operativo, arquitectura, versión de Node.js, tipo de base de datos y si usas Docker, la app de escritorio o un servidor normal), cuántas veces se abrió cada tipo de pestaña y cuántos inicios de sesión SSH hubo, y qué funciones integradas están activas. Nunca incluye nombres de usuario, nombres de host, direcciones IP, credenciales ni nada que te identifique a ti o a tus servidores. -Viene activado. Puedes desactivarlo en los ajustes de administración, en General, o poner `ENABLE_TELEMETRY=false` antes incluso de arrancar Termix. +Viene activado. Puedes desactivarlo o elegir qué incluye en los ajustes de administración, en Usage Statistics, donde también puedes ver el informe exacto antes de enviarlo. Cada usuario puede excluir su propio uso de funciones desde su perfil. Poner `ENABLE_TELEMETRY=false` lo desactiva y bloquea el interruptor.
diff --git a/docs/readme/README-FR.md b/docs/readme/README-FR.md index 0f45d9057..f99b2997e 100644 --- a/docs/readme/README-FR.md +++ b/docs/readme/README-FR.md @@ -365,9 +365,9 @@ Vous pouvez faire tourner le serveur Termix sur un VPS plutôt que dans votre pr ## Télémétrie -Termix envoie une fois par jour un petit signal anonyme, pour que je puisse voir combien d'instances tournent et quelles fonctionnalités servent vraiment. Il contient un identifiant d'instance aléatoire, le nombre d'utilisateurs et d'hôtes, la version de l'application et les fonctionnalités utilisées ces dernières 24 heures (terminal, gestionnaire de fichiers, tunnels, docker, etc.). Il ne contient jamais de noms d'utilisateur, de noms d'hôtes, d'adresses IP, d'identifiants ni quoi que ce soit qui puisse vous identifier, vous ou vos serveurs. +Termix envoie une fois par jour un petit rapport anonyme, pour que je puisse voir combien d'instances tournent et quelles fonctionnalités sont utilisées. Il contient un identifiant d'instance aléatoire, la version de l'application et le nombre d'utilisateurs et d'hôtes. Il peut aussi inclure votre plateforme (système, architecture, version de Node.js, type de base de données, et si vous utilisez Docker, l'application de bureau ou un simple serveur), combien de fois chaque type d'onglet a été ouvert et combien de connexions SSH ont eu lieu, ainsi que les fonctionnalités intégrées actives. Il ne contient jamais de noms d'utilisateur, de noms d'hôte, d'adresses IP, d'identifiants ni quoi que ce soit qui permette de vous identifier, vous ou vos serveurs. -C'est activé par défaut. Désactivez-le dans les paramètres d'administration, section Général, ou définissez `ENABLE_TELEMETRY=false` avant même de démarrer Termix. +C'est activé par défaut. Désactivez-le ou choisissez ce qu'il contient dans les paramètres d'administration, section Usage Statistics, où vous pouvez aussi voir le rapport exact avant l'envoi. Chaque utilisateur peut exclure sa propre utilisation dans son profil. Définir `ENABLE_TELEMETRY=false` le désactive et verrouille l'interrupteur.
diff --git a/docs/readme/README-HI.md b/docs/readme/README-HI.md index beb832bb1..aea110a31 100644 --- a/docs/readme/README-HI.md +++ b/docs/readme/README-HI.md @@ -372,9 +372,9 @@ termix ssh 1 ## टेलीमेट्री -Termix दिन में एक बार एक छोटा सा गुमनाम संकेत भेजता है, ताकि मुझे पता चले कि कितने इंस्टेंस चल रहे हैं और कौन सी सुविधाएँ सच में इस्तेमाल होती हैं। इसमें एक बेतरतीब इंस्टेंस आईडी, आपके पास कितने उपयोगकर्ता और होस्ट हैं, ऐप का संस्करण, और पिछले 24 घंटे में इस्तेमाल हुई सुविधाएँ (टर्मिनल, फ़ाइल मैनेजर, टनल, docker आदि) होती हैं। इसमें कभी भी उपयोगकर्ता नाम, होस्ट नाम, IP पते, क्रेडेंशियल या ऐसी कोई चीज़ नहीं होती जो आपकी या आपके सर्वर की पहचान बताए। +Termix दिन में एक बार एक छोटी गुमनाम रिपोर्ट भेजता है ताकि मैं देख सकूं कि कितने इंस्टेंस चल रहे हैं और कौन से फ़ीचर इस्तेमाल हो रहे हैं। इसमें एक रैंडम इंस्टेंस ID, ऐप का वर्शन, और आपके यूज़र्स और होस्ट्स की संख्या होती है। इसमें आपका प्लेटफ़ॉर्म (OS, आर्किटेक्चर, Node.js वर्शन, डेटाबेस का प्रकार, और आप Docker, डेस्कटॉप ऐप या सामान्य सर्वर इस्तेमाल करते हैं), हर तरह का टैब कितनी बार खोला गया और कितने SSH लॉगिन हुए, और कौन से बिल्ट-इन फ़ीचर चल रहे हैं, यह भी हो सकता है। इसमें कभी भी यूज़रनेम, होस्टनेम, IP पते, क्रेडेंशियल या ऐसी कोई चीज़ नहीं होती जिससे आपकी या आपके सर्वर की पहचान हो। -यह डिफ़ॉल्ट रूप से चालू रहता है। इसे एडमिन सेटिंग्स में सामान्य के अंतर्गत बंद करें, या Termix शुरू करने से पहले ही `ENABLE_TELEMETRY=false` सेट कर दें। +यह डिफ़ॉल्ट रूप से चालू रहता है। इसे एडमिन सेटिंग्स में Usage Statistics के अंतर्गत बंद करें या चुनें कि इसमें क्या शामिल हो, और वहीं भेजी जाने वाली रिपोर्ट पहले से देख भी सकते हैं। हर यूज़र अपनी प्रोफ़ाइल में अपना फ़ीचर इस्तेमाल हटा सकता है। `ENABLE_TELEMETRY=false` सेट करने से यह बंद हो जाता है और स्विच लॉक हो जाता है।
diff --git a/docs/readme/README-IT.md b/docs/readme/README-IT.md index 430bf70f5..d67e120af 100644 --- a/docs/readme/README-IT.md +++ b/docs/readme/README-IT.md @@ -367,9 +367,9 @@ Puoi far girare il server Termix su un VPS invece che dentro la tua rete. Se Ter ## Telemetria -Termix invia una volta al giorno un piccolo segnale anonimo, così posso vedere quante istanze sono attive e quali funzionalità vengono usate davvero. Contiene un ID istanza casuale, quanti utenti e host hai, la versione dell'app e quali funzionalità (terminale, gestore file, tunnel, docker, ecc.) sono state usate nelle ultime 24 ore. Non contiene mai nomi utente, nomi host, indirizzi IP, credenziali o qualsiasi altra cosa che identifichi te o i tuoi server. +Termix invia una volta al giorno un piccolo report anonimo, così posso vedere quante istanze sono attive e quali funzioni vengono usate. Contiene un ID di istanza casuale, la versione dell'app e quanti utenti e host hai. Può includere anche la tua piattaforma (sistema operativo, architettura, versione di Node.js, tipo di database e se usi Docker, l'app desktop o un server normale), quante volte è stato aperto ogni tipo di scheda e quanti login SSH ci sono stati, e quali funzioni integrate sono attive. Non contiene mai nomi utente, nomi host, indirizzi IP, credenziali o altro che identifichi te o i tuoi server. -È attivo di base. Puoi spegnerlo nelle impostazioni di amministrazione, sezione Generale, oppure impostare `ENABLE_TELEMETRY=false` prima ancora di avviare Termix. +È attivo di base. Puoi spegnerlo o scegliere cosa include nelle impostazioni di amministrazione, sezione Usage Statistics, dove puoi anche vedere il report esatto prima dell'invio. Ogni utente può escludere il proprio uso delle funzioni dal suo profilo. Impostare `ENABLE_TELEMETRY=false` lo spegne e blocca l'interruttore.
diff --git a/docs/readme/README-JA.md b/docs/readme/README-JA.md index 4ffbc7298..c0ec71279 100644 --- a/docs/readme/README-JA.md +++ b/docs/readme/README-JA.md @@ -367,9 +367,9 @@ Termix のサーバーは自分のネットワーク内ではなく、VPS で動 ## テレメトリー -Termix は 1 日 1 回、匿名の小さなデータを送ります。どれくらいのインスタンスが動いていて、どの機能が使われているかを把握するためのものです。含まれるのはランダムなインスタンス ID、ユーザーとホストの数、アプリのバージョン、直近 24 時間に使われた機能(ターミナル、ファイルマネージャー、トンネル、Docker など)だけです。ユーザー名、ホスト名、IP アドレス、認証情報など、あなたやサーバーを特定できるものは一切含まれません。 +Termix は 1 日に 1 回、小さな匿名レポートを送信します。これで、どれだけのインスタンスが動いていて、どの機能が使われているかを把握しています。内容はランダムなインスタンス ID、アプリのバージョン、ユーザー数とホスト数です。さらに、プラットフォーム(OS、アーキテクチャ、Node.js のバージョン、データベースの種類、Docker・デスクトップアプリ・通常のサーバーのどれで動いているか)、各種タブが開かれた回数と SSH ログイン数、動作中の組み込み機能を含めることもできます。ユーザー名、ホスト名、IP アドレス、認証情報など、あなたやサーバーを特定できる情報は一切含みません。 -初期状態では有効です。管理設定の「一般」から止められますし、Termix を起動する前に `ENABLE_TELEMETRY=false` を設定しておくこともできます。 +初期状態では有効です。管理設定の「Usage Statistics」から止めたり、含める内容を選んだりでき、送信されるレポートをそのまま事前に確認することもできます。各ユーザーはプロフィールで自分の機能利用データを除外できます。`ENABLE_TELEMETRY=false` を設定すると無効になり、スイッチもロックされます。
diff --git a/docs/readme/README-KO.md b/docs/readme/README-KO.md index 4dd2fee68..e359b0746 100644 --- a/docs/readme/README-KO.md +++ b/docs/readme/README-KO.md @@ -365,9 +365,9 @@ Termix 서버는 집 안 네트워크가 아니라 VPS에서 돌릴 수도 있 ## 텔레메트리 -Termix는 하루에 한 번 익명의 작은 데이터를 보냅니다. 인스턴스가 얼마나 돌아가는지, 어떤 기능이 쓰이는지 파악하기 위한 것입니다. 여기에는 무작위 인스턴스 ID, 사용자와 호스트 수, 앱 버전, 최근 24시간 동안 쓴 기능(터미널, 파일 관리자, 터널, Docker 등)만 들어갑니다. 사용자 이름과 호스트 이름, IP 주소, 자격 증명처럼 나나 내 서버를 알아볼 수 있는 것은 전혀 담기지 않습니다. +Termix는 하루에 한 번 작은 익명 보고서를 보내서 몇 개의 인스턴스가 실행 중이고 어떤 기능이 쓰이는지 알 수 있게 합니다. 보고서에는 무작위 인스턴스 ID, 앱 버전, 사용자 수와 호스트 수가 들어 있습니다. 또한 플랫폼 정보(OS, 아키텍처, Node.js 버전, 데이터베이스 종류, Docker·데스크톱 앱·일반 서버 중 무엇으로 실행하는지), 탭 종류별로 열린 횟수와 SSH 로그인 수, 실행 중인 내장 기능도 포함될 수 있습니다. 사용자 이름, 호스트 이름, IP 주소, 자격 증명 등 당신이나 서버를 식별할 수 있는 정보는 절대 포함되지 않습니다. -기본으로 켜져 있습니다. 관리 설정의 일반에서 끄거나, Termix를 시작하기 전에 `ENABLE_TELEMETRY=false`를 지정하면 됩니다. +기본으로 켜져 있습니다. 관리 설정의 Usage Statistics에서 끄거나 포함할 내용을 고를 수 있고, 보낼 보고서를 미리 그대로 볼 수도 있습니다. 각 사용자는 자신의 프로필에서 본인의 기능 사용 기록을 뺄 수 있습니다. `ENABLE_TELEMETRY=false`를 지정하면 꺼지고 스위치가 잠깁니다.
diff --git a/docs/readme/README-PT.md b/docs/readme/README-PT.md index fadc2a749..f76761a48 100644 --- a/docs/readme/README-PT.md +++ b/docs/readme/README-PT.md @@ -363,9 +363,9 @@ A [GINERNET](https://docs.termix.site/install/ginernet) patrocina o Termix, e a ## Telemetria -O Termix manda uma vez por dia um pequeno sinal anônimo, para eu saber quantas instâncias estão rodando e quais funcionalidades são usadas. Ele contém um ID de instância aleatório, quantos usuários e hosts tu tens, a versão do aplicativo e quais funcionalidades (terminal, gerenciador de arquivos, túneis, docker, etc.) foram usadas nas últimas 24 horas. Nunca contém nomes de usuário, nomes de host, endereços IP, credenciais ou qualquer coisa que identifique ti ou os teus servidores. +O Termix envia uma vez por dia um pequeno relatório anónimo, para eu ver quantas instâncias estão a correr e que funcionalidades são usadas. Contém um ID de instância aleatório, a versão da app e quantos utilizadores e hosts tens. Também pode incluir a tua plataforma (sistema operativo, arquitetura, versão do Node.js, tipo de base de dados e se usas Docker, a app de desktop ou um servidor normal), quantas vezes cada tipo de separador foi aberto e quantos logins SSH houve, e que funcionalidades integradas estão ativas. Nunca contém nomes de utilizador, nomes de host, endereços IP, credenciais ou qualquer outra coisa que te identifique a ti ou aos teus servidores. -Vem ligado por padrão. Podes desligar nas configurações de administração, em Geral, ou definir `ENABLE_TELEMETRY=false` antes mesmo de iniciar o Termix. +Vem ligado por padrão. Podes desligar ou escolher o que inclui nas configurações de administração, em Usage Statistics, onde também podes ver o relatório exato antes de ser enviado. Cada utilizador pode excluir o seu próprio uso de funcionalidades no perfil. Definir `ENABLE_TELEMETRY=false` desliga-o e bloqueia o interruptor.
diff --git a/docs/readme/README-RU.md b/docs/readme/README-RU.md index 06296821a..6be533190 100644 --- a/docs/readme/README-RU.md +++ b/docs/readme/README-RU.md @@ -363,9 +363,9 @@ termix ssh 1 ## Телеметрия -Termix раз в сутки отправляет небольшой анонимный сигнал, чтобы я понимал, сколько установок работает и какими возможностями действительно пользуются. В нём есть случайный идентификатор установки, число пользователей и хостов, версия приложения и то, какие возможности (терминал, файловый менеджер, туннели, docker и прочее) использовались за последние 24 часа. В нём никогда нет имён пользователей, имён хостов, IP-адресов, учётных данных и ничего другого, что указывало бы на вас или ваши серверы. +Раз в день Termix отправляет небольшой анонимный отчёт, чтобы я видел, сколько установок работает и какими функциями пользуются. В нём есть случайный ID установки, версия приложения и количество пользователей и хостов. Он также может включать вашу платформу (ОС, архитектуру, версию Node.js, тип базы данных и то, как запущен Termix: в Docker, как настольное приложение или как обычный сервер), сколько раз открывались вкладки каждого типа и сколько было входов по SSH, а также какие встроенные функции работают. В нём никогда нет имён пользователей, имён хостов, IP-адресов, учётных данных или чего-либо ещё, что может указать на вас или ваши серверы. -По умолчанию он включён. Выключить можно в настройках администратора в разделе «Общие» или задать `ENABLE_TELEMETRY=false` ещё до первого запуска Termix. +По умолчанию он включён. Выключить его или выбрать, что в него входит, можно в настройках администратора в разделе Usage Statistics, там же можно заранее посмотреть точный отчёт. Каждый пользователь может исключить своё использование функций в своём профиле. `ENABLE_TELEMETRY=false` выключает его и блокирует переключатель.
diff --git a/docs/readme/README-TR.md b/docs/readme/README-TR.md index 35a118987..3b10c0bb1 100644 --- a/docs/readme/README-TR.md +++ b/docs/readme/README-TR.md @@ -363,9 +363,9 @@ Termix sunucusunu kendi ağınız yerine bir VPS üzerinde de çalıştırabilir ## Telemetri -Termix günde bir kez küçük ve anonim bir sinyal gönderir; böylece kaç kurulumun çalıştığını ve hangi özelliklerin kullanıldığını görebiliyorum. İçinde rastgele bir kurulum kimliği, kaç kullanıcı ve sunucunuz olduğu, uygulama sürümü ve son 24 saatte hangi özelliklerin (terminal, dosya yöneticisi, tüneller, docker vb.) kullanıldığı yer alır. İçinde asla kullanıcı adları, sunucu adları, IP adresleri, kimlik bilgileri ya da sizi veya sunucularınızı tanımlayan başka bir şey bulunmaz. +Termix, kaç kurulumun çalıştığını ve hangi özelliklerin kullanıldığını görebilmem için günde bir kez küçük ve anonim bir rapor gönderir. Raporda rastgele bir kurulum kimliği, uygulama sürümü ve kaç kullanıcı ile sunucunuz olduğu bulunur. Ayrıca platformunuzu (işletim sistemi, mimari, Node.js sürümü, veritabanı türü ve Docker, masaüstü uygulaması ya da normal bir sunucu kullanıp kullanmadığınız), her sekme türünün kaç kez açıldığını ve kaç SSH girişi yapıldığını, hangi yerleşik özelliklerin çalıştığını da içerebilir. Kullanıcı adları, sunucu adları, IP adresleri, kimlik bilgileri ya da sizi veya sunucularınızı tanıtacak hiçbir şey asla gönderilmez. -Varsayılan olarak açıktır. Yönetici ayarlarında Genel bölümünden kapatabilir ya da Termix'i hiç başlatmadan önce `ENABLE_TELEMETRY=false` tanımlayabilirsiniz. +Varsayılan olarak açıktır. Yönetici ayarlarında Usage Statistics bölümünden kapatabilir veya neleri içereceğini seçebilir, gönderilecek raporu olduğu gibi önizleyebilirsiniz. Her kullanıcı kendi özellik kullanımını profilinden hariç tutabilir. `ENABLE_TELEMETRY=false` tanımlamak onu kapatır ve anahtarı kilitler.
diff --git a/docs/readme/README-VI.md b/docs/readme/README-VI.md index c16803e69..beb822aad 100644 --- a/docs/readme/README-VI.md +++ b/docs/readme/README-VI.md @@ -363,9 +363,9 @@ Bạn có thể chạy máy chủ Termix trên VPS thay vì trong mạng của m ## Dữ liệu sử dụng -Termix gửi một tín hiệu nhỏ ẩn danh mỗi ngày một lần, để tôi biết có bao nhiêu bản đang chạy và tính năng nào thực sự được dùng. Nó gồm một mã bản cài ngẫu nhiên, số người dùng và máy chủ bạn có, phiên bản ứng dụng, và những tính năng (terminal, trình quản lý tệp, tunnel, docker, v.v.) đã dùng trong 24 giờ qua. Nó không bao giờ chứa tên người dùng, tên máy chủ, địa chỉ IP, thông tin đăng nhập hay bất cứ thứ gì nhận dạng bạn hoặc máy chủ của bạn. +Mỗi ngày một lần, Termix gửi một báo cáo ẩn danh nhỏ để tôi biết có bao nhiêu bản cài đặt đang chạy và tính năng nào được dùng. Báo cáo gồm một ID ngẫu nhiên của bản cài đặt, phiên bản ứng dụng, và số người dùng cùng số máy chủ của bạn. Nó cũng có thể gồm nền tảng của bạn (hệ điều hành, kiến trúc, phiên bản Node.js, loại cơ sở dữ liệu, và bạn chạy bằng Docker, ứng dụng máy tính hay máy chủ thường), số lần mỗi loại tab được mở và số lần đăng nhập SSH, cùng các tính năng tích hợp đang chạy. Nó không bao giờ chứa tên người dùng, tên máy chủ, địa chỉ IP, thông tin đăng nhập hay bất cứ thứ gì có thể nhận ra bạn hoặc máy chủ của bạn. -Mặc định là bật. Bạn tắt nó trong phần Cài đặt quản trị, mục Chung, hoặc đặt `ENABLE_TELEMETRY=false` trước cả khi khởi động Termix. +Mặc định là bật. Bạn có thể tắt hoặc chọn nội dung gửi đi trong phần Cài đặt quản trị, mục Usage Statistics, và xem trước chính xác báo cáo sẽ được gửi. Mỗi người dùng có thể loại phần sử dụng tính năng của riêng mình trong hồ sơ. Đặt `ENABLE_TELEMETRY=false` sẽ tắt nó và khóa công tắc.
diff --git a/package-lock.json b/package-lock.json index 00e24da3b..e6f1bb7f1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7021,6 +7021,10 @@ "resolved": "plugins/tailscale", "link": true }, + "node_modules/@termix-plugin/telemetry": { + "resolved": "plugins/telemetry", + "link": true + }, "node_modules/@termix-plugin/termix-identity": { "resolved": "plugins/termix-identity", "link": true @@ -19277,6 +19281,29 @@ "ssh2": "^1.17.0" } }, + "plugins/telemetry": { + "name": "@termix-plugin/telemetry", + "version": "1.0.0", + "dependencies": { + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" + } + }, "plugins/termix-identity": { "name": "@termix-plugin/termix-identity", "version": "1.0.0", diff --git a/packages/plugin-sdk/src/backend.ts b/packages/plugin-sdk/src/backend.ts index ecbe2e4cc..d012d966b 100644 --- a/packages/plugin-sdk/src/backend.ts +++ b/packages/plugin-sdk/src/backend.ts @@ -1958,6 +1958,20 @@ export interface PluginSystem { registerTlsRenewer: () => Promise<() => void>; } +/** One installed plugin as ctx.plugins.list reports it. */ +export interface PluginSummary { + id: string; + version: string; + source: "bundled" | "user"; + /** "active" when running; anything else means it is not. */ + state: string; +} + +/** The installed plugins. Needs plugins:read. */ +export interface PluginPlugins { + list: () => Promise; +} + export interface PluginContext { readonly pluginId: string; readonly manifest: PluginManifest; @@ -1999,6 +2013,8 @@ export interface PluginContext { readonly process: PluginProcess; /** The server certificate. Needs system:tls. */ readonly system: PluginSystem; + /** The installed plugins and whether each runs. Needs plugins:read. */ + readonly plugins: PluginPlugins; /** * Runs `fn` with `userId` as the acting user, for background work that has diff --git a/packages/plugin-sdk/src/capabilities.ts b/packages/plugin-sdk/src/capabilities.ts index 662278056..58f7cb93e 100644 --- a/packages/plugin-sdk/src/capabilities.ts +++ b/packages/plugin-sdk/src/capabilities.ts @@ -68,6 +68,7 @@ export const CAPABILITY_CATALOG: readonly CapabilityInfo[] = [ entry("files:own", "low"), entry("secrets:own", "low"), entry("settings:read-core", "low"), + entry("plugins:read", "low"), entry("ui:surface", "low"), ] as const; diff --git a/packages/plugin-sdk/src/testing.ts b/packages/plugin-sdk/src/testing.ts index 67b041562..3f5daf2d4 100644 --- a/packages/plugin-sdk/src/testing.ts +++ b/packages/plugin-sdk/src/testing.ts @@ -78,6 +78,7 @@ import type { PluginBinarySpec, PluginTlsCertificateInfo, PluginTlsStatus, + PluginSummary, PluginKeyboardInteractiveHandler, PluginProcessHandle, PluginProcessOptions, @@ -152,6 +153,8 @@ export interface FakeContextOptions { * from registerTlsRenewer calls. */ tlsStatus?: Omit; + /** What ctx.plugins.list answers. Default: only this plugin, active. */ + installedPlugins?: PluginSummary[]; /** * Checks a ctx.system.writeTlsCertificate call. Throw to reject the pair. * Without it every pair is accepted and described with placeholder info. @@ -1233,6 +1236,18 @@ export function createFakeContext( }, }, + plugins: { + list: async () => + options.installedPlugins ?? [ + { + id: pluginId, + version: "1.0.0", + source: "bundled", + state: "active", + }, + ], + }, + system: { tlsStatus: async () => { tls.calls.push("tlsStatus"); @@ -1416,6 +1431,8 @@ export interface MockContextOptions { /** See FakeContextOptions. */ tlsStatus?: FakeContextOptions["tlsStatus"]; /** See FakeContextOptions. */ + installedPlugins?: FakeContextOptions["installedPlugins"]; + /** See FakeContextOptions. */ validateTls?: FakeContextOptions["validateTls"]; /** See FakeContextOptions. */ refuseEnrollment?: string; @@ -1441,7 +1458,7 @@ export interface MockPluginContext extends FakePluginContext { * topic outside the plugin's own namespace, settings:read-core on * ctx.settings.readCore, ssh:connect plus credentials:use on ctx.ssh, * notify:send on ctx.notify (notify:hub on serve), network:outbound on ctx.fetch, process:spawn on - * ctx.process, system:tls on ctx.system, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately + * ctx.process, system:tls on ctx.system, plugins:read on ctx.plugins, and auth:provide on ctx.auth. Reading a plugin's own settings is deliberately * ungated. As the SDK grows a member, add its gate here in the same shape. */ export function createMockCtx( @@ -1472,6 +1489,7 @@ export function createMockCtx( fetch: options.fetch, process: options.process, tlsStatus: options.tlsStatus, + installedPlugins: options.installedPlugins, validateTls: options.validateTls, refuseEnrollment: options.refuseEnrollment, baseUrl: options.baseUrl, @@ -1838,6 +1856,13 @@ export function createMockCtx( }, }, + plugins: { + list: async () => { + require("plugins:read"); + return ctx.plugins.list(); + }, + }, + system: { tlsStatus: async () => { require("system:tls"); diff --git a/plugins/telemetry/CHANGELOG.md b/plugins/telemetry/CHANGELOG.md new file mode 100644 index 000000000..dc39a5321 --- /dev/null +++ b/plugins/telemetry/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## 1.0.0 + +First release, bundled with Termix 2.9.0. + +Moves the daily anonymous usage report out of Termix core. Adds platform info, feature usage counts and the list of running features, each with its own switch, a per-user opt-out, a preview of the exact report and a button to send it now. diff --git a/plugins/telemetry/README.md b/plugins/telemetry/README.md new file mode 100644 index 000000000..effefb226 --- /dev/null +++ b/plugins/telemetry/README.md @@ -0,0 +1,29 @@ +# Usage Statistics + +Sends a small anonymous report once a day so the Termix developers can see how many instances run and which features get used. + +## What is sent + +Always, while it is on: + +- A random instance ID, the Termix version, and how many users and hosts exist. + +Each of these has its own switch in Admin Settings > Usage Statistics: + +- Platform info: operating system, CPU architecture, Node.js version, database type, and whether Termix runs in Docker, the desktop app or as a plain server. +- Feature usage: how many times each kind of tab was opened, plus SSH logins and host connections, since the last report. +- Installed features: the ids of the running built-in features. + +It never sends usernames, hostnames, IP addresses, credentials or anything else that identifies you or your servers. Admins can preview the exact report before it is sent. + +## Turning it off + +- Admin Settings > Usage Statistics > Share anonymous usage statistics. +- Each user can leave their own feature usage out in User Profile > Usage Statistics. +- `ENABLE_TELEMETRY=false` turns it off and locks the switch. `ENABLE_TELEMETRY=true` locks it on. + +## Environment + +- `ENABLE_TELEMETRY`: `true` or `false` overrides the admin switch. +- `POSTHOG_API_KEY`: the PostHog project key. Defaults to the Termix project. +- `POSTHOG_HOST`: the PostHog host. Defaults to `https://us.i.posthog.com`. diff --git a/plugins/telemetry/locales/en.json b/plugins/telemetry/locales/en.json new file mode 100644 index 000000000..83a0016c0 --- /dev/null +++ b/plugins/telemetry/locales/en.json @@ -0,0 +1,58 @@ +{ + "permissions": { + "manage": { + "title": "Manage usage statistics", + "description": "See what usage statistics are sent, send them now and reset the instance ID." + } + }, + "settings": { + "enabled": { + "label": "Share anonymous usage statistics", + "description": "Sends a small anonymous summary once a day to help improve Termix. It never includes usernames, hostnames, IP addresses or credentials." + }, + "includePlatform": { + "label": "Include platform info", + "description": "The operating system, CPU architecture, Node.js version, database type and how Termix is installed (Docker, desktop app or server)." + }, + "includeFeatureUsage": { + "label": "Include feature usage", + "description": "How many times each kind of tab was opened and how many SSH logins happened since the last report. Users can turn off their own part in their profile." + }, + "includePlugins": { + "label": "Include installed features", + "description": "Which built-in features are installed and running." + }, + "instanceId": { + "label": "Instance ID" + }, + "shareFeatureUsage": { + "label": "Include my feature usage", + "description": "Counts which kinds of tabs you open in the anonymous usage statistics. Nothing about you or your hosts is sent." + } + }, + "status": { + "title": "Status", + "on": "Sharing usage statistics once a day", + "off": "Not sharing usage statistics", + "lockedOn": "Turned on by the ENABLE_TELEMETRY environment variable. Change it there.", + "lockedOff": "Turned off by the ENABLE_TELEMETRY environment variable. Change it there.", + "instanceId": "Instance ID: {{id}}", + "noInstanceId": "No instance ID yet", + "lastSent": "Last sent {{date}}", + "neverSent": "Not sent yet", + "nextSend": "Next report due {{date}}", + "lastError": "Last attempt failed: {{error}}", + "preview": "Preview report", + "sendNow": "Send now", + "sending": "Sending...", + "sent": "Usage statistics sent", + "notSent": "Usage statistics are turned off, nothing was sent", + "resetId": "Reset instance ID", + "resetIdConfirm": "Reset the instance ID? Future reports will look like a new install.", + "resetIdDone": "Instance ID reset", + "previewTitle": "Next report", + "previewDescription": "This is exactly what would be sent right now.", + "close": "Close", + "loadFailed": "Could not load the usage statistics status" + } +} diff --git a/plugins/telemetry/manifest.json b/plugins/telemetry/manifest.json new file mode 100644 index 000000000..c20ec8236 --- /dev/null +++ b/plugins/telemetry/manifest.json @@ -0,0 +1,97 @@ +{ + "id": "telemetry", + "name": "Usage Statistics", + "version": "1.0.0", + "description": "Sends an anonymous daily summary of how Termix is used, to help decide what to work on next.", + "author": { + "name": "Termix" + }, + "license": "MIT", + "repository": "https://github.com/Termix-SSH/Termix", + "category": "Monitoring", + "icon": "ChartNoAxesColumn", + "engine": { + "termix": ">=2.9.0", + "api": "1" + }, + "capabilities": [ + "db:core-refs", + "network:outbound", + "network:serve", + "events:core", + "db:own", + "kv:own", + "plugins:read", + "ui:surface" + ], + "contributes": { + "permissions": [ + { + "name": "manage", + "titleKey": "permissions.manage.title", + "descriptionKey": "permissions.manage.description", + "defaultRoles": ["admin"] + } + ], + "settings": { + "admin": [ + { + "key": "status", + "type": "custom", + "component": "status", + "permission": "manage" + }, + { + "key": "enabled", + "type": "boolean", + "labelKey": "settings.enabled.label", + "descriptionKey": "settings.enabled.description", + "default": true + }, + { + "key": "includePlatform", + "type": "boolean", + "labelKey": "settings.includePlatform.label", + "descriptionKey": "settings.includePlatform.description", + "requires": "enabled", + "default": true + }, + { + "key": "includeFeatureUsage", + "type": "boolean", + "labelKey": "settings.includeFeatureUsage.label", + "descriptionKey": "settings.includeFeatureUsage.description", + "requires": "enabled", + "default": true + }, + { + "key": "includePlugins", + "type": "boolean", + "labelKey": "settings.includePlugins.label", + "descriptionKey": "settings.includePlugins.description", + "requires": "enabled", + "default": true + }, + { + "key": "instanceId", + "type": "string", + "labelKey": "settings.instanceId.label", + "hidden": true, + "default": "" + } + ], + "user": [ + { + "key": "shareFeatureUsage", + "type": "boolean", + "labelKey": "settings.shareFeatureUsage.label", + "descriptionKey": "settings.shareFeatureUsage.description", + "default": true + } + ] + } + }, + "locales": "locales", + "platforms": ["linux", "win32", "darwin"], + "desktop": "server" +} diff --git a/plugins/telemetry/package.json b/plugins/telemetry/package.json new file mode 100644 index 000000000..a228cdb63 --- /dev/null +++ b/plugins/telemetry/package.json @@ -0,0 +1,31 @@ +{ + "name": "@termix-plugin/telemetry", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "build": "termix-plugin build", + "test": "termix-plugin test", + "typecheck": "tsc -p .", + "validate": "termix-plugin validate" + }, + "dependencies": { + "drizzle-orm": "^0.45.2" + }, + "devDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "@testing-library/dom": "^10.4.2", + "@testing-library/react": "^16.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "jsdom": "^30.0.1", + "vitest": "^4.1.11" + }, + "peerDependencies": { + "@termix/plugin-sdk": "^1.0.0", + "express": "^5.2.1", + "react": "^19.2.8", + "sonner": "^2.0.8" + } +} diff --git a/plugins/telemetry/src/backend/collect.ts b/plugins/telemetry/src/backend/collect.ts new file mode 100644 index 000000000..c3da23e1d --- /dev/null +++ b/plugins/telemetry/src/backend/collect.ts @@ -0,0 +1,140 @@ +import fs from "node:fs"; +import { randomUUID } from "node:crypto"; +import { sql } from "drizzle-orm"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { envOverride } from "./config.js"; +import { usageProperty, type UsageCounts, type UsageStore } from "./usage.js"; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +// ctx.db hands back the server's own drizzle handle and tables, untyped. +type Table = any; +type Drizzle = any; +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export interface TelemetrySettings { + enabled: boolean; + includePlatform: boolean; + includeFeatureUsage: boolean; + includePlugins: boolean; + instanceId: string; +} + +export interface TelemetryPayload { + event: "instance_heartbeat"; + distinct_id: string; + properties: Record; +} + +type Env = Record; + +export async function readSettings( + ctx: PluginContext, +): Promise { + const all = await ctx.settings.getAll("admin"); + return { + enabled: all.enabled !== false, + includePlatform: all.includePlatform !== false, + includeFeatureUsage: all.includeFeatureUsage !== false, + includePlugins: all.includePlugins !== false, + instanceId: typeof all.instanceId === "string" ? all.instanceId : "", + }; +} + +/** The env variable decides when set, the admin switch otherwise. */ +export function isEnabled(settings: TelemetrySettings, env?: Env): boolean { + return envOverride(env) ?? settings.enabled; +} + +/** + * Made on first use rather than on activate, so the boot copy of a 2.8 + * instance id is never shadowed by a new one. + */ +export async function ensureInstanceId(ctx: PluginContext): Promise { + const existing = await ctx.settings.get("instanceId"); + if (existing) return existing; + const id = randomUUID(); + await ctx.settings.set("instanceId", id); + return id; +} + +export function detectDeployment( + env: Env = process.env, + exists: (path: string) => boolean = fs.existsSync, +): "desktop" | "docker" | "server" { + if (env.ELECTRON_EMBEDDED === "true") return "desktop"; + if (exists("/.dockerenv")) return "docker"; + return "server"; +} + +async function countRows(ctx: PluginContext): Promise<{ + userCount: number; + hostCount: number; +}> { + const { users, hosts } = await ctx.db.refs<{ users: Table; hosts: Table }>(); + const db = await ctx.db.client(); + const count = async (table: Table) => { + const rows = await db.select({ count: sql`count(*)` }).from(table); + return Number(rows[0]?.count ?? 0); + }; + const [userCount, hostCount] = await Promise.all([ + count(users), + count(hosts), + ]); + return { userCount, hostCount }; +} + +export interface BuiltPayload { + payload: TelemetryPayload; + /** The counts the payload carries, to subtract once it is sent. */ + usage: UsageCounts; +} + +export async function buildPayload( + ctx: PluginContext, + usage: UsageStore, + env: Env = process.env, +): Promise { + const settings = await readSettings(ctx); + const distinctId = await ensureInstanceId(ctx); + const { userCount, hostCount } = await countRows(ctx); + + const properties: Record = { + version: env.VERSION || "unknown", + user_count: userCount, + host_count: hostCount, + }; + + if (settings.includePlatform) { + properties.os = process.platform; + properties.arch = process.arch; + properties.node_version = process.versions.node; + properties.db_dialect = ctx.db.dialect; + properties.deployment = detectDeployment(env); + } + + let carried: UsageCounts = {}; + if (settings.includeFeatureUsage) { + carried = await usage.read(); + for (const [name, count] of Object.entries(carried)) { + properties[usageProperty(name)] = count; + } + } + + if (settings.includePlugins) { + const running = (await ctx.plugins.list()) + .filter((plugin) => plugin.state === "active") + .map((plugin) => plugin.id) + .sort(); + properties.plugins_enabled = running; + properties.plugin_count = running.length; + } + + return { + payload: { + event: "instance_heartbeat", + distinct_id: distinctId, + properties, + }, + usage: carried, + }; +} diff --git a/plugins/telemetry/src/backend/config.ts b/plugins/telemetry/src/backend/config.ts new file mode 100644 index 000000000..9af9b307e --- /dev/null +++ b/plugins/telemetry/src/backend/config.ts @@ -0,0 +1,26 @@ +/** Public project key. Only lets a client send events, never read them. */ +export const DEFAULT_POSTHOG_API_KEY = + "phc_xM8UznirsFxUkGE68gH4jzeqevf4kh76wGw7Ci7hH2dd"; +export const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com"; + +type Env = Record; + +/** ENABLE_TELEMETRY wins over the admin setting when set. */ +export function envOverride(env: Env = process.env): boolean | null { + const value = env.ENABLE_TELEMETRY?.trim().toLowerCase(); + if (!value) return null; + return value === "true"; +} + +export function posthogConfig(env: Env = process.env): { + apiKey: string; + host: string; +} { + return { + apiKey: env.POSTHOG_API_KEY?.trim() || DEFAULT_POSTHOG_API_KEY, + host: (env.POSTHOG_HOST?.trim() || DEFAULT_POSTHOG_HOST).replace( + /\/+$/, + "", + ), + }; +} diff --git a/plugins/telemetry/src/backend/index.ts b/plugins/telemetry/src/backend/index.ts new file mode 100644 index 000000000..edca3ac28 --- /dev/null +++ b/plugins/telemetry/src/backend/index.ts @@ -0,0 +1,65 @@ +import type { Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { envOverride } from "./config.js"; +import { isEnabled, readSettings } from "./collect.js"; +import { CHECK_INTERVAL_MS, createReporter } from "./reporter.js"; +import { registerRoutes } from "./routes.js"; +import { createUsageStore } from "./usage.js"; + +/** First check a few minutes after boot, once core's boot copies ran. */ +const FIRST_CHECK_DELAY_MS = 3 * 60 * 1000; + +type Env = Record; + +export async function activateWith(ctx: PluginContext, env: Env) { + const usage = createUsageStore(ctx.kv); + const reporter = createReporter(ctx, usage, env); + + const check = async () => { + try { + await reporter.send(); + } catch { + // Logged by the reporter; the next check tries again. + } + }; + ctx.schedule.after(FIRST_CHECK_DELAY_MS, check); + ctx.schedule.every(CHECK_INTERVAL_MS, check); + + const countEvent = (feature: string) => async () => { + const settings = await readSettings(ctx); + if (isEnabled(settings, env) && settings.includeFeatureUsage) { + await usage.add({ [feature]: 1 }); + } + }; + const onLogin = countEvent("ssh_login"); + const onConnected = countEvent("host_connected"); + ctx.events.on("host.login", () => void onLogin().catch(() => undefined)); + ctx.events.on("host.session.status", (payload) => { + if ((payload as { online?: boolean } | null)?.online) { + void onConnected().catch(() => undefined); + } + }); + + ctx.settings.onValidate("admin", async (values) => { + const locked = envOverride(env); + if (locked === null || values.enabled === undefined) return; + const stored = (await ctx.settings.get("enabled")) !== false; + if (values.enabled !== stored) { + return { + enabled: + "This is set by the ENABLE_TELEMETRY environment variable and cannot be changed here.", + }; + } + }); + + registerRoutes(ctx, ctx.http.router(), reporter, usage, env); + return { reporter, usage }; +} + +export async function activate(ctx: PluginContext) { + await activateWith(ctx, process.env); +} + +export async function deactivate() { + // Everything above was registered through ctx and is disposed by core. +} diff --git a/plugins/telemetry/src/backend/reporter.ts b/plugins/telemetry/src/backend/reporter.ts new file mode 100644 index 000000000..ddb857773 --- /dev/null +++ b/plugins/telemetry/src/backend/reporter.ts @@ -0,0 +1,117 @@ +import { randomUUID } from "node:crypto"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { envOverride, posthogConfig } from "./config.js"; +import { buildPayload, isEnabled, readSettings } from "./collect.js"; +import type { UsageStore } from "./usage.js"; + +export const REPORT_INTERVAL_MS = 24 * 60 * 60 * 1000; +/** Checked hourly, so a restart never skips or doubles a day. */ +export const CHECK_INTERVAL_MS = 60 * 60 * 1000; + +const STATE_KEY = "state"; + +export interface ReporterState { + lastSentAt: string | null; + lastAttemptAt: string | null; + lastError: string | null; +} + +export type SendResult = + { sent: true } | { sent: false; reason: "disabled" | "not-due" }; + +type Env = Record; + +export function createReporter( + ctx: PluginContext, + usage: UsageStore, + env: Env = process.env, + now: () => number = Date.now, +) { + async function state(): Promise { + const stored = (await ctx.kv.get(STATE_KEY)) as + Partial | undefined; + return { + lastSentAt: stored?.lastSentAt ?? null, + lastAttemptAt: stored?.lastAttemptAt ?? null, + lastError: stored?.lastError ?? null, + }; + } + + async function saveState(patch: Partial) { + await ctx.kv.set(STATE_KEY, { ...(await state()), ...patch }); + } + + async function nextDueAt(): Promise { + const { lastSentAt } = await state(); + if (!lastSentAt) return null; + return new Date(Date.parse(lastSentAt) + REPORT_INTERVAL_MS).toISOString(); + } + + async function send(options: { force?: boolean } = {}): Promise { + if (!isEnabled(await readSettings(ctx), env)) { + return { sent: false, reason: "disabled" }; + } + if (!options.force) { + const due = await nextDueAt(); + if (due && Date.parse(due) > now()) { + return { sent: false, reason: "not-due" }; + } + } + + const attemptAt = new Date(now()).toISOString(); + const { apiKey, host } = posthogConfig(env); + try { + const { payload, usage: carried } = await buildPayload(ctx, usage, env); + const response = await ctx.fetch(`${host}/capture/`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ api_key: apiKey, ...payload }), + timeoutMs: 10_000, + }); + if (!response.ok) { + throw new Error(`PostHog answered ${response.status}`); + } + await usage.subtract(carried); + await saveState({ + lastSentAt: attemptAt, + lastAttemptAt: attemptAt, + lastError: null, + }); + ctx.log.info("Sent daily usage statistics"); + return { sent: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await saveState({ lastAttemptAt: attemptAt, lastError: message }); + ctx.log.warn(`Could not send usage statistics: ${message}`); + throw error; + } + } + + return { + state, + send, + + async status() { + const settings = await readSettings(ctx); + return { + enabled: isEnabled(settings, env), + locked: envOverride(env) !== null, + instanceId: settings.instanceId || null, + nextDueAt: await nextDueAt(), + ...(await state()), + }; + }, + + async preview() { + return (await buildPayload(ctx, usage, env)).payload; + }, + + async resetInstanceId(): Promise { + const id = randomUUID(); + await ctx.settings.set("instanceId", id); + return id; + }, + }; +} + +export type Reporter = ReturnType; diff --git a/plugins/telemetry/src/backend/routes.ts b/plugins/telemetry/src/backend/routes.ts new file mode 100644 index 000000000..d593d9ddd --- /dev/null +++ b/plugins/telemetry/src/backend/routes.ts @@ -0,0 +1,148 @@ +import type { Request, RequestHandler, Response, Router } from "express"; +import type { PluginContext } from "@termix/plugin-sdk/backend"; +import { isEnabled, readSettings } from "./collect.js"; +import type { Reporter } from "./reporter.js"; +import { sanitizeUsage, type UsageStore } from "./usage.js"; + +/** Whether the signed-in user's feature usage should be counted. */ +export async function shouldTrackUser( + ctx: PluginContext, + userId: string | undefined, + env?: Record, +): Promise { + if (!userId) return false; + const settings = await readSettings(ctx); + if (!isEnabled(settings, env) || !settings.includeFeatureUsage) return false; + return (await ctx.settings.getUser(userId, "shareFeatureUsage")) !== false; +} + +export function registerRoutes( + ctx: PluginContext, + router: Router, + reporter: Reporter, + usage: UsageStore, + env?: Record, +): void { + const manage = ctx.rbac.require("manage") as unknown as RequestHandler; + + /** + * @openapi + * /plugin-api/telemetry/status: + * get: + * summary: Usage statistics status + * description: Whether usage statistics are sent, whether ENABLE_TELEMETRY locks that, the instance ID and the last attempt. + * tags: [Usage Statistics] + * responses: + * 200: + * description: Status. + * 403: + * description: Missing the telemetry.manage permission. + */ + router.get("/status", manage, async (_req: Request, res: Response) => { + res.json(await reporter.status()); + }); + + /** + * @openapi + * /plugin-api/telemetry/preview: + * get: + * summary: Preview the next report + * description: The exact event that would be sent right now, without the API key. + * tags: [Usage Statistics] + * responses: + * 200: + * description: The event. + * 403: + * description: Missing the telemetry.manage permission. + */ + router.get("/preview", manage, async (_req: Request, res: Response) => { + res.json(await reporter.preview()); + }); + + /** + * @openapi + * /plugin-api/telemetry/send: + * post: + * summary: Send usage statistics now + * description: Sends the report straight away. Does nothing while usage statistics are turned off. + * tags: [Usage Statistics] + * responses: + * 200: + * description: Whether a report was sent. + * 403: + * description: Missing the telemetry.manage permission. + * 502: + * description: The report could not be delivered. + */ + router.post("/send", manage, async (_req: Request, res: Response) => { + try { + res.json(await reporter.send({ force: true })); + } catch (error) { + res.status(502).json({ + error: error instanceof Error ? error.message : String(error), + }); + } + }); + + /** + * @openapi + * /plugin-api/telemetry/reset-id: + * post: + * summary: Reset the instance ID + * description: Picks a new random instance ID, so later reports look like a new install. + * tags: [Usage Statistics] + * responses: + * 200: + * description: The new ID. + * 403: + * description: Missing the telemetry.manage permission. + */ + router.post("/reset-id", manage, async (_req: Request, res: Response) => { + res.json({ instanceId: await reporter.resetInstanceId() }); + }); + + /** + * @openapi + * /plugin-api/telemetry/usage/config: + * get: + * summary: Whether to count this user's feature usage + * description: False when usage statistics or feature usage are off, or the user turned off their own part. + * tags: [Usage Statistics] + * responses: + * 200: + * description: "{ track: boolean }" + */ + router.get("/usage/config", async (_req: Request, res: Response) => { + res.json({ track: await shouldTrackUser(ctx, ctx.currentActor(), env) }); + }); + + /** + * @openapi + * /plugin-api/telemetry/usage: + * post: + * summary: Add feature usage counts + * description: Counts from the signed-in user's browser, added to the next report. Ignored when this user is not counted. Names and counts are checked and capped. + * tags: [Usage Statistics] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * features: + * type: object + * additionalProperties: + * type: integer + * responses: + * 204: + * description: Recorded or ignored. + */ + router.post("/usage", async (req: Request, res: Response) => { + if (await shouldTrackUser(ctx, ctx.currentActor(), env)) { + const counts = sanitizeUsage(req.body?.features); + if (Object.keys(counts).length > 0) await usage.add(counts); + } + res.status(204).end(); + }); +} diff --git a/plugins/telemetry/src/backend/usage.ts b/plugins/telemetry/src/backend/usage.ts new file mode 100644 index 000000000..f2c436dc1 --- /dev/null +++ b/plugins/telemetry/src/backend/usage.ts @@ -0,0 +1,75 @@ +import type { PluginKeyValue } from "@termix/plugin-sdk/backend"; + +const USAGE_KEY = "usage"; +const FEATURE_NAME = /^[a-z][a-z0-9_.-]{0,63}$/; +export const MAX_FEATURES = 100; +const MAX_COUNT = 100_000; + +export type UsageCounts = Record; + +/** Keeps valid names with positive whole counts, capped. */ +export function sanitizeUsage(input: unknown): UsageCounts { + const clean: UsageCounts = {}; + if (!input || typeof input !== "object" || Array.isArray(input)) { + return clean; + } + for (const [name, raw] of Object.entries(input)) { + if (!FEATURE_NAME.test(name)) continue; + const count = Math.floor(Number(raw)); + if (!Number.isFinite(count) || count <= 0) continue; + clean[name] = Math.min(count, MAX_COUNT); + if (Object.keys(clean).length >= MAX_FEATURES) break; + } + return clean; +} + +export function createUsageStore(kv: PluginKeyValue) { + // One write at a time, so two requests never drop each other's counts. + let queue: Promise = Promise.resolve(); + const serial = (fn: () => Promise): Promise => { + const next = queue.then(fn, fn); + queue = next.catch(() => undefined); + return next; + }; + + const read = async (): Promise => + sanitizeUsage(await kv.get(USAGE_KEY)); + + return { + read, + + add: (counts: UsageCounts) => + serial(async () => { + const current = await read(); + for (const [name, count] of Object.entries(sanitizeUsage(counts))) { + if ( + !(name in current) && + Object.keys(current).length >= MAX_FEATURES + ) { + continue; + } + current[name] = Math.min((current[name] ?? 0) + count, MAX_COUNT); + } + await kv.set(USAGE_KEY, current); + }), + + /** Drops what a successful report already carried. */ + subtract: (sent: UsageCounts) => + serial(async () => { + const current = await read(); + for (const [name, count] of Object.entries(sent)) { + const left = (current[name] ?? 0) - count; + if (left > 0) current[name] = left; + else delete current[name]; + } + await kv.set(USAGE_KEY, current); + }), + }; +} + +export type UsageStore = ReturnType; + +/** PostHog property name for a feature: used_tab_terminal. */ +export function usageProperty(name: string): string { + return `used_${name.replace(/[.-]/g, "_")}`; +} diff --git a/plugins/telemetry/src/frontend/TelemetryStatusSetting.tsx b/plugins/telemetry/src/frontend/TelemetryStatusSetting.tsx new file mode 100644 index 000000000..1be24dd08 --- /dev/null +++ b/plugins/telemetry/src/frontend/TelemetryStatusSetting.tsx @@ -0,0 +1,191 @@ +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + usePluginApi, + useTranslation, + type SettingsComponentProps, +} from "@termix/plugin-sdk/frontend"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + useConfirmation, +} from "@termix/plugin-sdk/ui"; + +export interface TelemetryStatus { + enabled: boolean; + locked: boolean; + instanceId: string | null; + lastSentAt: string | null; + lastAttemptAt: string | null; + lastError: string | null; + nextDueAt: string | null; +} + +function errorMessage(error: unknown): string { + const data = (error as { response?: { data?: { error?: string } } })?.response + ?.data; + if (data?.error) return data.error; + return error instanceof Error ? error.message : String(error); +} + +export function TelemetryStatusSetting({ running }: SettingsComponentProps) { + const { t } = useTranslation(); + const api = usePluginApi(); + const { confirmWithToast } = useConfirmation(); + const [status, setStatus] = useState(null); + const [sending, setSending] = useState(false); + const [preview, setPreview] = useState(null); + + const load = useCallback(async () => { + try { + const { data } = await api.get("/status"); + setStatus(data); + } catch { + setStatus(null); + toast.error(t("status.loadFailed")); + } + }, [api, t]); + + useEffect(() => { + if (running) void load(); + }, [running, load]); + + async function sendNow() { + setSending(true); + try { + const { data } = await api.post<{ sent: boolean }>("/send"); + if (data.sent) toast.success(t("status.sent")); + else toast.info(t("status.notSent")); + } catch (error) { + toast.error(errorMessage(error)); + } finally { + setSending(false); + await load(); + } + } + + async function showPreview() { + try { + const { data } = await api.get("/preview"); + setPreview(data); + } catch (error) { + toast.error(errorMessage(error)); + } + } + + function resetId() { + void confirmWithToast(t("status.resetIdConfirm"), async () => { + try { + await api.post("/reset-id"); + toast.success(t("status.resetIdDone")); + await load(); + } catch (error) { + toast.error(errorMessage(error)); + } + }); + } + + let summary = t("status.off"); + if (status?.locked) { + summary = status.enabled ? t("status.lockedOn") : t("status.lockedOff"); + } else if (status?.enabled) { + summary = t("status.on"); + } + + const muted = "text-[10px] text-muted-foreground"; + + return ( +
+ {t("status.title")} + {status && ( + <> + {summary} + + {status.instanceId + ? t("status.instanceId", { id: status.instanceId }) + : t("status.noInstanceId")} + + + {status.lastSentAt + ? t("status.lastSent", { + date: new Date(status.lastSentAt).toLocaleString(), + }) + : t("status.neverSent")} + + {status.enabled && status.nextDueAt && ( + + {t("status.nextSend", { + date: new Date(status.nextDueAt).toLocaleString(), + })} + + )} + {status.lastError && ( + + {t("status.lastError", { error: status.lastError })} + + )} + + )} +
+ + + +
+ !open && setPreview(null)} + > + + + {t("status.previewTitle")} + + {t("status.previewDescription")} + + +
+            {JSON.stringify(preview, null, 2)}
+          
+ + + +
+
+
+ ); +} diff --git a/plugins/telemetry/src/frontend/index.tsx b/plugins/telemetry/src/frontend/index.tsx new file mode 100644 index 000000000..40ff753fc --- /dev/null +++ b/plugins/telemetry/src/frontend/index.tsx @@ -0,0 +1,12 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; +import { TelemetryStatusSetting } from "./TelemetryStatusSetting"; +import { startTabTracker } from "./tracker"; + +export function activate(app: TermixApp): void { + app.registerSettingsComponent("status", TelemetryStatusSetting); + if (!app.guest) app.onDispose(startTabTracker(app)); +} + +export function deactivate(): void { + // Registrations through app are disposed by core. +} diff --git a/plugins/telemetry/src/frontend/tracker.ts b/plugins/telemetry/src/frontend/tracker.ts new file mode 100644 index 000000000..a09c5fe53 --- /dev/null +++ b/plugins/telemetry/src/frontend/tracker.ts @@ -0,0 +1,109 @@ +import type { TermixApp } from "@termix/plugin-sdk/frontend"; + +export const FLUSH_INTERVAL_MS = 5 * 60 * 1000; + +type Counts = Record; + +/** Open tabs per type. Slot ids change on every read, so only types count. */ +export function countTabTypes(layout: unknown): Counts { + const tabs = (layout as { tabs?: unknown } | null)?.tabs; + const counts: Counts = {}; + if (!Array.isArray(tabs)) return counts; + for (const tab of tabs) { + const type = (tab as { type?: unknown })?.type; + if (typeof type === "string" && type) + counts[type] = (counts[type] ?? 0) + 1; + } + return counts; +} + +/** Tabs opened between two snapshots, keyed as tab.. */ +export function openedSince(before: Counts, after: Counts): Counts { + const opened: Counts = {}; + for (const [type, count] of Object.entries(after)) { + const added = count - (before[type] ?? 0); + if (added > 0) opened[`tab.${type}`] = added; + } + return opened; +} + +/** + * Counts which kinds of tabs the user opens and sends the totals to the + * plugin's backend every few minutes. Does nothing unless the backend says + * this user is counted. + */ +export function startTabTracker( + app: TermixApp, + flushMs: number = FLUSH_INTERVAL_MS, +): () => void { + let track = false; + let ready = false; + let previous: Counts = {}; + let pending: Counts = {}; + + const snapshot = () => countTabTypes(app.tabs.getLayout()); + + const refreshConfig = async () => { + try { + const { data } = await app.api.get<{ track?: boolean }>("/usage/config"); + track = data?.track === true; + } catch { + track = false; + } + if (!track) pending = {}; + }; + + const flush = (keepalive = false) => { + if (!track || Object.keys(pending).length === 0) return; + const features = pending; + pending = {}; + void app + .fetch("/usage", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ features }), + keepalive, + }) + .catch(() => undefined); + }; + + const disposers: Array<() => void> = []; + + disposers.push( + app.tabs.onReady(() => { + // Tabs restored after login were opened in an earlier session. + previous = snapshot(); + ready = true; + }), + ); + + disposers.push( + app.tabs.onChange(() => { + const current = snapshot(); + if (ready && track) { + for (const [name, count] of Object.entries( + openedSince(previous, current), + )) { + pending[name] = (pending[name] ?? 0) + count; + } + } + previous = current; + }), + ); + + disposers.push(app.onSettingsChanged(() => void refreshConfig())); + + const timer = setInterval(() => flush(), flushMs); + disposers.push(() => clearInterval(timer)); + + const onPageHide = () => flush(true); + window.addEventListener("pagehide", onPageHide); + disposers.push(() => window.removeEventListener("pagehide", onPageHide)); + + void refreshConfig(); + + return () => { + flush(true); + for (const dispose of disposers) dispose(); + }; +} diff --git a/plugins/telemetry/tests/backend/telemetry.test.ts b/plugins/telemetry/tests/backend/telemetry.test.ts new file mode 100644 index 000000000..d5e6de7d9 --- /dev/null +++ b/plugins/telemetry/tests/backend/telemetry.test.ts @@ -0,0 +1,378 @@ +import http from "node:http"; +import type { AddressInfo } from "node:net"; +import { fileURLToPath } from "node:url"; +import express, { type Router } from "express"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createMockCtx, + createTestDb, + type MockPluginContext, + type TestDb, +} from "@termix/plugin-sdk/testing"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import manifestJson from "../../manifest.json"; +import { activateWith } from "../../src/backend/index.js"; +import { + DEFAULT_POSTHOG_API_KEY, + envOverride, + posthogConfig, +} from "../../src/backend/config.js"; +import { detectDeployment } from "../../src/backend/collect.js"; +import { + MAX_FEATURES, + sanitizeUsage, + usageProperty, +} from "../../src/backend/usage.js"; +import { CHECK_INTERVAL_MS } from "../../src/backend/reporter.js"; + +const manifest = manifestJson as unknown as PluginManifest; +const pluginDir = fileURLToPath(new URL("../..", import.meta.url)); + +interface Sent { + url: string; + body: Record & { properties: Record }; +} + +let cleanup: Array<() => Promise | void> = []; + +afterEach(async () => { + for (const fn of cleanup) await fn(); + cleanup = []; +}); + +async function setup( + options: { + settings?: Record; + env?: Record; + permissions?: string[]; + status?: number; + } = {}, +) { + const db: TestDb = await createTestDb(pluginDir); + db.sqlite + .prepare("INSERT INTO users (id, username) VALUES (?, ?)") + .run("u1", "u1"); + db.sqlite + .prepare("INSERT INTO users (id, username) VALUES (?, ?)") + .run("u2", "u2"); + db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(1); + + const sent: Sent[] = []; + let router: Router | null = null; + const mock: MockPluginContext = createMockCtx({ + pluginId: manifest.id, + manifest, + capabilities: manifest.capabilities, + db: db.database, + router: () => (router = express.Router()), + settings: options.settings, + permissions: options.permissions ?? ["manage"], + installedPlugins: [ + { id: "telemetry", version: "1.0.0", source: "bundled", state: "active" }, + { id: "docker", version: "1.0.0", source: "bundled", state: "active" }, + { id: "serial", version: "1.0.0", source: "bundled", state: "stopped" }, + ], + fetch: async (url, init) => { + sent.push({ url, body: JSON.parse(String(init?.body)) }); + return new Response("{}", { status: options.status ?? 200 }); + }, + }); + mock.setActor("u1"); + + const env = { VERSION: "2.9.0", ...options.env }; + const { reporter, usage } = await activateWith(mock.ctx, env); + + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + mock.setActor(req.header("x-test-user") ?? "u1"); + next(); + }); + app.use((req, res, next) => router!(req, res, next)); + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, resolve)); + const { port } = server.address() as AddressInfo; + cleanup.push( + () => new Promise((resolve) => server.close(() => resolve())), + () => db.close(), + ); + + const call = async ( + method: string, + path: string, + body?: unknown, + user = "u1", + ) => { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method, + headers: { + "x-test-user": user, + ...(body !== undefined ? { "content-type": "application/json" } : {}), + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + const text = await response.text(); + return { status: response.status, body: text ? JSON.parse(text) : null }; + }; + + return { mock, reporter, usage, sent, call }; +} + +describe("config", () => { + it("reads ENABLE_TELEMETRY as a lock only when set", () => { + expect(envOverride({})).toBeNull(); + expect(envOverride({ ENABLE_TELEMETRY: " " })).toBeNull(); + expect(envOverride({ ENABLE_TELEMETRY: "TRUE" })).toBe(true); + expect(envOverride({ ENABLE_TELEMETRY: "false" })).toBe(false); + expect(envOverride({ ENABLE_TELEMETRY: "no" })).toBe(false); + }); + + it("uses the embedded key unless the env sets one", () => { + expect(posthogConfig({})).toEqual({ + apiKey: DEFAULT_POSTHOG_API_KEY, + host: "https://us.i.posthog.com", + }); + expect( + posthogConfig({ + POSTHOG_API_KEY: "k", + POSTHOG_HOST: "https://ph.local/", + }), + ).toEqual({ apiKey: "k", host: "https://ph.local" }); + }); + + it("detects how Termix runs", () => { + expect(detectDeployment({ ELECTRON_EMBEDDED: "true" }, () => true)).toBe( + "desktop", + ); + expect(detectDeployment({}, () => true)).toBe("docker"); + expect(detectDeployment({}, () => false)).toBe("server"); + }); +}); + +describe("usage counts", () => { + it("keeps valid names and positive whole counts", () => { + expect( + sanitizeUsage({ + "tab.terminal": 2.7, + "Bad Name": 1, + zero: 0, + neg: -3, + nan: "x", + big: 1e9, + }), + ).toEqual({ "tab.terminal": 2, big: 100_000 }); + expect(sanitizeUsage(null)).toEqual({}); + expect(sanitizeUsage([1])).toEqual({}); + }); + + it("caps the number of features", () => { + const many = Object.fromEntries( + Array.from({ length: MAX_FEATURES + 20 }, (_, i) => [`f${i}`, 1]), + ); + expect(Object.keys(sanitizeUsage(many))).toHaveLength(MAX_FEATURES); + }); + + it("maps a feature to a property name", () => { + expect(usageProperty("tab.file-manager")).toBe("used_tab_file_manager"); + }); +}); + +describe("reporting", () => { + it("sends a full report with every section on", async () => { + const { mock, usage, sent } = await setup(); + await usage.add({ "tab.terminal": 3 }); + mock.ctx.events.emit("host.login", { hostId: 1 }); + mock.ctx.events.emit("host.session.status", { hostId: 1, online: true }); + mock.ctx.events.emit("host.session.status", { hostId: 1, online: false }); + await vi.waitFor(async () => + expect(await usage.read()).toMatchObject({ + ssh_login: 1, + host_connected: 1, + }), + ); + + await mock.runScheduled(); + + expect(sent).toHaveLength(1); + const { url, body } = sent[0]; + expect(url).toBe("https://us.i.posthog.com/capture/"); + expect(body.api_key).toBe(DEFAULT_POSTHOG_API_KEY); + expect(body.event).toBe("instance_heartbeat"); + expect(typeof body.distinct_id).toBe("string"); + expect(body.properties).toMatchObject({ + version: "2.9.0", + user_count: 2, + host_count: 1, + os: process.platform, + arch: process.arch, + db_dialect: "sqlite", + used_tab_terminal: 3, + used_ssh_login: 1, + used_host_connected: 1, + plugins_enabled: ["docker", "telemetry"], + plugin_count: 2, + }); + expect(await usage.read()).toEqual({}); + }); + + it("checks hourly and sends at most once a day", async () => { + const { mock, sent } = await setup(); + expect(mock.scheduled.find((job) => job.kind === "every")?.ms).toBe( + CHECK_INTERVAL_MS, + ); + await mock.runScheduled(); + await mock.runScheduled(); + expect(sent).toHaveLength(1); + }); + + it("leaves out each section when its switch is off", async () => { + const { mock, usage, sent } = await setup({ + settings: { + includePlatform: false, + includeFeatureUsage: false, + includePlugins: false, + }, + }); + await usage.add({ "tab.terminal": 1 }); + await mock.runScheduled(); + expect(Object.keys(sent[0].body.properties).sort()).toEqual([ + "host_count", + "user_count", + "version", + ]); + expect(await usage.read()).toEqual({ "tab.terminal": 1 }); + }); + + it("sends nothing and counts nothing while turned off", async () => { + const { mock, usage, sent } = await setup({ settings: { enabled: false } }); + mock.ctx.events.emit("host.login", { hostId: 1 }); + await mock.runScheduled(); + expect(sent).toEqual([]); + expect(await usage.read()).toEqual({}); + }); + + it("lets ENABLE_TELEMETRY win over the admin switch", async () => { + const off = await setup({ env: { ENABLE_TELEMETRY: "false" } }); + await off.mock.runScheduled(); + expect(off.sent).toEqual([]); + + const on = await setup({ + settings: { enabled: false }, + env: { ENABLE_TELEMETRY: "true" }, + }); + await on.mock.runScheduled(); + expect(on.sent).toHaveLength(1); + }); + + it("keeps the 2.8 instance id", async () => { + const { mock, sent } = await setup({ + settings: { instanceId: "legacy-id" }, + }); + await mock.runScheduled(); + expect(sent[0].body.distinct_id).toBe("legacy-id"); + }); + + it("records a failed send and keeps the counts", async () => { + const { mock, reporter, usage, sent } = await setup({ status: 500 }); + await usage.add({ "tab.terminal": 1 }); + await mock.runScheduled(); + expect(sent.length).toBeGreaterThan(0); + const state = await reporter.state(); + expect(state.lastError).toBe("PostHog answered 500"); + expect(state.lastSentAt).toBeNull(); + expect(await usage.read()).toEqual({ "tab.terminal": 1 }); + }); + + it("refuses a change to the switch while ENABLE_TELEMETRY locks it", async () => { + const locked = await setup({ env: { ENABLE_TELEMETRY: "true" } }); + expect( + await locked.mock.validateSettings("admin", { enabled: false }), + ).toHaveProperty("enabled"); + expect( + await locked.mock.validateSettings("admin", { enabled: true }), + ).not.toHaveProperty("enabled"); + + const free = await setup(); + expect( + await free.mock.validateSettings("admin", { enabled: false }), + ).not.toHaveProperty("enabled"); + }); +}); + +describe("routes", () => { + it("serves status, preview, send and reset to a manager", async () => { + const { call, sent } = await setup(); + + const preview = await call("GET", "/preview"); + expect(preview.status).toBe(200); + expect(preview.body.event).toBe("instance_heartbeat"); + expect(preview.body).not.toHaveProperty("api_key"); + expect(sent).toEqual([]); + + expect((await call("POST", "/send")).body).toEqual({ sent: true }); + const status = await call("GET", "/status"); + expect(status.body).toMatchObject({ + enabled: true, + locked: false, + lastError: null, + }); + expect(status.body.lastSentAt).toBeTruthy(); + expect(status.body.nextDueAt).toBeTruthy(); + + const before = status.body.instanceId; + const reset = await call("POST", "/reset-id"); + expect(reset.body.instanceId).not.toBe(before); + }); + + it("answers 502 when the report cannot be delivered", async () => { + const { call } = await setup({ status: 503 }); + const response = await call("POST", "/send"); + expect(response.status).toBe(502); + expect(response.body.error).toBe("PostHog answered 503"); + }); + + it("keeps the admin routes from users without manage", async () => { + const { call } = await setup({ permissions: [] }); + for (const [method, path] of [ + ["GET", "/status"], + ["GET", "/preview"], + ["POST", "/send"], + ["POST", "/reset-id"], + ]) { + expect((await call(method, path)).status).toBe(403); + } + expect((await call("GET", "/usage/config")).status).toBe(200); + }); + + it("records usage only for users who are counted", async () => { + const { mock, call, usage } = await setup({ permissions: [] }); + expect((await call("GET", "/usage/config")).body).toEqual({ track: true }); + + await call("POST", "/usage", { + features: { "tab.terminal": 2, "BAD!": 5 }, + }); + expect(await usage.read()).toEqual({ "tab.terminal": 2 }); + + await mock.ctx.settings.setUser("u2", "shareFeatureUsage", false); + expect((await call("GET", "/usage/config", undefined, "u2")).body).toEqual({ + track: false, + }); + const ignored = await call( + "POST", + "/usage", + { features: { "tab.terminal": 9 } }, + "u2", + ); + expect(ignored.status).toBe(204); + expect(await usage.read()).toEqual({ "tab.terminal": 2 }); + }); + + it("counts nobody while feature usage is off", async () => { + const { call, usage } = await setup({ + settings: { includeFeatureUsage: false }, + }); + expect((await call("GET", "/usage/config")).body).toEqual({ track: false }); + await call("POST", "/usage", { features: { "tab.terminal": 1 } }); + expect(await usage.read()).toEqual({}); + }); +}); diff --git a/plugins/telemetry/tests/frontend/telemetry.test.tsx b/plugins/telemetry/tests/frontend/telemetry.test.tsx new file mode 100644 index 000000000..2317b74ef --- /dev/null +++ b/plugins/telemetry/tests/frontend/telemetry.test.tsx @@ -0,0 +1,193 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { + renderWithApp, + type RenderedPluginApp, +} from "@termix/plugin-sdk/testing"; +import type { PluginApiClient, TermixApp } from "@termix/plugin-sdk/frontend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import * as plugin from "../../src/frontend/index"; +import { + countTabTypes, + openedSince, + startTabTracker, +} from "../../src/frontend/tracker"; +import manifestJson from "../../manifest.json"; +import locales from "../../locales/en.json"; + +const api = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn(), +})); + +vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ + t: (key: string, values?: Record) => + values ? `${key} ${Object.values(values).join(" ")}` : key, + }), + usePluginApi: () => api, +})); + +const { TelemetryStatusSetting } = + await import("../../src/frontend/TelemetryStatusSetting"); + +const manifest = manifestJson as unknown as PluginManifest; + +let rendered: RenderedPluginApp | null = null; + +afterEach(async () => { + await rendered?.deactivate(); + rendered = null; + api.get.mockReset(); + api.post.mockReset(); + vi.useRealTimers(); +}); + +function fakeApp(track: boolean) { + let layout: unknown = { version: 1, tabs: [] }; + let onChange: () => void = () => undefined; + const posts: unknown[] = []; + const app = { + api: { + get: vi.fn(async () => ({ data: { track } })), + }, + fetch: vi.fn(async (_path: string, init?: RequestInit) => { + posts.push(JSON.parse(String(init?.body))); + return new Response(null, { status: 204 }); + }), + tabs: { + getLayout: () => layout, + onReady: (listener: () => void) => { + listener(); + return () => undefined; + }, + onChange: (listener: () => void) => { + onChange = listener; + return () => undefined; + }, + }, + onSettingsChanged: () => () => undefined, + } as unknown as TermixApp; + return { + app, + posts, + setTabs(types: string[]) { + layout = { version: 1, tabs: types.map((type) => ({ type })) }; + onChange(); + }, + }; +} + +describe("tab counting", () => { + it("counts tabs per type and the ones opened since", () => { + const before = countTabTypes({ + tabs: [{ type: "terminal" }, { type: "terminal" }, { type: "docker" }], + }); + expect(before).toEqual({ terminal: 2, docker: 1 }); + expect(countTabTypes(null)).toEqual({}); + expect( + openedSince(before, { terminal: 3, docker: 0, "file-manager": 1 }), + ).toEqual({ "tab.terminal": 1, "tab.file-manager": 1 }); + }); +}); + +describe("startTabTracker", () => { + it("sends newly opened tabs on the timer", async () => { + vi.useFakeTimers(); + const fake = fakeApp(true); + const stop = startTabTracker(fake.app, 1000); + await vi.waitFor(() => expect(fake.app.api.get).toHaveBeenCalled()); + await Promise.resolve(); + + fake.setTabs(["terminal"]); + fake.setTabs(["terminal", "terminal", "docker"]); + fake.setTabs(["docker"]); + await vi.advanceTimersByTimeAsync(1000); + + expect(fake.posts).toEqual([ + { features: { "tab.terminal": 2, "tab.docker": 1 } }, + ]); + + await vi.advanceTimersByTimeAsync(1000); + expect(fake.posts).toHaveLength(1); + stop(); + }); + + it("sends nothing for a user who is not counted", async () => { + vi.useFakeTimers(); + const fake = fakeApp(false); + const stop = startTabTracker(fake.app, 1000); + await vi.waitFor(() => expect(fake.app.api.get).toHaveBeenCalled()); + fake.setTabs(["terminal"]); + await vi.advanceTimersByTimeAsync(1000); + stop(); + expect(fake.posts).toEqual([]); + }); +}); + +describe("telemetry activate", () => { + it("registers the status component and removes it on deactivate", async () => { + api.get.mockResolvedValue({ data: { track: false } }); + rendered = await renderWithApp(plugin, { + manifest, + locales, + api: api as unknown as PluginApiClient, + }); + expect(rendered.registered.settingsComponents()).toEqual(["status"]); + await rendered.deactivate(); + expect(rendered.registered.settingsComponents()).toEqual([]); + rendered = null; + }); +}); + +describe("TelemetryStatusSetting", () => { + const props = { + pluginId: "telemetry", + values: {}, + setValue: () => undefined, + running: true, + }; + + it("shows the state, instance id and last error", async () => { + api.get.mockResolvedValue({ + data: { + enabled: true, + locked: false, + instanceId: "abc-123", + lastSentAt: null, + lastAttemptAt: "2026-09-30T00:00:00.000Z", + lastError: "PostHog answered 500", + nextDueAt: null, + }, + }); + render(); + await waitFor(() => expect(screen.getByText("status.on")).toBeTruthy()); + expect(screen.getByText("status.instanceId abc-123")).toBeTruthy(); + expect(screen.getByText("status.neverSent")).toBeTruthy(); + expect( + screen.getByText("status.lastError PostHog answered 500"), + ).toBeTruthy(); + }); + + it("says when ENABLE_TELEMETRY locks it off", async () => { + api.get.mockResolvedValue({ + data: { + enabled: false, + locked: true, + instanceId: null, + lastSentAt: null, + lastAttemptAt: null, + lastError: null, + nextDueAt: null, + }, + }); + render(); + await waitFor(() => + expect(screen.getByText("status.lockedOff")).toBeTruthy(), + ); + expect( + (screen.getByText("status.sendNow") as HTMLButtonElement).disabled, + ).toBe(true); + }); +}); diff --git a/plugins/telemetry/tsconfig.json b/plugins/telemetry/tsconfig.json new file mode 100644 index 000000000..7b35314c3 --- /dev/null +++ b/plugins/telemetry/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@termix/plugin-sdk/tsconfig.plugin.json", + "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] +} diff --git a/plugins/telemetry/vitest.config.ts b/plugins/telemetry/vitest.config.ts new file mode 100644 index 000000000..31ba004fc --- /dev/null +++ b/plugins/telemetry/vitest.config.ts @@ -0,0 +1,3 @@ +import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset"; + +export default pluginVitestConfig(import.meta.url); diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 75cdbd886..b1b74d944 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -11,7 +11,6 @@ import { AUDIT_FORWARD_URL_ENV, AUDIT_FORWARD_URL_SETTING, } from "../../utils/audit-forwarder.js"; -import { getTelemetryEnvOverride } from "../../utils/analytics.js"; import { NOTIFICATION_PRIVATE_ALLOWLIST_KEY, parseNotificationAllowlist, @@ -193,117 +192,6 @@ export function registerUserSettingsRoutes( } }); - /** - * @openapi - * /users/analytics-enabled: - * get: - * summary: Get analytics enabled setting - * description: Returns whether anonymous usage telemetry is enabled, and whether the value is locked by the ENABLE_TELEMETRY environment variable. - * tags: - * - Users - * responses: - * 200: - * description: Analytics enabled status. - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - * locked: - * type: boolean - */ - router.get("/analytics-enabled", authenticateJWT, async (_req, res) => { - try { - const override = getTelemetryEnvOverride(); - if (override !== null) { - return res.json({ enabled: override, locked: true }); - } - res.json({ - enabled: await createCurrentSettingsRepository().getBoolean( - "analytics_enabled", - true, - ), - locked: false, - }); - } catch (err) { - authLogger.error("Failed to get analytics enabled setting", err); - res - .status(500) - .json({ error: "Failed to get analytics enabled setting" }); - } - }); - - /** - * @openapi - * /users/analytics-enabled: - * patch: - * summary: Update analytics enabled setting (admin only) - * description: Enables or disables the daily anonymous usage telemetry heartbeat. - * tags: - * - Users - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * enabled: - * type: boolean - * responses: - * 200: - * description: Setting updated. - * 403: - * description: Not authorized. - * 409: - * description: Setting is locked by the ENABLE_TELEMETRY environment variable. - * 500: - * description: Failed to update setting. - */ - router.patch("/analytics-enabled", authenticateJWT, async (req, res) => { - const userId = (req as AuthenticatedRequest).userId; - try { - const actor = await getAdminActor(userId); - if (!actor) { - return res.status(403).json({ error: "Not authorized" }); - } - if (getTelemetryEnvOverride() !== null) { - return res.status(409).json({ - error: "Telemetry is locked by the ENABLE_TELEMETRY env variable", - }); - } - const { enabled } = req.body; - if (typeof enabled !== "boolean") { - return res.status(400).json({ error: "enabled must be a boolean" }); - } - await createCurrentSettingsRepository().set( - "analytics_enabled", - enabled ? "true" : "false", - ); - - const { ipAddress, userAgent } = getRequestMeta(req); - await logAudit({ - userId, - username: actor.username ?? userId, - action: "update_analytics_enabled", - resourceType: "setting", - details: JSON.stringify({ enabled }), - ipAddress, - userAgent, - success: true, - }); - - res.json({ enabled }); - } catch (err) { - authLogger.error("Failed to update analytics enabled setting", err); - res - .status(500) - .json({ error: "Failed to update analytics enabled setting" }); - } - }); - /** * @openapi * /users/audit-forwarding: diff --git a/src/backend/plugins/ctx-plugins.ts b/src/backend/plugins/ctx-plugins.ts new file mode 100644 index 000000000..d1499c889 --- /dev/null +++ b/src/backend/plugins/ctx-plugins.ts @@ -0,0 +1,29 @@ +/** + * ctx.plugins: which plugins are installed and whether each runs, under + * plugins:read. Ids and versions only, nothing operational. + */ + +import type { PluginPlugins } from "@termix/plugin-sdk/backend"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; +import { assertCapability } from "./permissions.js"; + +export function createPluginPlugins(manifest: PluginManifest): PluginPlugins { + return { + list: async () => { + await assertCapability( + manifest.id, + "plugins:read", + manifest.capabilities, + ); + const { getPluginRuntime } = await import("./index.js"); + return getPluginRuntime() + .loader.list() + .map((plugin) => ({ + id: plugin.id, + version: plugin.manifest.version, + source: plugin.source, + state: plugin.state, + })); + }, + }; +} diff --git a/src/backend/plugins/ctx.ts b/src/backend/plugins/ctx.ts index 4b7e61e07..780657d39 100644 --- a/src/backend/plugins/ctx.ts +++ b/src/backend/plugins/ctx.ts @@ -61,6 +61,7 @@ import { createPluginSchedule } from "./schedule.js"; import { createPluginFetch, createPluginNotify } from "./ctx-notify.js"; import { createPluginProcess } from "./ctx-process.js"; import { createPluginSystem } from "./ctx-system.js"; +import { createPluginPlugins } from "./ctx-plugins.js"; import { createPluginCredentials } from "./ctx-credentials.js"; import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js"; @@ -945,6 +946,7 @@ export function createPluginContext( }), auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }), system: createPluginSystem({ manifest, bag: handle.bag, audit: auditCall }), + plugins: createPluginPlugins(manifest), desktop: { openIsolatedWindow: (request) => desktopOpenIsolatedWindow(request), diff --git a/src/backend/starter.ts b/src/backend/starter.ts index e87819fdd..80981ab6a 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -274,9 +274,6 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { }); } - const { startAnalyticsHeartbeat } = await import("./utils/analytics.js"); - startAnalyticsHeartbeat(); - // After plugins, so their sync entities are registered before a pass. if (process.env.ELECTRON_EMBEDDED === "true") { try { diff --git a/src/backend/tests/database/routes/user-settings-routes.test.ts b/src/backend/tests/database/routes/user-settings-routes.test.ts index 057663f5f..d10da752d 100644 --- a/src/backend/tests/database/routes/user-settings-routes.test.ts +++ b/src/backend/tests/database/routes/user-settings-routes.test.ts @@ -23,9 +23,6 @@ vi.mock("../../../utils/audit-logger.js", () => ({ logAudit: vi.fn(async () => {}), getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }), })); -vi.mock("../../../utils/analytics.js", () => ({ - getTelemetryEnvOverride: () => null, -})); vi.mock("../../../database/repositories/factory.js", () => ({ createCurrentSettingsRepository: () => ({ get: async (key: string) => state.settings[key] ?? null, diff --git a/src/backend/tests/fixtures/upgrade/db.sqlite b/src/backend/tests/fixtures/upgrade/db.sqlite index d3d8f1798..5ebe6743f 100644 Binary files a/src/backend/tests/fixtures/upgrade/db.sqlite and b/src/backend/tests/fixtures/upgrade/db.sqlite differ diff --git a/src/backend/tests/fixtures/upgrade/rows.ts b/src/backend/tests/fixtures/upgrade/rows.ts index af5db7c85..c740d0385 100644 --- a/src/backend/tests/fixtures/upgrade/rows.ts +++ b/src/backend/tests/fixtures/upgrade/rows.ts @@ -901,6 +901,8 @@ export const ROWS: Record = { ["terminal_session_persistence_enabled", "false"], ["terminal_image_max_count", "7"], ["session_sharing_globally_enabled", "false"], + ["analytics_enabled", "false"], + ["analytics_instance_id", "d4-instance-id"], // Long enough that the fixture's recordings never age out. ["session_recording_retention_days", "3650"], ].map(([key, value]) => ({ key, value })), diff --git a/src/backend/tests/plugins/ctx-plugins.test.ts b/src/backend/tests/plugins/ctx-plugins.test.ts new file mode 100644 index 000000000..8c2d0e499 --- /dev/null +++ b/src/backend/tests/plugins/ctx-plugins.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import type { PluginManifest } from "@termix/plugin-sdk/manifest"; + +vi.mock("../../plugins/permissions.js", async () => { + const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + return { + assertCapability: async ( + pluginId: string, + capability: string, + declared: readonly string[], + ) => { + if (!declared.includes(capability)) { + throw new PluginCapabilityError(pluginId, capability); + } + }, + }; +}); + +vi.mock("../../plugins/index.js", () => ({ + getPluginRuntime: () => ({ + loader: { + list: () => [ + { + id: "alpha", + source: "bundled", + state: "active", + manifest: { version: "1.2.0" }, + dir: "/secret/path", + lastError: "boom", + }, + { + id: "beta", + source: "user", + state: "stopped", + manifest: { version: "0.1.0" }, + }, + ], + }, + }), +})); + +const { createPluginPlugins } = await import("../../plugins/ctx-plugins.js"); +const { PluginCapabilityError } = await import("@termix/plugin-sdk/backend"); + +const manifest = (capabilities: string[]) => + ({ id: "reader", capabilities }) as unknown as PluginManifest; + +describe("ctx.plugins", () => { + it("lists id, version, source and state only", async () => { + const plugins = createPluginPlugins(manifest(["plugins:read"])); + expect(await plugins.list()).toEqual([ + { id: "alpha", version: "1.2.0", source: "bundled", state: "active" }, + { id: "beta", version: "0.1.0", source: "user", state: "stopped" }, + ]); + }); + + it("refuses without plugins:read", async () => { + const plugins = createPluginPlugins(manifest([])); + await expect(plugins.list()).rejects.toBeInstanceOf(PluginCapabilityError); + }); +}); diff --git a/src/backend/tests/plugins/upgrade-fixture.test.ts b/src/backend/tests/plugins/upgrade-fixture.test.ts index 2b4541201..1585efbcb 100644 --- a/src/backend/tests/plugins/upgrade-fixture.test.ts +++ b/src/backend/tests/plugins/upgrade-fixture.test.ts @@ -543,6 +543,10 @@ function upgradeChecks(current: () => Booted, bootIndex: number) { expect(await settings(booted, "session-sharing", "admin")).toMatchObject({ globallyEnabled: false, }); + expect(await settings(booted, "telemetry", "admin")).toMatchObject({ + enabled: false, + instanceId: "d4-instance-id", + }); expect(await settings(booted, "acme-ssl", "admin")).toMatchObject({ domain: "termix.d4.example", email: "d4@example.com", diff --git a/src/backend/tests/upgrade/telemetry-settings-migration.test.ts b/src/backend/tests/upgrade/telemetry-settings-migration.test.ts new file mode 100644 index 000000000..d9d473bd2 --- /dev/null +++ b/src/backend/tests/upgrade/telemetry-settings-migration.test.ts @@ -0,0 +1,100 @@ +/** + * The telemetry settings migration: an instance that turned telemetry off in + * 2.8 stays off, keeps its instance id, and a second run changes nothing. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface SettingsRow { + pluginId: string; + key: string; + value: string | null; +} + +const coreSettings = new Map(); +const settingsRows: SettingsRow[] = []; +let pluginInstalled = true; + +const find = (pluginId: string, key: string) => + settingsRows.find((row) => row.pluginId === pluginId && row.key === key); + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginRepository: () => ({ + findById: async (id: string) => (pluginInstalled ? { id } : null), + }), + createCurrentPluginSettingsRepository: () => ({ + get: async (pluginId: string, _scope: string, _id: null, key: string) => + find(pluginId, key) ?? null, + set: async ( + pluginId: string, + _scope: string, + _id: null, + key: string, + value: string | null, + ) => { + const existing = find(pluginId, key); + if (existing) existing.value = value; + else settingsRows.push({ pluginId, key, value }); + }, + }), + createCurrentSettingsRepository: () => ({ + get: async (key: string) => coreSettings.get(key) ?? null, + }), +})); + +const { runTelemetrySettingsMigration } = + await import("../../upgrade/telemetry-settings-migration.js"); + +const stored = (key: string) => { + const row = find("telemetry", key); + return row?.value == null ? undefined : JSON.parse(row.value); +}; + +describe("runTelemetrySettingsMigration", () => { + beforeEach(() => { + coreSettings.clear(); + settingsRows.length = 0; + pluginInstalled = true; + }); + + it("moves a disabled switch and the instance id", async () => { + coreSettings.set("analytics_enabled", "false"); + coreSettings.set("analytics_instance_id", "abc-123"); + + const result = await runTelemetrySettingsMigration(); + + expect(result).toEqual({ movedEnabled: true, movedInstanceId: true }); + expect(stored("enabled")).toBe(false); + expect(stored("instanceId")).toBe("abc-123"); + }); + + it("writes nothing for an enabled instance without an id", async () => { + coreSettings.set("analytics_enabled", "true"); + await runTelemetrySettingsMigration(); + expect(settingsRows).toHaveLength(0); + }); + + it("is idempotent and never overwrites plugin values", async () => { + coreSettings.set("analytics_enabled", "false"); + coreSettings.set("analytics_instance_id", "old"); + settingsRows.push({ + pluginId: "telemetry", + key: "instanceId", + value: JSON.stringify("new"), + }); + + await runTelemetrySettingsMigration(); + const second = await runTelemetrySettingsMigration(); + + expect(second).toEqual({ movedEnabled: false, movedInstanceId: false }); + expect(stored("instanceId")).toBe("new"); + expect(settingsRows).toHaveLength(2); + }); + + it("does nothing without the plugin row", async () => { + pluginInstalled = false; + coreSettings.set("analytics_enabled", "false"); + await runTelemetrySettingsMigration(); + expect(settingsRows).toHaveLength(0); + }); +}); diff --git a/src/backend/tests/utils/analytics.test.ts b/src/backend/tests/utils/analytics.test.ts deleted file mode 100644 index f53a6ed79..000000000 --- a/src/backend/tests/utils/analytics.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - -const mockGetBoolean = vi.fn(); -const mockGet = vi.fn(); -const mockSet = vi.fn(); -const mockPost = vi.fn(); - -function makeChain(resolveValue: unknown) { - const chain: Record = {}; - const methods = ["from", "where", "groupBy"]; - for (const m of methods) { - chain[m] = vi.fn(() => chain); - } - (chain as unknown as Promise).then = (cb: (v: unknown) => unknown) => - Promise.resolve(resolveValue).then(cb); - return chain; -} - -vi.mock("../../database/repositories/factory.js", () => ({ - createCurrentSettingsRepository: () => ({ - getBoolean: mockGetBoolean, - get: mockGet, - set: mockSet, - }), - createCurrentRepositoryContext: () => ({ - drizzle: { - select: vi.fn(() => makeChain([{ count: 0 }])), - }, - }), -})); - -vi.mock("../../database/db/schema.js", () => ({ - users: {}, - hosts: {}, - recentActivity: { type: "type", timestamp: "timestamp" }, -})); - -vi.mock("../../utils/logger.js", () => ({ - Logger: class { - info = vi.fn(); - warn = vi.fn(); - error = vi.fn(); - }, -})); - -vi.mock("axios", () => ({ - default: { post: mockPost }, -})); - -describe("analytics", () => { - const originalEnv = { ...process.env }; - - beforeEach(() => { - vi.clearAllMocks(); - process.env = { ...originalEnv }; - delete process.env.ENABLE_TELEMETRY; - }); - - afterEach(() => { - process.env = { ...originalEnv }; - }); - - it("isAnalyticsEnabled defaults to true via the settings repository", async () => { - delete process.env.ENABLE_TELEMETRY; - mockGetBoolean.mockResolvedValue(true); - const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); - - const result = await isAnalyticsEnabled(); - - expect(result).toBe(true); - expect(mockGetBoolean).toHaveBeenCalledWith("analytics_enabled", true); - }); - - it("ENABLE_TELEMETRY=false disables analytics without consulting the database", async () => { - process.env.ENABLE_TELEMETRY = "false"; - const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); - - const result = await isAnalyticsEnabled(); - - expect(result).toBe(false); - expect(mockGetBoolean).not.toHaveBeenCalled(); - }); - - it("ENABLE_TELEMETRY=true forces analytics on without consulting the database", async () => { - process.env.ENABLE_TELEMETRY = "TRUE"; - const { isAnalyticsEnabled } = await import("../../utils/analytics.js"); - - const result = await isAnalyticsEnabled(); - - expect(result).toBe(true); - expect(mockGetBoolean).not.toHaveBeenCalled(); - }); - - it("getTelemetryEnvOverride returns null when unset or blank", async () => { - const { getTelemetryEnvOverride } = - await import("../../utils/analytics.js"); - - delete process.env.ENABLE_TELEMETRY; - expect(getTelemetryEnvOverride()).toBe(null); - - process.env.ENABLE_TELEMETRY = " "; - expect(getTelemetryEnvOverride()).toBe(null); - }); - - it("startAnalyticsHeartbeat sends nothing when ENABLE_TELEMETRY=false", async () => { - process.env.ENABLE_TELEMETRY = "false"; - process.env.POSTHOG_API_KEY = "phc_test"; - const { startAnalyticsHeartbeat } = - await import("../../utils/analytics.js"); - - startAnalyticsHeartbeat(); - await Promise.resolve(); - - expect(mockPost).not.toHaveBeenCalled(); - }); - - it("collectAndSendHeartbeat does not call PostHog when ENABLE_TELEMETRY=false", async () => { - process.env.ENABLE_TELEMETRY = "false"; - process.env.POSTHOG_API_KEY = "phc_test"; - const { collectAndSendHeartbeat } = - await import("../../utils/analytics.js"); - - await collectAndSendHeartbeat(); - - expect(mockPost).not.toHaveBeenCalled(); - }); - - it("getOrCreateInstanceId returns the existing id without generating one", async () => { - mockGet.mockResolvedValue("existing-id"); - const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); - - const id = await getOrCreateInstanceId(); - - expect(id).toBe("existing-id"); - expect(mockSet).not.toHaveBeenCalled(); - }); - - it("getOrCreateInstanceId generates and persists a new id when absent", async () => { - mockGet.mockResolvedValue(null); - const { getOrCreateInstanceId } = await import("../../utils/analytics.js"); - - const id = await getOrCreateInstanceId(); - - expect(id).toMatch(/^[0-9a-f-]{36}$/); - expect(mockSet).toHaveBeenCalledWith("analytics_instance_id", id); - }); - - it("collectAndSendHeartbeat does not call PostHog when POSTHOG_API_KEY is unset", async () => { - delete process.env.POSTHOG_API_KEY; - const { collectAndSendHeartbeat } = - await import("../../utils/analytics.js"); - - await collectAndSendHeartbeat(); - - expect(mockPost).not.toHaveBeenCalled(); - }); - - it("collectAndSendHeartbeat does not call PostHog when analytics is disabled", async () => { - process.env.POSTHOG_API_KEY = "phc_test"; - mockGetBoolean.mockResolvedValue(false); - const { collectAndSendHeartbeat } = - await import("../../utils/analytics.js"); - - await collectAndSendHeartbeat(); - - expect(mockPost).not.toHaveBeenCalled(); - }); - - it("collectAndSendHeartbeat posts a heartbeat event with the expected shape when enabled", async () => { - process.env.POSTHOG_API_KEY = "phc_test"; - mockGetBoolean.mockResolvedValue(true); - mockGet.mockResolvedValue("instance-123"); - mockPost.mockResolvedValue({}); - const { collectAndSendHeartbeat } = - await import("../../utils/analytics.js"); - - await collectAndSendHeartbeat(); - - expect(mockPost).toHaveBeenCalledTimes(1); - const [url, body] = mockPost.mock.calls[0]; - expect(url).toContain("/capture/"); - expect(body).toMatchObject({ - api_key: "phc_test", - event: "instance_heartbeat", - distinct_id: "instance-123", - properties: expect.objectContaining({ - user_count: 0, - host_count: 0, - }), - }); - }); -}); diff --git a/src/backend/upgrade/boot-migrations.ts b/src/backend/upgrade/boot-migrations.ts index 6d1bfcbde..e5f9cd3ab 100644 --- a/src/backend/upgrade/boot-migrations.ts +++ b/src/backend/upgrade/boot-migrations.ts @@ -20,6 +20,7 @@ import { runSshTerminalMacrosMigration } from "./ssh-terminal-macros-migration.j import { runSnippetsSettingsMigration } from "./snippets-settings-migration.js"; import { runTmuxMonitorSettingsMigration } from "./tmux-monitor-settings-migration.js"; import { runSessionSharingSettingsMigration } from "./session-sharing-settings-migration.js"; +import { runTelemetrySettingsMigration } from "./telemetry-settings-migration.js"; import { runSessionRecordingSettingsMigration } from "./session-recording-settings-migration.js"; import { runRemoteDesktopSettingsMigration } from "./remote-desktop-settings-migration.js"; import { runHostMetricsSettingsMigration } from "./host-metrics-settings-migration.js"; @@ -67,6 +68,7 @@ const MIGRATIONS: Array<[string, () => Promise]> = [ ["runNotificationChannelMigration", runNotificationChannelMigration], ["runTermixIdentityCaMigration", runTermixIdentityCaMigration], ["runSsoSettingsMigration", runSsoSettingsMigration], + ["runTelemetrySettingsMigration", runTelemetrySettingsMigration], // Last: it reads what the moves above wrote. ["runHostDefaultsMigration", runHostDefaultsMigration], ]; diff --git a/src/backend/upgrade/telemetry-settings-migration.ts b/src/backend/upgrade/telemetry-settings-migration.ts new file mode 100644 index 000000000..833791680 --- /dev/null +++ b/src/backend/upgrade/telemetry-settings-migration.ts @@ -0,0 +1,74 @@ +/** + * Moves the 2.8 telemetry settings into the telemetry plugin: the admin + * switch (settings.analytics_enabled) into its `enabled` admin setting, and + * the instance id (settings.analytics_instance_id) into `instanceId`, so the + * instance keeps reporting under the same id. + * + * Idempotent: nothing is written where the plugin already has a row. The + * legacy settings rows stay in place until 3.0.0. The plugin waits a few + * minutes before its first send, so this lands first. + */ + +import { databaseLogger } from "../utils/logger.js"; +import { + createCurrentPluginRepository, + createCurrentPluginSettingsRepository, + createCurrentSettingsRepository, +} from "../database/repositories/factory.js"; + +const PLUGIN_ID = "telemetry"; + +export interface TelemetrySettingsMigrationResult { + movedEnabled: boolean; + movedInstanceId: boolean; +} + +export async function runTelemetrySettingsMigration(): Promise { + const result: TelemetrySettingsMigrationResult = { + movedEnabled: false, + movedInstanceId: false, + }; + + // plugin_settings has a foreign key to plugins. + try { + if (!(await createCurrentPluginRepository().findById(PLUGIN_ID))) { + return result; + } + } catch { + return result; + } + + const coreSettings = createCurrentSettingsRepository(); + const pluginSettings = createCurrentPluginSettingsRepository(); + + const moves: Array<[string, string, unknown]> = []; + if ((await coreSettings.get("analytics_enabled")) === "false") { + moves.push(["analytics_enabled", "enabled", false]); + } + const instanceId = await coreSettings.get("analytics_instance_id"); + if (instanceId) { + moves.push(["analytics_instance_id", "instanceId", instanceId]); + } + + for (const [, key, value] of moves) { + const existing = await pluginSettings.get(PLUGIN_ID, "admin", null, key); + if (existing && existing.value !== null) continue; + await pluginSettings.set( + PLUGIN_ID, + "admin", + null, + key, + JSON.stringify(value), + ); + if (key === "enabled") result.movedEnabled = true; + else result.movedInstanceId = true; + } + + if (result.movedEnabled || result.movedInstanceId) { + databaseLogger.info("Moved telemetry settings into plugin settings", { + operation: "telemetry_settings_migration", + ...result, + }); + } + return result; +} diff --git a/src/backend/utils/analytics.ts b/src/backend/utils/analytics.ts deleted file mode 100644 index 1884f281f..000000000 --- a/src/backend/utils/analytics.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { getErrorMessage } from "./error-message.js"; -import crypto from "crypto"; -import axios from "axios"; -import { sql } from "drizzle-orm"; -import { users, hosts, recentActivity } from "../database/db/schema.js"; -import { - createCurrentSettingsRepository, - createCurrentRepositoryContext, -} from "../database/repositories/factory.js"; -import { Logger } from "./logger.js"; - -const analyticsLogger = new Logger("ANALYTICS", "📈", "#06b6d4"); - -const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com"; -const HEARTBEAT_INTERVAL_MS = 24 * 60 * 60 * 1000; - -export function getTelemetryEnvOverride(): boolean | null { - const envVal = process.env.ENABLE_TELEMETRY; - if (envVal === undefined) return null; - const normalized = envVal.trim().toLowerCase(); - if (normalized === "") return null; - return normalized === "true"; -} - -export async function isAnalyticsEnabled(): Promise { - const override = getTelemetryEnvOverride(); - if (override !== null) return override; - - return createCurrentSettingsRepository().getBoolean( - "analytics_enabled", - true, - ); -} - -export async function getOrCreateInstanceId(): Promise { - const settings = createCurrentSettingsRepository(); - const existing = await settings.get("analytics_instance_id"); - if (existing) return existing; - - const id = crypto.randomUUID(); - await settings.set("analytics_instance_id", id); - return id; -} - -function getAppVersion(): string { - return process.env.VERSION || "unknown"; -} - -async function collectFeatureUsage(): Promise> { - const since = new Date(Date.now() - HEARTBEAT_INTERVAL_MS).toISOString(); - const db = createCurrentRepositoryContext().drizzle; - - const rows = await db - .select({ - type: recentActivity.type, - count: sql`count(*)`, - }) - .from(recentActivity) - .where(sql`${recentActivity.timestamp} >= ${since}`) - .groupBy(recentActivity.type); - - // Activity types come from whichever plugins recorded them, so report the - // ones that appeared rather than a fixed list. - const usage: Record = {}; - for (const row of rows) { - if (/^[a-z][a-z0-9_]*$/.test(row.type)) { - usage[`used_${row.type}`] = Number(row.count); - } - } - return usage; -} - -async function collectCounts(): Promise<{ - userCount: number; - hostCount: number; -}> { - const db = createCurrentRepositoryContext().drizzle; - - const [userRows, hostRows] = await Promise.all([ - db.select({ count: sql`count(*)` }).from(users), - db.select({ count: sql`count(*)` }).from(hosts), - ]); - - return { - userCount: Number(userRows[0]?.count ?? 0), - hostCount: Number(hostRows[0]?.count ?? 0), - }; -} - -export async function collectAndSendHeartbeat(): Promise { - const apiKey = process.env.POSTHOG_API_KEY; - if (!apiKey) return; - - try { - if (!(await isAnalyticsEnabled())) return; - - const instanceId = await getOrCreateInstanceId(); - const { userCount, hostCount } = await collectCounts(); - const featureUsage = await collectFeatureUsage(); - - await axios.post( - `${POSTHOG_HOST}/capture/`, - { - api_key: apiKey, - event: "instance_heartbeat", - distinct_id: instanceId, - properties: { - version: getAppVersion(), - user_count: userCount, - host_count: hostCount, - ...featureUsage, - }, - }, - { timeout: 10000 }, - ); - - analyticsLogger.info("Sent daily usage heartbeat", { - operation: "analytics_heartbeat_sent", - }); - } catch (err) { - analyticsLogger.warn("Failed to send usage heartbeat", { - operation: "analytics_heartbeat_failed", - error: getErrorMessage(err), - }); - } -} - -export function startAnalyticsHeartbeat(): void { - if (getTelemetryEnvOverride() === false) { - analyticsLogger.info("Telemetry disabled by ENABLE_TELEMETRY", { - operation: "analytics_disabled_by_env", - }); - return; - } - - if (!process.env.POSTHOG_API_KEY) { - analyticsLogger.info("Analytics disabled: POSTHOG_API_KEY not set", { - operation: "analytics_disabled_no_key", - }); - return; - } - - void collectAndSendHeartbeat(); - setInterval(() => void collectAndSendHeartbeat(), HEARTBEAT_INTERVAL_MS); -} diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 32b360810..d7020d052 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -46,35 +46,6 @@ export async function updateSessionTimeout( // Tailscale settings/device API wrappers moved to // plugins/tailscale/src/frontend/tailscale-api.ts. -// ============================================================================ -// ANALYTICS SETTINGS -// ============================================================================ - -export async function getAnalyticsEnabled(): Promise<{ - enabled: boolean; - locked?: boolean; -}> { - try { - const response = await authApi.get("/users/analytics-enabled"); - return response.data; - } catch (error) { - handleApiError(error, "fetch analytics enabled setting"); - } -} - -export async function updateAnalyticsEnabled( - enabled: boolean, -): Promise<{ enabled: boolean }> { - try { - const response = await authApi.patch("/users/analytics-enabled", { - enabled, - }); - return response.data; - } catch (error) { - handleApiError(error, "update analytics enabled setting"); - } -} - // ============================================================================ // BRANDING SETTINGS // ============================================================================ diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 544bd3456..a3f6e6418 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1255,10 +1255,6 @@ "secondFactorAfterExternalLoginDesc": "Off, an enrolled second factor is only asked for after password and other local logins, same as before. On, it is asked for after every login method, including SSO and LDAP.", "allowPasswordReset": "Allow Password Reset", "allowPasswordResetDesc": "Reset code via Docker logs", - "analyticsEnabled": "Share Anonymous Usage Statistics", - "analyticsEnabledDesc": "Sends an anonymous daily count of users, hosts, and feature usage to help improve Termix. No personal data or connection details are ever included.", - "analyticsEnabledLockedDesc": "This setting is locked by the ENABLE_TELEMETRY environment variable and cannot be changed here.", - "updateAnalyticsFailed": "Failed to update analytics setting", "auditForwardingTitle": "Forward to SIEM", "auditForwardingDesc": "Ship a copy of every audit entry to an external collector as NDJSON. Local logs stay the source of truth.", "auditForwardingEnvNotice": "An environment variable also configures forwarding; the URL saved here takes precedence.", @@ -1872,6 +1868,10 @@ "title": "Read Termix settings", "consequence": "Server settings, not your hosts or credentials." }, + "plugins:read": { + "title": "See installed features", + "consequence": "It can see which features are installed and running." + }, "ui:surface": { "title": "Add its own screens", "consequence": "Tabs, panels and settings you can hide later." diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 7391f7bc9..481b396e6 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -37,8 +37,6 @@ import { getUserRoles, } from "@/main-axios"; import { - getAnalyticsEnabled, - updateAnalyticsEnabled, getBranding, updateBranding, type BrandingSettings, @@ -121,8 +119,6 @@ export function AdminSettingsPanel({ const [sessionTimeout, setSessionTimeout] = useState("24"); const [statusInterval, setStatusInterval] = useState("60"); const [logLevel, setLogLevel] = useState("info"); - const [analyticsEnabled, setAnalyticsEnabled] = useState(true); - const [analyticsLocked, setAnalyticsLocked] = useState(false); const [notificationPrivateEndpoints, setNotificationPrivateEndpoints] = useState([]); @@ -286,7 +282,6 @@ export function AdminSettingsPanel({ level, oidcProv, secondFactorExternal, - analytics, notificationEndpoints, branding, ] = await Promise.allSettled([ @@ -298,7 +293,6 @@ export function AdminSettingsPanel({ getLogLevel(), getExternalAutoProvision(), getSecondFactorAfterExternalLogin(), - getAnalyticsEnabled(), getNotificationPrivateEndpoints(), getBranding(), ]); @@ -323,10 +317,6 @@ export function AdminSettingsPanel({ } if (level.status === "fulfilled") setLogLevel(level.value.level); - if (analytics.status === "fulfilled") { - setAnalyticsEnabled(analytics.value.enabled); - setAnalyticsLocked(analytics.value.locked ?? false); - } if (notificationEndpoints.status === "fulfilled") { setNotificationPrivateEndpoints(notificationEndpoints.value); } @@ -407,18 +397,6 @@ export function AdminSettingsPanel({ } } - async function handleToggleAnalytics() { - if (analyticsLocked) return; - const newVal = !analyticsEnabled; - setAnalyticsEnabled(newVal); - try { - await updateAnalyticsEnabled(newVal); - } catch { - setAnalyticsEnabled(!newVal); - toast.error(t("admin.updateAnalyticsFailed")); - } - } - async function handleSaveNotificationPrivateEndpoints(hosts: string[]) { const previous = notificationPrivateEndpoints; setNotificationPrivateEndpoints(hosts); @@ -784,9 +762,6 @@ export function AdminSettingsPanel({ toggle("general")} - analyticsEnabled={analyticsEnabled} - analyticsLocked={analyticsLocked} - handleToggleAnalytics={handleToggleAnalytics} notificationPrivateEndpoints={notificationPrivateEndpoints} onSaveNotificationPrivateEndpoints={ handleSaveNotificationPrivateEndpoints diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index 7333e337b..a918b7b52 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -10,9 +10,6 @@ import type { TlsStatus } from "@/api/tls-api"; type GeneralSettingsSectionProps = { open: boolean; onToggle: () => void; - analyticsEnabled: boolean; - analyticsLocked: boolean; - handleToggleAnalytics: () => void; notificationPrivateEndpoints: string[]; onSaveNotificationPrivateEndpoints: (hosts: string[]) => void; allowRegistration: boolean; @@ -39,9 +36,6 @@ type GeneralSettingsSectionProps = { export function AdminGeneralSettingsSection({ open, onToggle, - analyticsEnabled, - analyticsLocked, - handleToggleAnalytics, notificationPrivateEndpoints, onSaveNotificationPrivateEndpoints, allowRegistration, @@ -74,20 +68,6 @@ export function AdminGeneralSettingsSection({ onToggle={onToggle} >
- - -
{t("admin.notificationPrivateEndpoints")}