From 43fb893b4cd0f678bf14482559cd2b18ceef4bfc Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Tue, 27 Oct 2026 15:42:44 +0800 Subject: [PATCH 1/6] feat(container-indicator): show live usage, configuration, and terminal access --- .../Resources/Localizable.xcstrings | 4148 ++++++++++++++++- .../ContainerIndicatorController.swift | 127 +- .../ContainerIndicatorDetailWindow.swift | 463 ++ .../Sources/ContainerIndicatorModels.swift | 344 +- .../Sources/ContainerIndicatorPlugin.swift | 45 +- .../Sources/ContainerIndicatorStats.swift | 103 + .../ContainerIndicatorTerminalLauncher.swift | 147 + .../ContainerIndicatorWidgetViews.swift | 103 +- .../Tests/ContainerIndicatorPluginTests.swift | 359 +- .../container-indicator-performance.md | 7 + 10 files changed, 5569 insertions(+), 277 deletions(-) create mode 100644 Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift create mode 100644 Plugins/ContainerIndicator/Sources/ContainerIndicatorStats.swift create mode 100644 Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift create mode 100644 changes/unreleased/container-indicator-performance.md diff --git a/Plugins/ContainerIndicator/Resources/Localizable.xcstrings b/Plugins/ContainerIndicator/Resources/Localizable.xcstrings index d093e57d1..d64e44d72 100644 --- a/Plugins/ContainerIndicator/Resources/Localizable.xcstrings +++ b/Plugins/ContainerIndicator/Resources/Localizable.xcstrings @@ -1,1162 +1,4550 @@ { "sourceLanguage": "zh-Hans", "strings": { - "metadata.description": { + "detail.containerMissing": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "راقب وأدر خدمة container والحاويات والأجهزة الافتراضية" + "value": "الحاوية لم تعد موجودة" } }, "de": { "stringUnit": { "state": "translated", - "value": "Container-Dienst, Container und virtuelle Maschinen überwachen und verwalten" + "value": "Container existiert nicht mehr" } }, "en": { "stringUnit": { "state": "translated", - "value": "Monitor and manage the container service, containers, and virtual machines" + "value": "Container no longer exists" } }, "es": { "stringUnit": { "state": "translated", - "value": "Supervisa y gestiona el servicio container, los contenedores y las máquinas virtuales" + "value": "El contenedor ya no existe" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Surveille et gère le service container, les conteneurs et les machines virtuelles" + "value": "Le conteneur n'existe plus" } }, "ja": { "stringUnit": { "state": "translated", - "value": "container サービス、コンテナ、仮想マシンを監視して管理します" + "value": "コンテナは存在しません" } }, "ko": { "stringUnit": { "state": "translated", - "value": "container 서비스, 컨테이너, 가상 머신을 모니터링하고 관리합니다" + "value": "컨테이너가 더 이상 존재하지 않습니다" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Monitore e gerencie o serviço container, os contêineres e as máquinas virtuais" + "value": "O contêiner não existe mais" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Мониторинг и управление службой container, контейнерами и виртуальными машинами" + "value": "Контейнер больше не существует" } }, "tr": { "stringUnit": { "state": "translated", - "value": "container hizmetini, konteynerleri ve sanal makineleri izleyin ve yönetin" + "value": "Konteyner artık mevcut değil" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "监控并管理 container 服务、容器与虚拟机" + "value": "容器已不存在" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "監控並管理 container 服務、容器與虛擬機" + "value": "容器已不存在" } } } }, - "metadata.title": { + "detail.field.command": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "حالة Apple Container" + "value": "الأمر" } }, "de": { "stringUnit": { "state": "translated", - "value": "Apple Container Status" + "value": "Befehl" } }, "en": { "stringUnit": { "state": "translated", - "value": "Apple Container Status" + "value": "Command" } }, "es": { "stringUnit": { "state": "translated", - "value": "Estado de Apple Container" + "value": "Comando" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Statut d'Apple Container" + "value": "Commande" } }, "ja": { "stringUnit": { "state": "translated", - "value": "Apple Container のステータス" + "value": "コマンド" } }, "ko": { "stringUnit": { "state": "translated", - "value": "Apple Container 상태" + "value": "명령" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Status do Apple Container" + "value": "Comando" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Состояние Apple Container" + "value": "Команда" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Apple Container Durumu" + "value": "Komut" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "Apple Container状态" + "value": "命令" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Apple Container狀態" + "value": "命令" } } } }, - "panel.action.restart": { + "detail.field.containerID": { "extractionState": "manual", "localizations": { - "zh-Hans": { + "ar": { "stringUnit": { "state": "translated", - "value": "重启" + "value": "معرّف الحاوية" } }, - "zh-Hant": { + "de": { "stringUnit": { "state": "translated", - "value": "重新啟動" + "value": "Container-ID" } }, "en": { "stringUnit": { "state": "translated", - "value": "Restart" + "value": "Container ID" } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "Neu starten" + "value": "ID del contenedor" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Reiniciar" + "value": "ID du conteneur" } }, - "fr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Redémarrer" + "value": "コンテナ ID" } }, - "ru": { + "ko": { "stringUnit": { "state": "translated", - "value": "Перезапустить" + "value": "컨테이너 ID" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Reiniciar" + "value": "ID do contêiner" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "再起動" + "value": "ID контейнера" } }, - "ko": { + "tr": { "stringUnit": { "state": "translated", - "value": "다시 시작" + "value": "Konteyner Kimliği" } }, - "ar": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "إعادة التشغيل" + "value": "容器 ID" } }, - "tr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Yeniden Başlat" + "value": "容器 ID" } } } }, - "panel.action.start": { + "detail.field.cpu": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "بدء" + "value": "CPU" } }, "de": { "stringUnit": { "state": "translated", - "value": "Starten" + "value": "CPU" } }, "en": { "stringUnit": { "state": "translated", - "value": "Start" + "value": "CPU" } }, "es": { "stringUnit": { "state": "translated", - "value": "Iniciar" + "value": "CPU" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Démarrer" + "value": "CPU" } }, "ja": { "stringUnit": { "state": "translated", - "value": "起動" + "value": "CPU" } }, "ko": { "stringUnit": { "state": "translated", - "value": "시작" + "value": "CPU" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Iniciar" + "value": "CPU" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Запустить" + "value": "ЦП" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Başlat" + "value": "CPU" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "启动" + "value": "CPU" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "啟動" + "value": "CPU" } } } }, - "panel.action.stop": { + "detail.field.createdAt": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "إيقاف" + "value": "تاريخ الإنشاء" } }, "de": { "stringUnit": { "state": "translated", - "value": "Beenden" + "value": "Erstellt" } }, "en": { "stringUnit": { "state": "translated", - "value": "Stop" + "value": "Created" } }, "es": { "stringUnit": { "state": "translated", - "value": "Detener" + "value": "Creado" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Arrêter" + "value": "Créé" } }, "ja": { "stringUnit": { "state": "translated", - "value": "停止" + "value": "作成日時" } }, "ko": { "stringUnit": { "state": "translated", - "value": "중지" + "value": "생성일" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Parar" + "value": "Criado" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Остановить" + "value": "Создан" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Durdur" + "value": "Oluşturulma" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "停止" + "value": "创建时间" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "停止" + "value": "建立時間" } } } }, - "panel.detail.containers": { + "detail.field.disk": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "الحاويات" + "value": "القرص" } }, "de": { "stringUnit": { "state": "translated", - "value": "Container" + "value": "Festplatte" } }, "en": { "stringUnit": { "state": "translated", - "value": "Containers" + "value": "Disk" } }, "es": { "stringUnit": { "state": "translated", - "value": "Contenedores" + "value": "Disco" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Conteneurs" + "value": "Disque" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コンテナ" + "value": "ディスク" } }, "ko": { "stringUnit": { "state": "translated", - "value": "컨테이너" + "value": "디스크" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Contêineres" + "value": "Disco" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Контейнеры" + "value": "Диск" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Konteynerler" + "value": "Disk" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "容器" + "value": "磁盘" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "容器" + "value": "磁碟" } } } }, - "panel.detail.empty": { + "detail.field.dnsDomain": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "لا يوجد" + "value": "نطاق DNS" } }, "de": { "stringUnit": { "state": "translated", - "value": "Keine" + "value": "DNS-Domäne" } }, "en": { "stringUnit": { "state": "translated", - "value": "None" + "value": "DNS domain" } }, "es": { "stringUnit": { "state": "translated", - "value": "Ninguno" + "value": "Dominio DNS" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Aucun" + "value": "Domaine DNS" } }, "ja": { "stringUnit": { "state": "translated", - "value": "なし" + "value": "DNS ドメイン" } }, "ko": { "stringUnit": { "state": "translated", - "value": "없음" + "value": "DNS 도메인" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Nenhum" + "value": "Domínio DNS" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Нет" + "value": "Домен DNS" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Yok" + "value": "DNS etki alanı" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "暂无" + "value": "DNS 域" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "暫無" + "value": "DNS 網域" } } } }, - "panel.detail.machines": { + "detail.field.dnsServers": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "الأجهزة الافتراضية" + "value": "خوادم DNS" } }, "de": { "stringUnit": { "state": "translated", - "value": "Virtuelle Maschinen" + "value": "DNS-Server" } }, "en": { "stringUnit": { "state": "translated", - "value": "Virtual Machines" + "value": "DNS servers" } }, "es": { "stringUnit": { "state": "translated", - "value": "Máquinas virtuales" + "value": "Servidores DNS" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Machines virtuelles" + "value": "Serveurs DNS" } }, "ja": { "stringUnit": { "state": "translated", - "value": "仮想マシン" + "value": "DNS サーバ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "가상 머신" + "value": "DNS 서버" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Máquinas virtuais" + "value": "Servidores DNS" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Виртуальные машины" + "value": "DNS-серверы" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Sanal Makineler" + "value": "DNS sunucuları" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "虚拟机" + "value": "DNS 服务器" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "虛擬機" + "value": "DNS 伺服器" } } } }, - "panel.detail.resourcesFormat": { + "detail.field.environment": { "extractionState": "manual", "localizations": { - "zh-Hans": { + "ar": { "stringUnit": { "state": "translated", - "value": "CPU %1$d 核 · 内存 %2$@ · 存储 %3$@" + "value": "متغيرات البيئة" } }, - "zh-Hant": { + "de": { "stringUnit": { "state": "translated", - "value": "CPU %1$d 核 · 記憶體 %2$@ · 儲存 %3$@" + "value": "Umgebung" } }, "en": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Memory %2$@ · Storage %3$@" + "value": "Environment" } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Arbeitsspeicher %2$@ · Speicher %3$@" + "value": "Entorno" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Memoria %2$@ · Almacenamiento %3$@" + "value": "Environnement" } }, - "fr": { + "ja": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Mémoire %2$@ · Stockage %3$@" + "value": "環境変数" } }, - "ru": { + "ko": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Память %2$@ · Хранилище %3$@" + "value": "환경 변수" } }, "pt": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Memória %2$@ · Armazenamento %3$@" + "value": "Ambiente" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · メモリ %2$@ · ストレージ %3$@" + "value": "Переменные среды" } }, - "ko": { + "tr": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · 메모리 %2$@ · 스토리지 %3$@" + "value": "Ortam" } }, - "ar": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "الوحدة المعالجة %1$d · الذاكرة %2$@ · التخزين %3$@" + "value": "环境变量" } }, - "tr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "CPU %1$d · Bellek %2$@ · Depolama %3$@" + "value": "環境變數" } } } }, - "panel.detail.service": { + "detail.field.environmentKeys": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "خدمة الحاويات" + "value": "أسماء المتغيرات" } }, "de": { "stringUnit": { "state": "translated", - "value": "Container-Dienst" + "value": "Variablennamen" } }, "en": { "stringUnit": { "state": "translated", - "value": "Container Service" + "value": "Variable names" } }, "es": { "stringUnit": { "state": "translated", - "value": "Servicio de contenedores" + "value": "Nombres de variables" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Service de conteneurs" + "value": "Noms des variables" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コンテナサービス" + "value": "変数名" } }, "ko": { "stringUnit": { "state": "translated", - "value": "컨테이너 서비스" + "value": "변수 이름" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Serviço de contêineres" + "value": "Nomes das variáveis" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Служба контейнеров" + "value": "Имена переменных" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Konteyner Hizmeti" + "value": "Değişken adları" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "容器服务" + "value": "变量名" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "容器服務" + "value": "變數名稱" } } } }, - "panel.detail.serviceNotStarted": { + "detail.field.hostname": { "extractionState": "manual", "localizations": { - "zh-Hans": { + "ar": { "stringUnit": { "state": "translated", - "value": "服务未启动" + "value": "اسم المضيف" } }, - "zh-Hant": { + "de": { "stringUnit": { "state": "translated", - "value": "服務未啟動" + "value": "Hostname" } }, "en": { "stringUnit": { "state": "translated", - "value": "Service not started" + "value": "Hostname" } }, - "de": { + "es": { "stringUnit": { "state": "translated", - "value": "Dienst nicht gestartet" + "value": "Nombre de host" } }, - "es": { + "fr": { "stringUnit": { "state": "translated", - "value": "Servicio no iniciado" + "value": "Nom d'hôte" } }, - "fr": { + "ja": { "stringUnit": { "state": "translated", - "value": "Service non démarré" + "value": "ホスト名" } }, - "ru": { + "ko": { "stringUnit": { "state": "translated", - "value": "Служба не запущена" + "value": "호스트 이름" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Serviço não iniciado" + "value": "Nome do host" } }, - "ja": { + "ru": { "stringUnit": { "state": "translated", - "value": "サービス未起動" + "value": "Имя хоста" } }, - "ko": { + "tr": { "stringUnit": { "state": "translated", - "value": "서비스가 실행되지 않음" + "value": "Ana bilgisayar adı" } }, - "ar": { + "zh-Hans": { "stringUnit": { "state": "translated", - "value": "الخدمة غير مشغّلة" + "value": "主机名" } }, - "tr": { + "zh-Hant": { "stringUnit": { "state": "translated", - "value": "Hizmet başlatılmadı" + "value": "主機名稱" } } } }, - "panel.error.actionFailedFormat": { + "detail.field.imageDigest": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "فشلت العملية: %@" + "value": "ملخص الصورة" } }, "de": { "stringUnit": { "state": "translated", - "value": "Vorgang fehlgeschlagen: %@" + "value": "Image-Digest" } }, "en": { "stringUnit": { "state": "translated", - "value": "Operation failed: %@" + "value": "Image digest" } }, "es": { "stringUnit": { "state": "translated", - "value": "La operación falló: %@" + "value": "Resumen de la imagen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Opération échouée : %@" + "value": "Empreinte de l'image" } }, "ja": { "stringUnit": { "state": "translated", - "value": "操作に失敗しました:%@" + "value": "イメージダイジェスト" } }, "ko": { "stringUnit": { "state": "translated", - "value": "작업 실패: %@" + "value": "이미지 다이제스트" } }, "pt": { "stringUnit": { "state": "translated", - "value": "A operação falhou: %@" + "value": "Resumo da imagem" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Операция не выполнена: %@" + "value": "Дайджест образа" } }, "tr": { "stringUnit": { "state": "translated", - "value": "İşlem başarısız: %@" + "value": "İmaj özeti" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "操作失败:%@" + "value": "镜像摘要" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "操作失敗:%@" + "value": "映像摘要" } } } }, - "panel.error.commandFailed": { + "detail.field.imageSize": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "فشل تنفيذ الأمر" + "value": "حجم الصورة" } }, "de": { "stringUnit": { "state": "translated", - "value": "Befehl fehlgeschlagen" + "value": "Image-Größe" } }, "en": { "stringUnit": { "state": "translated", - "value": "Command failed" + "value": "Image size" } }, "es": { "stringUnit": { "state": "translated", - "value": "El comando falló" + "value": "Tamaño de la imagen" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Échec de la commande" + "value": "Taille de l'image" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コマンドの実行に失敗しました" + "value": "イメージサイズ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "명령 실행에 실패했습니다" + "value": "이미지 크기" } }, "pt": { "stringUnit": { "state": "translated", - "value": "O comando falhou" + "value": "Tamanho da imagem" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Команда завершилась с ошибкой" + "value": "Размер образа" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Komut çalıştırılamadı" + "value": "İmaj boyutu" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "命令执行失败" + "value": "镜像大小" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "指令執行失敗" + "value": "映像大小" } } } }, - "panel.error.commandNotFound": { + "detail.field.memory": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "لم يتم العثور على أمر container" + "value": "الذاكرة" } }, "de": { "stringUnit": { "state": "translated", - "value": "container-Befehl nicht gefunden" + "value": "Arbeitsspeicher" } }, "en": { "stringUnit": { "state": "translated", - "value": "container command not found" + "value": "Memory" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se encontró el comando container" + "value": "Memoria" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Commande container introuvable" + "value": "Mémoire" } }, "ja": { "stringUnit": { "state": "translated", - "value": "container コマンドが見つかりません" + "value": "メモリ" } }, "ko": { "stringUnit": { "state": "translated", - "value": "container 명령을 찾을 수 없습니다" + "value": "메모리" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Comando container não encontrado" + "value": "Memória" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Команда container не найдена" + "value": "Память" } }, "tr": { "stringUnit": { "state": "translated", - "value": "container komutu bulunamadı" + "value": "Bellek" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "未找到 container 命令" + "value": "内存" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "找不到 container 指令" + "value": "記憶體" } } } }, - "panel.error.containersReadFailed": { + "detail.field.network": { "extractionState": "manual", "localizations": { "ar": { "stringUnit": { "state": "translated", - "value": "تعذّرت قراءة قائمة الحاويات" + "value": "الشبكة" } }, "de": { "stringUnit": { "state": "translated", - "value": "Containerliste konnte nicht gelesen werden" + "value": "Netzwerk" } }, "en": { "stringUnit": { "state": "translated", - "value": "Failed to read containers" + "value": "Network" } }, "es": { "stringUnit": { "state": "translated", - "value": "No se pudo leer la lista de contenedores" + "value": "Red" } }, "fr": { "stringUnit": { "state": "translated", - "value": "Impossible de lire la liste des conteneurs" + "value": "Réseau" } }, "ja": { "stringUnit": { "state": "translated", - "value": "コンテナ一覧を読み取れませんでした" + "value": "ネットワーク" } }, "ko": { "stringUnit": { "state": "translated", - "value": "컨테이너 목록을 읽지 못했습니다" + "value": "네트워크" } }, "pt": { "stringUnit": { "state": "translated", - "value": "Não foi possível ler a lista de contêineres" + "value": "Rede" } }, "ru": { "stringUnit": { "state": "translated", - "value": "Не удалось прочитать список контейнеров" + "value": "Сеть" } }, "tr": { "stringUnit": { "state": "translated", - "value": "Konteyner listesi okunamadı" + "value": "Ağ" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "容器列表读取失败" + "value": "网络" } }, "zh-Hant": { "stringUnit": { "state": "translated", - "value": "容器清單讀取失敗" + "value": "網路" } } } }, - "panel.error.machinesReadFailed": { + "detail.field.platform": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المنصة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Plattform" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Platform" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Plataforma" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Plateforme" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プラットフォーム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "플랫폼" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Plataforma" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Платформа" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Platform" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "平台" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "平台" + } + } + } + }, + "detail.field.ports": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المنافذ" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ports" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ports" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Puertos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ports" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポート" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포트" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Portas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Порты" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bağlantı noktaları" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "端口" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "連接埠" + } + } + } + }, + "detail.field.readOnly": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "للقراءة فقط" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur lesen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Read-only" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo lectura" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture seule" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取り専用" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 전용" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Somente leitura" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Только чтение" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Salt okunur" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "只读" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "唯讀" + } + } + } + }, + "detail.field.rosetta": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Rosetta" + } + } + } + }, + "detail.field.runtime": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وقت التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laufzeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runtime" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécution" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ランタイム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "런타임" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tempo de execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Среда выполнения" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma zamanı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行階段" + } + } + } + }, + "detail.field.ssh": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "SSH" + } + } + } + }, + "detail.field.status": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحالة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "État" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ステータス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "상태" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durum" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "狀態" + } + } + } + }, + "detail.field.storageQuota": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حصة التخزين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicherkontingent" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Storage quota" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuota de almacenamiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Quota de stockage" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ストレージクォータ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저장 공간 할당량" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Cota de armazenamento" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Квота хранилища" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Depolama kotası" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "存储配额" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "儲存配額" + } + } + } + }, + "detail.field.user": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المستخدم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Benutzer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "User" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Usuario" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisateur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ユーザ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용자" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Usuário" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пользователь" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kullanıcı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用户" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用者" + } + } + } + }, + "detail.field.workdir": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "دليل العمل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Arbeitsverzeichnis" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Working directory" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Directorio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répertoire de travail" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作業ディレクトリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 디렉터리" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Diretório de trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Рабочий каталог" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma dizini" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "工作目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "工作目錄" + } + } + } + }, + "detail.section.live": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مباشر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Live" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En vivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En direct" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リアルタイム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실시간" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ao vivo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В реальном времени" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Canlı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時" + } + } + } + }, + "detail.section.network": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الشبكة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Red" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réseau" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ネットワーク" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "네트워크" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Rede" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сеть" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ağ" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "网络" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網路" + } + } + } + }, + "detail.section.overview": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نظرة عامة" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Übersicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Overview" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resumen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aperçu" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "概要" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "개요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Visão geral" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Обзор" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Genel bakış" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "概览" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "概覽" + } + } + } + }, + "detail.section.process": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "العملية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Prozess" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Process" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Proceso" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Processus" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロセス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "프로세스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Processo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Процесс" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Süreç" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "进程" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "處理程序" + } + } + } + }, + "detail.section.resources": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الموارد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ressourcen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Resources" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recursos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ressources" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リソース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "리소스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Recursos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ресурсы" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kaynaklar" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "资源" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資源" + } + } + } + }, + "detail.section.runtime": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "وقت التشغيل" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Laufzeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Runtime" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tiempo de ejecución" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Exécution" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ランタイム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "런타임" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Tempo de execução" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Среда выполнения" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Çalışma zamanı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "运行时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "執行階段" + } + } + } + }, + "detail.value.coresFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d نوى" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Kerne" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d cores" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d núcleos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d cœurs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d コア" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 코어" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d núcleos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d ядер" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%d çekirdek" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 核" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 核心" + } + } + } + }, + "detail.value.diskUsageFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "قراءة %1$@ · كتابة %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Lesen %1$@ · Schreiben %2$@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Read %1$@ · Write %2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Lectura %1$@ · Escritura %2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture %1$@ · Écriture %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "読み取り %1$@ · 書き込み %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "읽기 %1$@ · 쓰기 %2$@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Leitura %1$@ · Gravação %2$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Чтение %1$@ · Запись %2$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Okuma %1$@ · Yazma %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读 %1$@ · 写 %2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取 %1$@ · 寫入 %2$@" + } + } + } + }, + "detail.value.environmentCountFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d عناصر (القيم مخفية)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Einträge (Werte ausgeblendet)" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%d entries (values hidden)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d entradas (valores ocultos)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d entrées (valeurs masquées)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 件(値は非表示)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d개(값 숨김)" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d entradas (valores ocultos)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d записей (значения скрыты)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%d öğe (değerler gizli)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 项(值已隐藏)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 項(值已隱藏)" + } + } + } + }, + "detail.value.memoryUsageFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ / %2$@" + } + } + } + }, + "detail.value.no": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "いいえ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "아니요" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hayır" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "否" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "否" + } + } + } + }, + "detail.value.none": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا شيء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguno" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yok" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無" + } + } + } + }, + "detail.value.notSet": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير محدد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Nicht festgelegt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not set" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin establecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non défini" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설정 안 됨" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não definido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не задано" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ayarlanmadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未设置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未設定" + } + } + } + }, + "detail.value.yes": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "نعم" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ja" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Yes" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sí" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Oui" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "はい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "예" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sim" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Да" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Evet" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "是" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "是" + } + } + } + }, + "metadata.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "راقب وأدر خدمة container والحاويات والأجهزة الافتراضية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Container-Dienst, Container und virtuelle Maschinen überwachen und verwalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Monitor and manage the container service, containers, and virtual machines" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Supervisa y gestiona el servicio container, los contenedores y las máquinas virtuales" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveille et gère le service container, les conteneurs et les machines virtuelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "container サービス、コンテナ、仮想マシンを監視して管理します" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "container 서비스, 컨테이너, 가상 머신을 모니터링하고 관리합니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitore e gerencie o serviço container, os contêineres e as máquinas virtuais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мониторинг и управление службой container, контейнерами и виртуальными машинами" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "container hizmetini, konteynerleri ve sanal makineleri izleyin ve yönetin" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监控并管理 container 服务、容器与虚拟机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "監控並管理 container 服務、容器與虛擬機" + } + } + } + }, + "metadata.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "حالة Apple Container" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Apple Container Status" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Apple Container Status" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estado de Apple Container" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Statut d'Apple Container" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Apple Container のステータス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Apple Container 상태" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Status do Apple Container" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Состояние Apple Container" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Apple Container Durumu" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Apple Container状态" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Apple Container狀態" + } + } + } + }, + "panel.action.details": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض التكوين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfiguration anzeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "View configuration" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver configuración" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher la configuration" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定を表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성 보기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver configuração" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показать конфигурацию" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yapılandırmayı görüntüle" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "查看配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "檢視設定" + } + } + } + }, + "panel.action.restart": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重启" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新啟動" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Restart" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neu starten" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Redémarrer" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перезапустить" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Reiniciar" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다시 시작" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التشغيل" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden Başlat" + } + } + } + }, + "panel.action.start": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "بدء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Starten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Start" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrer" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시작" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Iniciar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запустить" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlat" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動" + } + } + } + }, + "panel.action.stop": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Beenden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stop" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Detener" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Arrêter" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중지" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Parar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Остановить" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Durdur" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停止" + } + } + } + }, + "panel.action.terminal": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを開く" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 열기" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Abrir terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть терминал" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminali aç" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟終端機" + } + } + } + }, + "panel.detail.configPrefix": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التكوين" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Konfig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Config" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Config." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Config." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "구성" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Config." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Конфиг." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yapılandırma" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "配置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "設定" + } + } + } + }, + "panel.detail.containers": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الحاويات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Container" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Containers" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Contenedores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Conteneurs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテナ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컨테이너" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Contêineres" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Контейнеры" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Konteynerler" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "容器" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "容器" + } + } + } + }, + "panel.detail.empty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يوجد" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "None" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ninguno" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucun" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "なし" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "없음" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nenhum" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нет" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yok" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂无" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "暫無" + } + } + } + }, + "panel.detail.liveFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المعالج %1$@ · الذاكرة %2$@ · القرص %3$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · Speicher %2$@ · Festplatte %3$@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · Memory %2$@ · Disk %3$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · Memoria %2$@ · Disco %3$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · Mémoire %2$@ · Disque %3$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · メモリ %2$@ · ディスク %3$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · 메모리 %2$@ · 디스크 %3$@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · Memória %2$@ · Disco %3$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "ЦП %1$@ · Память %2$@ · Диск %3$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · Bellek %2$@ · Disk %3$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · 内存 %2$@ · 磁盘 %3$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$@ · 記憶體 %2$@ · 磁碟 %3$@" + } + } + } + }, + "panel.detail.livePrefix": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مباشر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Live" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Live" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "En vivo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "En direct" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リアルタイム" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "실시간" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ao vivo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В реальном времени" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Canlı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "实时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時" + } + } + } + }, + "panel.detail.machines": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الأجهزة الافتراضية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Virtuelle Maschinen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Virtual Machines" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Máquinas virtuales" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Machines virtuelles" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "仮想マシン" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "가상 머신" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Máquinas virtuais" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Виртуальные машины" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sanal Makineler" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "虚拟机" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "虛擬機" + } + } + } + }, + "panel.detail.resourcesFormat": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d 核 · 内存 %2$@ · 存储 %3$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d 核 · 記憶體 %2$@ · 儲存 %3$@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Memory %2$@ · Storage %3$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Arbeitsspeicher %2$@ · Speicher %3$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Memoria %2$@ · Almacenamiento %3$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Mémoire %2$@ · Stockage %3$@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Память %2$@ · Хранилище %3$@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Memória %2$@ · Armazenamento %3$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · メモリ %2$@ · ストレージ %3$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · 메모리 %2$@ · 스토리지 %3$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الوحدة المعالجة %1$d · الذاكرة %2$@ · التخزين %3$@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "CPU %1$d · Bellek %2$@ · Depolama %3$@" + } + } + } + }, + "panel.detail.service": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خدمة الحاويات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Container-Dienst" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Container Service" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Servicio de contenedores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Service de conteneurs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテナサービス" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컨테이너 서비스" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Serviço de contêineres" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба контейнеров" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Konteyner Hizmeti" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "容器服务" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "容器服務" + } + } + } + }, + "panel.detail.serviceNotStarted": { + "extractionState": "manual", + "localizations": { + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "服务未启动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "服務未啟動" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Service not started" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dienst nicht gestartet" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Servicio no iniciado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Service non démarré" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Служба не запущена" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Serviço não iniciado" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サービス未起動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서비스가 실행되지 않음" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الخدمة غير مشغّلة" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hizmet başlatılmadı" + } + } + } + }, + "panel.error.actionFailedFormat": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشلت العملية: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorgang fehlgeschlagen: %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Operation failed: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La operación falló: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opération échouée : %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作に失敗しました:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 실패: %@" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "A operação falhou: %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Операция не выполнена: %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İşlem başarısız: %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "操作失败:%@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "操作失敗:%@" + } + } + } + }, + "panel.error.commandFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل تنفيذ الأمر" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Befehl fehlgeschlagen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Command failed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El comando falló" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la commande" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コマンドの実行に失敗しました" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "명령 실행에 실패했습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O comando falhou" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда завершилась с ошибкой" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Komut çalıştırılamadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "命令执行失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指令執行失敗" + } + } + } + }, + "panel.error.commandNotFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على أمر container" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "container-Befehl nicht gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "container command not found" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró el comando container" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commande container introuvable" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "container コマンドが見つかりません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "container 명령을 찾을 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Comando container não encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда container не найдена" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "container komutu bulunamadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到 container 命令" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到 container 指令" + } + } + } + }, + "panel.error.containersReadFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذّرت قراءة قائمة الحاويات" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Containerliste konnte nicht gelesen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to read containers" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer la lista de contenedores" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire la liste des conteneurs" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテナ一覧を読み取れませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컨테이너 목록을 읽지 못했습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível ler a lista de contêineres" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать список контейнеров" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Konteyner listesi okunamadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "容器列表读取失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "容器清單讀取失敗" + } + } + } + }, + "panel.error.machinesReadFailed": { "extractionState": "manual", "localizations": { "ar": { @@ -1310,6 +4698,314 @@ } } }, + "panel.error.statsReadFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر قراءة بيانات الأداء" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Leistungsdaten konnten nicht gelesen werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to read performance data" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron leer los datos de rendimiento" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de lire les données de performance" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "パフォーマンスデータを読み取れませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "성능 데이터를 읽지 못했습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Falha ao ler os dados de desempenho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать данные о производительности" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Performans verileri okunamadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "性能数据读取失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "效能資料讀取失敗" + } + } + } + }, + "panel.error.terminalInvalidIdentifier": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف الحاوية غير صالح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ungültige Container-ID" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid container identifier" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Identificador de contenedor no válido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Identifiant de conteneur non valide" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテナ識別子が無効です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컨테이너 식별자가 유효하지 않습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Identificador de contêiner inválido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недопустимый идентификатор контейнера" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Geçersiz konteyner tanımlayıcısı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "容器标识无效" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "容器識別碼無效" + } + } + } + }, + "panel.error.terminalOpenFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر فتح الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal konnte nicht geöffnet werden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unable to open terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo abrir el terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible d'ouvrir le terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルを開けませんでした" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널을 열 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível abrir o terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось открыть терминал" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal açılamadı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法打开终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法開啟終端機" + } + } + } + }, + "panel.error.terminalUnavailable": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أمر container غير متاح" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "container-Befehl nicht verfügbar" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "container command unavailable" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Comando container no disponible" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Commande container indisponible" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "container コマンドを利用できません" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "container 명령을 사용할 수 없습니다" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Comando container indisponível" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Команда container недоступна" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "container komutu kullanılamıyor" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "container 命令不可用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "container 指令無法使用" + } + } + } + }, "panel.status.running": { "extractionState": "manual", "localizations": { diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift index c219badb1..34843e664 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift @@ -26,6 +26,8 @@ public final class ContainerIndicatorController: ObservableObject { private let commandRunner: any ContainerCommandRunning private let commandPathProvider: () -> String? private let localization: PluginLocalization + private let terminalLauncher: any ContainerTerminalLaunching + private let now: () -> Date private var pollTask: Task? /// The single refresh currently allowed to publish. Superseding it or /// hiding the panel cancels it, which also terminates the CLI command it @@ -34,17 +36,26 @@ public final class ContainerIndicatorController: ObservableObject { /// Identifies the newest refresh; older ones compare against it and drop /// the results they gathered before being replaced. private var refreshGeneration: UInt64 = 0 + /// Previous cumulative counters and the moment they were read. Live rates + /// only exist as a delta, and the baseline is dropped whenever monitoring + /// pauses so a long gap never turns into an averaged "current" value. + private var previousStats: [String: ContainerStatsCounters] = [:] + private var previousStatsAt: Date? public private(set) var isMonitoring = false public init( commandRunner: any ContainerCommandRunning = ContainerCommandRunner(), localization: PluginLocalization = PluginLocalization(bundle: .main), - commandPathProvider: @escaping () -> String? = { ContainerExecutableLocator.locate() } + commandPathProvider: @escaping () -> String? = { ContainerExecutableLocator.locate() }, + terminalLauncher: any ContainerTerminalLaunching = ContainerTerminalLauncher(), + now: @escaping () -> Date = Date.init ) { self.commandRunner = commandRunner self.localization = localization self.commandPath = commandPathProvider() self.commandPathProvider = commandPathProvider + self.terminalLauncher = terminalLauncher + self.now = now } public var isCommandAvailable: Bool { @@ -101,6 +112,10 @@ public final class ContainerIndicatorController: ObservableObject { isMonitoring = false pollTask?.cancel() pollTask = nil + // A paused monitor has no usable baseline: rates computed across the + // gap would describe an average instead of the current load. + previousStats = [:] + previousStatsAt = nil // Cancelling the refresh also cancels the CLI command it is waiting on, // so a hidden panel never leaves a `container` process running behind it. cancelInFlightRefresh() @@ -184,6 +199,8 @@ public final class ContainerIndicatorController: ObservableObject { guard status == .running else { isChecking = false + previousStats = [:] + previousStatsAt = nil if status == .unknown { checkErrorMessage = failureDetail( exitCode: statusResult.exitCode, @@ -200,7 +217,7 @@ public final class ContainerIndicatorController: ObservableObject { status: status, containers: keepsEntries ? previous.containers : [], machines: keepsEntries ? previous.machines : [], - updatedAt: Date() + updatedAt: now() ), generation: generation ) @@ -293,6 +310,72 @@ public final class ContainerIndicatorController: ObservableObject { } } + // Live counters come from one stats read for every running container. + // The command samples twice internally, so a refresh takes about two + // seconds longer while anything runs; with nothing running it is + // skipped entirely. + var statsFailure: String? + if containers.contains(where: \.isRunning) { + let statsResult = try? await commandRunner.run( + executable: path, + arguments: ["stats", "--format", "json", "--no-stream"] + ) + guard isCurrent(generation: generation) else { return } + + if statsResult?.exitCode == 0 { + let counters = ContainerIndicatorParser.stats( + fromJSON: statsResult?.standardOutput ?? "" + ) + let sampledAt = now() + let elapsed = previousStatsAt.map { sampledAt.timeIntervalSince($0) } ?? 0 + containers = containers.map { container in + guard container.isRunning else { return container } + guard let current = counters[container.id] else { + return container.withPerformance(nil) + } + return container.withPerformance( + ContainerIndicatorStatsMath.performance( + previous: previousStats[container.id], + current: current, + elapsed: elapsed, + // 100% means every core the container was given is + // busy, matching the "CPU N 核" line above it. + cores: max(1, container.cpus), + sampledAt: sampledAt + ) + ) + } + previousStats = counters + previousStatsAt = sampledAt + } else { + statsFailure = listFailureMessage( + label: localization.string( + "panel.error.statsReadFailed", + defaultValue: "性能数据读取失败" + ), + exitCode: statsResult?.exitCode, + standardError: statsResult?.standardError ?? "" + ) + // A failed read keeps the numbers already on screen; the card + // marks them as stale with the warning line. + let previousPerformance = Dictionary( + uniqueKeysWithValues: previous.containers.compactMap { container in + container.performance.map { (container.id, $0) } + } + ) + containers = containers.map { container in + guard container.performance == nil, + let performance = previousPerformance[container.id] else { + return container + } + return container.withPerformance(performance) + } + } + } else { + previousStats = [:] + previousStatsAt = nil + } + isChecking = false checkErrorMessage = nil publish( @@ -300,9 +383,10 @@ public final class ContainerIndicatorController: ObservableObject { status: .running, containers: containers, machines: machines, - updatedAt: Date(), + updatedAt: now(), containersFailure: containersFailure, - machinesFailure: machinesFailure + machinesFailure: machinesFailure, + statsFailure: statsFailure ), generation: generation ) @@ -343,6 +427,39 @@ public final class ContainerIndicatorController: ObservableObject { ]) } + // MARK: - Terminal + + /// Opens an interactive shell in the user's default terminal. The CLI must + /// run in a real TTY, so the launcher hands a `.command` script to + /// LaunchServices instead of spawning the process here. + public func openTerminal(target: ContainerTerminalTarget) { + resolveCommandPathIfNeeded() + guard let path = commandPath else { + operationErrorMessage = localization.string( + "panel.error.terminalUnavailable", + defaultValue: "container 命令不可用" + ) + onStateChange?() + return + } + + operationErrorMessage = nil + do { + try terminalLauncher.launch(target: target, executable: path) + } catch ContainerTerminalLaunchError.invalidIdentifier { + operationErrorMessage = localization.string( + "panel.error.terminalInvalidIdentifier", + defaultValue: "容器标识无效" + ) + } catch { + operationErrorMessage = localization.string( + "panel.error.terminalOpenFailed", + defaultValue: "无法打开终端" + ) + } + onStateChange?() + } + /// Runs a command sequence as one operation: it stops at the first failure /// and publishes exactly one outcome, then refreshes the snapshot. private func perform(sequences: [[String]]) { @@ -409,7 +526,7 @@ public final class ContainerIndicatorController: ObservableObject { status: status, containers: snapshot.containers, machines: snapshot.machines, - updatedAt: Date() + updatedAt: now() ) } diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift new file mode 100644 index 000000000..23253f64d --- /dev/null +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift @@ -0,0 +1,463 @@ +import AppKit +import MacToolsPluginKit +import SwiftUI + +// MARK: - Presenter + +/// Owns the single configuration window. The window observes the controller, so +/// it keeps showing live numbers while it stays open. +@MainActor +final class ContainerIndicatorDetailPresenter { + private var window: ContainerIndicatorDetailWindow? + + /// Called when the window appears and when the user closes it, so the + /// plugin can keep polling while the inspector is on screen. + var onVisibilityChange: ((Bool) -> Void)? + + var isPresenting: Bool { + window?.isVisible == true + } + + func present( + container: ContainerInstance, + controller: ContainerIndicatorController, + localization: PluginLocalization + ) { + close() + + let window = ContainerIndicatorDetailWindow( + containerID: container.id, + title: container.name, + controller: controller, + localization: localization + ) + window.onDismiss = { [weak self] in + self?.window = nil + self?.onVisibilityChange?(false) + } + + PluginPresentationSafety.prepareForWindowOrdering(window) + position(window) + window.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + self.window = window + onVisibilityChange?(true) + } + + /// Programmatic close (plugin deactivation); does not report visibility, + /// because the caller already owns the monitoring decision. + func close() { + guard let window else { return } + window.onDismiss = nil + window.orderOut(nil) + self.window = nil + } + + private func position(_ window: NSWindow) { + let pointerLocation = NSEvent.mouseLocation + let screen = NSScreen.screens.first { $0.frame.contains(pointerLocation) } ?? NSScreen.main + let visibleFrame = screen?.visibleFrame + ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let size = window.frame.size + window.setFrameOrigin(NSPoint( + x: visibleFrame.midX - size.width / 2, + y: visibleFrame.midY - size.height / 2 + )) + } +} + +// MARK: - Window + +@MainActor +final class ContainerIndicatorDetailWindow: NSPanel { + var onDismiss: (() -> Void)? + + init( + containerID: String, + title: String, + controller: ContainerIndicatorController, + localization: PluginLocalization + ) { + let size = NSSize(width: 520, height: 620) + super.init( + contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable, .resizable], + backing: .buffered, + defer: false + ) + + self.title = title + isFloatingPanel = true + level = .floating + hidesOnDeactivate = false + isReleasedWhenClosed = false + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + backgroundColor = .windowBackgroundColor + minSize = NSSize(width: 440, height: 420) + setContentSize(size) + + contentView = NSHostingView( + rootView: ContainerIndicatorDetailView( + containerID: containerID, + controller: controller, + localization: localization + ) + ) + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } + + override func performClose(_ sender: Any?) { + orderOut(nil) + onDismiss?() + } + + override func cancelOperation(_ sender: Any?) { + performClose(sender) + } +} + +// MARK: - Content + +private struct ContainerIndicatorDetailView: View { + let containerID: String + @ObservedObject var controller: ContainerIndicatorController + let localization: PluginLocalization + + @ObservedObject private var runtimeLocale = PluginRuntimeLocalization.source + + private var container: ContainerInstance? { + controller.snapshot.containers.first { $0.id == containerID } + } + + var body: some View { + let _ = runtimeLocale.revision + Group { + if let container { + VStack(spacing: 0) { + header(container) + Divider() + Form { + overviewSection(container) + resourcesSection(container) + runtimeSection(container) + networkSection(container) + processSection(container) + if container.isRunning { + liveSection(container) + } + } + .formStyle(.grouped) + } + } else { + missingState + } + } + .environment(\.locale, runtimeLocale.locale) + .environment( + \.layoutDirection, + runtimeLocale.locale.language.characterDirection == .rightToLeft + ? .rightToLeft + : .leftToRight + ) + } + + // MARK: Header + + private func header(_ container: ContainerInstance) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "shippingbox") + .font(.title3) + .foregroundStyle(Color(nsColor: .systemBlue)) + Text(container.name) + .font(.title3.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + statusBadge(container) + Spacer(minLength: 0) + } + if !container.image.isEmpty { + Text(container.image) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .textSelection(.enabled) + } + } + .padding(.horizontal, 20) + .padding(.top, 16) + .padding(.bottom, 10) + } + + private func statusBadge(_ container: ContainerInstance) -> some View { + let text = container.isRunning + ? localization.string("panel.status.running", defaultValue: "运行中") + : localization.string("panel.status.stopped", defaultValue: "已停止") + let color = container.isRunning ? Color(nsColor: .systemGreen) : Color.secondary + return Text(text) + .font(.caption.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 6) + .padding(.vertical, 1) + .background(Capsule().fill(color.opacity(0.15))) + .fixedSize() + } + + // MARK: Sections + + private func overviewSection(_ container: ContainerInstance) -> some View { + Section { + field( + localization.string("detail.field.status", defaultValue: "状态"), + container.isRunning + ? localization.string("panel.status.running", defaultValue: "运行中") + : localization.string("panel.status.stopped", defaultValue: "已停止") + ) + field( + localization.string("detail.field.containerID", defaultValue: "容器 ID"), + container.id + ) + field( + localization.string("detail.field.imageDigest", defaultValue: "镜像摘要"), + container.configuration.imageDigest.isEmpty + ? none + : container.configuration.imageDigest + ) + field( + localization.string("detail.field.createdAt", defaultValue: "创建时间"), + formattedDate(container.configuration.createdAt) + ) + } header: { + sectionTitle(localization.string("detail.section.overview", defaultValue: "概览")) + } + } + + private func resourcesSection(_ container: ContainerInstance) -> some View { + Section { + field( + localization.string("detail.field.cpu", defaultValue: "CPU"), + container.cpus > 0 + ? localization.format("detail.value.coresFormat", defaultValue: "%d 核", container.cpus) + : none + ) + field( + localization.string("detail.field.memory", defaultValue: "内存"), + container.memoryBytes > 0 + ? ContainerIndicatorFormatting.bytes(container.memoryBytes) + : none + ) + field( + localization.string("detail.field.storageQuota", defaultValue: "存储配额"), + container.configuration.storageQuotaBytes.map(ContainerIndicatorFormatting.bytes) + ?? localization.string("detail.value.notSet", defaultValue: "未设置") + ) + field( + localization.string("detail.field.imageSize", defaultValue: "镜像大小"), + container.imageStorageBytes.map(ContainerIndicatorFormatting.bytes) ?? none + ) + } header: { + sectionTitle(localization.string("detail.section.resources", defaultValue: "资源")) + } + } + + private func runtimeSection(_ container: ContainerInstance) -> some View { + let configuration = container.configuration + return Section { + field( + localization.string("detail.field.platform", defaultValue: "平台"), + configuration.platform.isEmpty ? none : configuration.platform + ) + field( + localization.string("detail.field.runtime", defaultValue: "运行时"), + configuration.runtimeHandler.isEmpty ? none : configuration.runtimeHandler + ) + field( + localization.string("detail.field.readOnly", defaultValue: "只读"), + boolean(configuration.readOnly) + ) + field( + localization.string("detail.field.rosetta", defaultValue: "Rosetta"), + boolean(configuration.rosetta) + ) + field( + localization.string("detail.field.ssh", defaultValue: "SSH"), + boolean(configuration.ssh) + ) + } header: { + sectionTitle(localization.string("detail.section.runtime", defaultValue: "运行时")) + } + } + + private func networkSection(_ container: ContainerInstance) -> some View { + let configuration = container.configuration + return Section { + field( + localization.string("detail.field.network", defaultValue: "网络"), + configuration.networks.isEmpty ? none : configuration.networks.joined(separator: ", ") + ) + field( + localization.string("detail.field.hostname", defaultValue: "主机名"), + configuration.hostname.isEmpty ? none : configuration.hostname + ) + field( + localization.string("detail.field.ports", defaultValue: "端口"), + configuration.ports.isEmpty ? none : configuration.ports.joined(separator: "\n") + ) + field( + localization.string("detail.field.dnsDomain", defaultValue: "DNS 域"), + configuration.dnsDomain.isEmpty ? none : configuration.dnsDomain + ) + field( + localization.string("detail.field.dnsServers", defaultValue: "DNS 服务器"), + configuration.dnsServers.isEmpty ? none : configuration.dnsServers.joined(separator: ", ") + ) + } header: { + sectionTitle(localization.string("detail.section.network", defaultValue: "网络")) + } + } + + private func processSection(_ container: ContainerInstance) -> some View { + let configuration = container.configuration + return Section { + field( + localization.string("detail.field.command", defaultValue: "命令"), + configuration.command.isEmpty ? none : configuration.command + ) + field( + localization.string("detail.field.workdir", defaultValue: "工作目录"), + configuration.workingDirectory.isEmpty ? none : configuration.workingDirectory + ) + field( + localization.string("detail.field.user", defaultValue: "用户"), + configuration.user.isEmpty ? none : configuration.user + ) + field( + localization.string("detail.field.environment", defaultValue: "环境变量"), + configuration.environmentKeys.isEmpty + ? none + : localization.format( + "detail.value.environmentCountFormat", + defaultValue: "%d 项(值已隐藏)", + configuration.environmentKeys.count + ) + ) + if !configuration.environmentKeys.isEmpty { + field( + localization.string("detail.field.environmentKeys", defaultValue: "变量名"), + configuration.environmentKeys.joined(separator: "\n") + ) + } + } header: { + sectionTitle(localization.string("detail.section.process", defaultValue: "进程")) + } + } + + private func liveSection(_ container: ContainerInstance) -> some View { + Section { + field( + localization.string("detail.field.cpu", defaultValue: "CPU"), + container.performance?.cpuPercent.map(ContainerIndicatorFormatting.percent) ?? placeholder + ) + field( + localization.string("detail.field.memory", defaultValue: "内存"), + memoryUsage(container.performance) + ) + field( + localization.string("detail.field.disk", defaultValue: "磁盘"), + diskUsage(container.performance) + ) + } header: { + sectionTitle(localization.string("detail.section.live", defaultValue: "实时")) + } + } + + // MARK: Rows + + private func field(_ label: String, _ value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Text(label) + .font(.subheadline) + .foregroundStyle(.secondary) + .frame(width: 92, alignment: .leading) + Text(value) + .font(.subheadline) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, 1) + } + + private func sectionTitle(_ title: String) -> some View { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + } + + private var missingState: some View { + VStack(spacing: 8) { + Image(systemName: "shippingbox") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text(localization.string("detail.containerMissing", defaultValue: "容器已不存在")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: Values + + private var none: String { + localization.string("detail.value.none", defaultValue: "无") + } + + private var placeholder: String { + "—" + } + + private func boolean(_ value: Bool) -> String { + value + ? localization.string("detail.value.yes", defaultValue: "是") + : localization.string("detail.value.no", defaultValue: "否") + } + + private func formattedDate(_ date: Date?) -> String { + guard let date else { return none } + return date.formatted( + Date.FormatStyle(date: .abbreviated, time: .shortened) + .locale(runtimeLocale.locale) + ) + } + + private func memoryUsage(_ performance: ContainerPerformance?) -> String { + guard let performance, let usage = performance.memoryBytes else { + return placeholder + } + let used = ContainerIndicatorFormatting.bytes(usage) + guard let limit = performance.memoryLimitBytes, limit > 0 else { + return used + } + return localization.format( + "detail.value.memoryUsageFormat", + defaultValue: "%1$@ / %2$@", + used, + ContainerIndicatorFormatting.bytes(limit) + ) + } + + private func diskUsage(_ performance: ContainerPerformance?) -> String { + guard let performance, + let read = performance.diskReadBytesPerSecond, + let write = performance.diskWriteBytesPerSecond else { + return placeholder + } + return localization.format( + "detail.value.diskUsageFormat", + defaultValue: "读 %1$@ · 写 %2$@", + ContainerIndicatorFormatting.rate(read), + ContainerIndicatorFormatting.rate(write) + ) + } +} diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorModels.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorModels.swift index f4e938f45..53fe2eb5c 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorModels.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorModels.swift @@ -8,21 +8,150 @@ public enum ContainerServiceStatus: String, Sendable, Equatable { case unknown } +/// Static configuration reported by `container list`, shown in the detail +/// window. Every field is optional in the CLI output, so the parser keeps +/// neutral defaults instead of failing the whole entry. +public struct ContainerConfiguration: Hashable, Sendable { + public let command: String + public let workingDirectory: String + public let user: String + /// Environment variable names only. Values stay out of the UI because + /// `container inspect` exposes secrets such as database passwords. + public let environmentKeys: [String] + public let ports: [String] + public let mounts: [String] + public let networks: [String] + public let hostname: String + public let dnsDomain: String + public let dnsServers: [String] + public let platform: String + public let runtimeHandler: String + public let readOnly: Bool + public let rosetta: Bool + public let ssh: Bool + public let imageDigest: String + public let createdAt: Date? + /// Optional storage quota from `configuration.resources.storage`. + public let storageQuotaBytes: Int? + + public static let empty = ContainerConfiguration( + command: "", + workingDirectory: "", + user: "", + environmentKeys: [], + ports: [], + mounts: [], + networks: [], + hostname: "", + dnsDomain: "", + dnsServers: [], + platform: "", + runtimeHandler: "", + readOnly: false, + rosetta: false, + ssh: false, + imageDigest: "", + createdAt: nil, + storageQuotaBytes: nil + ) + + public init( + command: String, + workingDirectory: String, + user: String, + environmentKeys: [String], + ports: [String], + mounts: [String], + networks: [String], + hostname: String, + dnsDomain: String, + dnsServers: [String], + platform: String, + runtimeHandler: String, + readOnly: Bool, + rosetta: Bool, + ssh: Bool, + imageDigest: String, + createdAt: Date?, + storageQuotaBytes: Int? + ) { + self.command = command + self.workingDirectory = workingDirectory + self.user = user + self.environmentKeys = environmentKeys + self.ports = ports + self.mounts = mounts + self.networks = networks + self.hostname = hostname + self.dnsDomain = dnsDomain + self.dnsServers = dnsServers + self.platform = platform + self.runtimeHandler = runtimeHandler + self.readOnly = readOnly + self.rosetta = rosetta + self.ssh = ssh + self.imageDigest = imageDigest + self.createdAt = createdAt + self.storageQuotaBytes = storageQuotaBytes + } +} + +/// One reduced sample of a running container's live resource usage. Rates are +/// nil until two daemon samples exist (or when a counter reset), so the card +/// can show "—" instead of a fabricated zero. +public struct ContainerPerformance: Hashable, Sendable { + /// Percentage of the container's configured CPU allotment, so 100% means + /// every configured core is busy. + public let cpuPercent: Double? + public let memoryBytes: Int? + public let memoryLimitBytes: Int? + public let diskReadBytesPerSecond: Double? + public let diskWriteBytesPerSecond: Double? + public let sampledAt: Date + + public var diskTotalBytesPerSecond: Double? { + guard let read = diskReadBytesPerSecond, + let write = diskWriteBytesPerSecond else { + return nil + } + return read + write + } + + public init( + cpuPercent: Double?, + memoryBytes: Int?, + memoryLimitBytes: Int?, + diskReadBytesPerSecond: Double?, + diskWriteBytesPerSecond: Double?, + sampledAt: Date + ) { + self.cpuPercent = cpuPercent + self.memoryBytes = memoryBytes + self.memoryLimitBytes = memoryLimitBytes + self.diskReadBytesPerSecond = diskReadBytesPerSecond + self.diskWriteBytesPerSecond = diskWriteBytesPerSecond + self.sampledAt = sampledAt + } +} + /// A container instance reported by `container list`. public struct ContainerInstance: Identifiable, Hashable, Sendable { public let id: String - public let name: String - public let statusText: String - public let image: String + public var name: String + public var statusText: String + public var image: String /// Configured core count from `configuration.resources.cpus`. - public let cpus: Int + public var cpus: Int /// Configured memory limit from `configuration.resources.memoryInBytes`. - public let memoryBytes: Int + public var memoryBytes: Int /// Container platform architecture, used to pick the matching image size. - public let architecture: String + public var architecture: String /// On-disk size of the matching image variant, resolved from /// `container image list`; nil while no match is available. - public let imageStorageBytes: Int? + public var imageStorageBytes: Int? + public var configuration: ContainerConfiguration + /// Latest live sample; only running containers receive one. + public var performance: ContainerPerformance? public var isRunning: Bool { statusText.lowercased() == "running" @@ -36,7 +165,9 @@ public struct ContainerInstance: Identifiable, Hashable, Sendable { cpus: Int = 0, memoryBytes: Int = 0, architecture: String = "", - imageStorageBytes: Int? = nil + imageStorageBytes: Int? = nil, + configuration: ContainerConfiguration = .empty, + performance: ContainerPerformance? = nil ) { self.id = id self.name = name @@ -46,20 +177,22 @@ public struct ContainerInstance: Identifiable, Hashable, Sendable { self.memoryBytes = memoryBytes self.architecture = architecture self.imageStorageBytes = imageStorageBytes + self.configuration = configuration + self.performance = performance } /// Returns a copy carrying the resolved image storage size. public func withImageStorage(_ bytes: Int?) -> ContainerInstance { - ContainerInstance( - id: id, - name: name, - statusText: statusText, - image: image, - cpus: cpus, - memoryBytes: memoryBytes, - architecture: architecture, - imageStorageBytes: bytes - ) + var copy = self + copy.imageStorageBytes = bytes + return copy + } + + /// Returns a copy carrying the latest live sample. + public func withPerformance(_ performance: ContainerPerformance?) -> ContainerInstance { + var copy = self + copy.performance = performance + return copy } } @@ -118,7 +251,8 @@ public struct ContainerMachine: Identifiable, Hashable, Sendable { } } -/// Byte sizes rendered for panel copy: whole units below GB, one decimal above. +/// Byte sizes and live rates rendered for panel copy: whole units below GB, +/// one decimal above. public enum ContainerIndicatorFormatting { private static let units = ["B", "KB", "MB", "GB", "TB"] @@ -136,6 +270,23 @@ public enum ContainerIndicatorFormatting { return String(format: "%.0f %@", value, units[index]) } + /// Throughput for the live line, e.g. `1.2 MB/s`. + public static func rate(_ bytesPerSecond: Double) -> String { + guard bytesPerSecond.isFinite, bytesPerSecond > 0 else { return "0 B/s" } + if bytesPerSecond >= 1024 * 1024 { + return String(format: "%.1f MB/s", bytesPerSecond / (1024 * 1024)) + } + if bytesPerSecond >= 1024 { + return String(format: "%.0f KB/s", bytesPerSecond / 1024) + } + return String(format: "%.0f B/s", bytesPerSecond) + } + + public static func percent(_ value: Double) -> String { + guard value.isFinite else { return "-" } + return String(format: "%.0f%%", max(0, value)) + } + /// The full image reference while it fits; once it is too long for the /// column, only its last path segment stays visible. The full reference /// remains available through the row's hover help. @@ -157,6 +308,9 @@ public struct ContainerSnapshot: Equatable, Sendable { public let containersFailure: String? /// Same as `containersFailure`, for `container machine list`. public let machinesFailure: String? + /// Why the latest `container stats` read failed. The live numbers then come + /// from the previous sample, so the card marks them as stale. + public let statsFailure: String? public static let unavailable = ContainerSnapshot( status: .unknown, @@ -171,7 +325,8 @@ public struct ContainerSnapshot: Equatable, Sendable { machines: [ContainerMachine], updatedAt: Date?, containersFailure: String? = nil, - machinesFailure: String? = nil + machinesFailure: String? = nil, + statsFailure: String? = nil ) { self.status = status self.containers = containers @@ -179,6 +334,7 @@ public struct ContainerSnapshot: Equatable, Sendable { self.updatedAt = updatedAt self.containersFailure = containersFailure self.machinesFailure = machinesFailure + self.statsFailure = statsFailure } public var runningContainerCount: Int { @@ -214,7 +370,7 @@ public enum ContainerIndicatorParser { return [] } - return entries.compactMap { entry in + return entries.compactMap { entry -> ContainerInstance? in guard let id = entry["id"] as? String else { return nil } let configuration = entry["configuration"] as? [String: Any] @@ -233,18 +389,45 @@ public enum ContainerIndicatorParser { image: imageReference, cpus: resources?["cpus"] as? Int ?? 0, memoryBytes: resources?["memoryInBytes"] as? Int ?? 0, - architecture: platform?["architecture"] as? String ?? "" + architecture: platform?["architecture"] as? String ?? "", + configuration: Self.configuration(from: configuration) ) } } + /// Reads the cumulative counters from `container stats --format json`. + /// CPU percentage and throughput are derived later from two samples. + public static func stats(fromJSON output: String) -> [String: ContainerStatsCounters] { + guard let data = output.data(using: .utf8), + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return [:] + } + + var counters: [String: ContainerStatsCounters] = [:] + for entry in entries { + guard let id = entry["id"] as? String else { continue } + counters[id] = ContainerStatsCounters( + id: id, + cpuUsageUsec: unsigned(entry["cpuUsageUsec"]), + memoryUsageBytes: unsigned(entry["memoryUsageBytes"]), + memoryLimitBytes: unsigned(entry["memoryLimitBytes"]), + blockReadBytes: unsigned(entry["blockReadBytes"]), + blockWriteBytes: unsigned(entry["blockWriteBytes"]), + networkRxBytes: unsigned(entry["networkRxBytes"]), + networkTxBytes: unsigned(entry["networkTxBytes"]), + numProcesses: unsigned(entry["numProcesses"]) + ) + } + return counters + } + public static func machines(fromJSON output: String) -> [ContainerMachine] { guard let data = output.data(using: .utf8), let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { return [] } - return entries.compactMap { entry in + return entries.compactMap { entry -> ContainerMachine? in guard let id = entry["id"] as? String else { return nil } return ContainerMachine( @@ -300,4 +483,119 @@ public enum ContainerIndicatorParser { } return result } + + // MARK: - Configuration + + private static func configuration(from configuration: [String: Any]?) -> ContainerConfiguration { + guard let configuration else { return .empty } + + let initProcess = configuration["initProcess"] as? [String: Any] ?? [:] + let executable = initProcess["executable"] as? String ?? "" + let arguments = initProcess["arguments"] as? [String] ?? [] + let command = ([executable] + arguments) + .filter { !$0.isEmpty } + .joined(separator: " ") + + let environment = initProcess["environment"] as? [String] ?? [] + let environmentKeys = environment.compactMap { item in + item.split(separator: "=", maxSplits: 1).first.map(String.init) + } + + let networks = configuration["networks"] as? [[String: Any]] ?? [] + let networkNames = networks.compactMap { $0["network"] as? String } + let networkOptions = networks.first?["options"] as? [String: Any] ?? [:] + + let dns = configuration["dns"] as? [String: Any] ?? [:] + let platform = configuration["platform"] as? [String: Any] ?? [:] + let platformOS = platform["os"] as? String ?? "" + let platformArchitecture = platform["architecture"] as? String ?? "" + + let image = configuration["image"] as? [String: Any] ?? [:] + let descriptor = image["descriptor"] as? [String: Any] ?? [:] + + let resources = configuration["resources"] as? [String: Any] ?? [:] + + return ContainerConfiguration( + command: command, + workingDirectory: initProcess["workingDirectory"] as? String ?? "", + user: userDescription(initProcess["user"]), + environmentKeys: environmentKeys, + ports: portDescriptions(configuration["publishedPorts"]), + mounts: mountDescriptions(configuration["mounts"]), + networks: networkNames, + hostname: networkOptions["hostname"] as? String ?? "", + dnsDomain: dns["domain"] as? String ?? "", + dnsServers: dns["nameservers"] as? [String] ?? [], + platform: [platformOS, platformArchitecture] + .filter { !$0.isEmpty } + .joined(separator: "/"), + runtimeHandler: configuration["runtimeHandler"] as? String ?? "", + readOnly: configuration["readOnly"] as? Bool ?? false, + rosetta: configuration["rosetta"] as? Bool ?? false, + ssh: configuration["ssh"] as? Bool ?? false, + imageDigest: descriptor["digest"] as? String ?? "", + createdAt: date(configuration["creationDate"]), + storageQuotaBytes: integer(resources["storage"]) + ) + } + + /// `user` is either `{id: {uid, gid}}` or `{raw: {userString}}`. + private static func userDescription(_ value: Any?) -> String { + guard let user = value as? [String: Any] else { return "" } + if let raw = user["raw"] as? [String: Any], + let userString = raw["userString"] as? String, + !userString.isEmpty { + return userString + } + if let id = user["id"] as? [String: Any], + let uid = integer(id["uid"]), + let gid = integer(id["gid"]) { + return "\(uid):\(gid)" + } + return "" + } + + private static func portDescriptions(_ value: Any?) -> [String] { + guard let ports = value as? [[String: Any]] else { return [] } + return ports.map { port in + let hostAddress = port["hostAddress"] as? String ?? "" + let hostPort = integer(port["hostPort"]) ?? 0 + let containerPort = integer(port["containerPort"]) ?? 0 + let proto = port["proto"] as? String ?? "tcp" + let host = hostAddress.isEmpty ? "\(hostPort)" : "\(hostAddress):\(hostPort)" + return "\(host) → \(containerPort)/\(proto)" + } + } + + private static func mountDescriptions(_ value: Any?) -> [String] { + guard let mounts = value as? [[String: Any]] else { return [] } + return mounts.map { mount in + let source = mount["source"] as? String ?? "" + let destination = mount["destination"] as? String ?? "" + if source.isEmpty { return destination } + if destination.isEmpty { return source } + return "\(source) → \(destination)" + } + } + + private static func date(_ value: Any?) -> Date? { + guard let string = value as? String else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } + + private static func integer(_ value: Any?) -> Int? { + guard let number = value as? NSNumber else { return nil } + return number.intValue + } + + private static func unsigned(_ value: Any?) -> UInt64? { + guard let number = value as? NSNumber else { return nil } + return number.uint64Value + } } diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift index e1a3e425d..ad2fc96ea 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift @@ -34,6 +34,10 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { let localization: PluginLocalization private var visibleItemIDs = Set() + /// The configuration window keeps polling alive on its own: the panel is + /// often dismissed while the inspector stays on screen. + private let detailPresenter = ContainerIndicatorDetailPresenter() + private var detailWindowIsOpen = false private enum ItemID { static let widget = "overview" @@ -56,6 +60,17 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { controller.onStateChange = { [weak self] in self?.onStateChange?() } + detailPresenter.onVisibilityChange = { [weak self] isVisible in + guard let self else { return } + let wasMonitoring = detailWindowIsOpen || !visibleItemIDs.isEmpty + detailWindowIsOpen = isVisible + let isMonitoring = isVisible || !visibleItemIDs.isEmpty + if isMonitoring, !wasMonitoring { + controller.startMonitoring() + } else if !isMonitoring, wasMonitoring { + controller.stopMonitoring() + } + } } // MARK: - Panel @@ -74,7 +89,10 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { controller: controller, localization: localization, pluginTitle: metadata.title, - reportContentHeight: context.reportContentHeight + reportContentHeight: context.reportContentHeight, + openDetails: { [weak self] container in + self?.openDetails(container) + } ) ) } @@ -109,17 +127,30 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { } private func updateVisibility(of itemID: String, isVisible: Bool) { + let wasMonitoring = detailWindowIsOpen || !visibleItemIDs.isEmpty if isVisible { - let wasHidden = visibleItemIDs.isEmpty visibleItemIDs.insert(itemID) - if wasHidden { - controller.startMonitoring() - } - } else if visibleItemIDs.remove(itemID) != nil, visibleItemIDs.isEmpty { + } else { + visibleItemIDs.remove(itemID) + } + let isMonitoring = detailWindowIsOpen || !visibleItemIDs.isEmpty + if isMonitoring, !wasMonitoring { + controller.startMonitoring() + } else if !isMonitoring, wasMonitoring { controller.stopMonitoring() } } + // MARK: - Details + + private func openDetails(_ container: ContainerInstance) { + detailPresenter.present( + container: container, + controller: controller, + localization: localization + ) + } + // MARK: - Lifecycle func activate(context: PluginRuntimeContext) { @@ -127,6 +158,8 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { } func deactivate(reason: PluginDeactivationReason) { + detailPresenter.close() + detailWindowIsOpen = false controller.stopMonitoring() } diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorStats.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorStats.swift new file mode 100644 index 000000000..f4196ebe1 --- /dev/null +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorStats.swift @@ -0,0 +1,103 @@ +import Foundation + +/// One raw read of `container stats --format json`. Every counter is +/// cumulative, so CPU percentage and throughput only exist once two reads have +/// been compared. +public struct ContainerStatsCounters: Equatable, Sendable { + public let id: String + public let cpuUsageUsec: UInt64? + public let memoryUsageBytes: UInt64? + public let memoryLimitBytes: UInt64? + public let blockReadBytes: UInt64? + public let blockWriteBytes: UInt64? + public let networkRxBytes: UInt64? + public let networkTxBytes: UInt64? + public let numProcesses: UInt64? + + public init( + id: String, + cpuUsageUsec: UInt64?, + memoryUsageBytes: UInt64?, + memoryLimitBytes: UInt64?, + blockReadBytes: UInt64?, + blockWriteBytes: UInt64?, + networkRxBytes: UInt64?, + networkTxBytes: UInt64?, + numProcesses: UInt64? + ) { + self.id = id + self.cpuUsageUsec = cpuUsageUsec + self.memoryUsageBytes = memoryUsageBytes + self.memoryLimitBytes = memoryLimitBytes + self.blockReadBytes = blockReadBytes + self.blockWriteBytes = blockWriteBytes + self.networkRxBytes = networkRxBytes + self.networkTxBytes = networkTxBytes + self.numProcesses = numProcesses + } +} + +/// Pure counter math behind the live line, kept out of the controller so the +/// delta rules are testable without a running daemon. +public enum ContainerIndicatorStatsMath { + /// CPU percentage of the container's configured cores: `100` means every + /// allotted core is busy. Returns nil for the first sample, a + /// non-positive interval, or a counter reset. + public static func cpuPercent( + previous: UInt64?, + current: UInt64?, + elapsed: TimeInterval, + cores: Int + ) -> Double? { + guard let previous, let current, current >= previous, elapsed > 0 else { + return nil + } + let usedSeconds = Double(current - previous) / 1_000_000 + return usedSeconds / elapsed / Double(max(1, cores)) * 100 + } + + /// Bytes per second between two cumulative counters. Nil for the first + /// sample, a non-positive interval, or a counter reset. + public static func rate( + previous: UInt64?, + current: UInt64?, + elapsed: TimeInterval + ) -> Double? { + guard let previous, let current, current >= previous, elapsed > 0 else { + return nil + } + return Double(current - previous) / elapsed + } + + /// Folds two daemon reads into one display sample. Memory is absolute and + /// always available; CPU and disk stay nil until a second sample exists. + public static func performance( + previous: ContainerStatsCounters?, + current: ContainerStatsCounters, + elapsed: TimeInterval, + cores: Int, + sampledAt: Date + ) -> ContainerPerformance { + ContainerPerformance( + cpuPercent: cpuPercent( + previous: previous?.cpuUsageUsec, + current: current.cpuUsageUsec, + elapsed: elapsed, + cores: cores + ), + memoryBytes: current.memoryUsageBytes.map { Int(clamping: $0) }, + memoryLimitBytes: current.memoryLimitBytes.map { Int(clamping: $0) }, + diskReadBytesPerSecond: rate( + previous: previous?.blockReadBytes, + current: current.blockReadBytes, + elapsed: elapsed + ), + diskWriteBytesPerSecond: rate( + previous: previous?.blockWriteBytes, + current: current.blockWriteBytes, + elapsed: elapsed + ), + sampledAt: sampledAt + ) + } +} diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift new file mode 100644 index 000000000..4f61b4378 --- /dev/null +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift @@ -0,0 +1,147 @@ +import AppKit +import Foundation + +/// A running resource that can open an interactive shell. +public enum ContainerTerminalTarget: Equatable, Sendable { + case container(id: String) + case machine(id: String) + + public var identifier: String { + switch self { + case .container(let id), .machine(let id): + return id + } + } +} + +public enum ContainerTerminalLaunchError: Error, Equatable, Sendable { + case invalidIdentifier + case invalidExecutable + case scriptWriteFailed + case openFailed +} + +@MainActor +public protocol ContainerTerminalLaunching { + func launch(target: ContainerTerminalTarget, executable: String) throws +} + +/// Opens an interactive shell in the user's default terminal. The plugin runs +/// inside the host app and has no PTY, so it writes a small `.command` script +/// and hands it to LaunchServices: that keeps the terminal choice with the +/// user and avoids AppleScript automation prompts. +@MainActor +public struct ContainerTerminalLauncher: ContainerTerminalLaunching { + /// Container and machine identifiers are user-chosen names; anything + /// outside this set never reaches the script. + private static let identifierPattern = "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" + + private let directory: URL + private let openURL: (URL) -> Bool + private let fileManager: FileManager + + public init( + directory: URL = FileManager.default.temporaryDirectory + .appendingPathComponent("MacTools/ContainerIndicator/Terminal", isDirectory: true), + fileManager: FileManager = .default, + openURL: @escaping (URL) -> Bool = { NSWorkspace.shared.open($0) } + ) { + self.directory = directory + self.fileManager = fileManager + self.openURL = openURL + } + + public func launch(target: ContainerTerminalTarget, executable: String) throws { + guard Self.isValidIdentifier(target.identifier) else { + throw ContainerTerminalLaunchError.invalidIdentifier + } + guard Self.isValidExecutablePath(executable) else { + throw ContainerTerminalLaunchError.invalidExecutable + } + + let script = Self.script(for: target, executable: executable) + let fileURL = directory.appendingPathComponent(Self.fileName(for: target)) + + do { + try fileManager.createDirectory( + at: directory, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try? fileManager.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: directory.path + ) + try Data(script.utf8).write(to: fileURL, options: .atomic) + try fileManager.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: fileURL.path + ) + } catch { + throw ContainerTerminalLaunchError.scriptWriteFailed + } + + guard openURL(fileURL) else { + throw ContainerTerminalLaunchError.openFailed + } + } + + /// The script content, exposed for tests. It prefers bash when the image + /// has it and falls back to the POSIX shell. + public static func script( + for target: ContainerTerminalTarget, + executable: String + ) -> String { + let container = shellQuoted(executable) + let identifier = shellQuoted(target.identifier) + + switch target { + case .container: + return """ + #!/bin/zsh + CONTAINER=\(container) + ID=\(identifier) + if "$CONTAINER" exec "$ID" /bin/sh -c 'test -x /bin/bash' >/dev/null 2>&1; then + exec "$CONTAINER" exec -it "$ID" /bin/bash + fi + exec "$CONTAINER" exec -it "$ID" /bin/sh + """ + case .machine: + return """ + #!/bin/zsh + exec \(container) machine run -n \(identifier) + """ + } + } + + /// Single-quote shell escaping: wrap in quotes and splice embedded quotes + /// as `'\''`. + static func shellQuoted(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + static func isValidIdentifier(_ identifier: String) -> Bool { + identifier.range(of: identifierPattern, options: .regularExpression) != nil + } + + static func isValidExecutablePath(_ path: String) -> Bool { + path.hasPrefix("/") + && !path.contains("\n") + && !path.contains("\r") + && !path.contains("\0") + } + + private static func fileName(for target: ContainerTerminalTarget) -> String { + let prefix: String + switch target { + case .container: prefix = "container" + case .machine: prefix = "machine" + } + let sanitized = target.identifier.replacingOccurrences( + of: "[^A-Za-z0-9._-]", + with: "-", + options: .regularExpression + ) + return "\(prefix)-\(sanitized).command" + } +} diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorWidgetViews.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorWidgetViews.swift index aa6640a13..3782a6d68 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorWidgetViews.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorWidgetViews.swift @@ -29,6 +29,7 @@ struct ContainerIndicatorWidgetCard: View { let localization: PluginLocalization let pluginTitle: String let reportContentHeight: (CGFloat) -> Void + let openDetails: (ContainerInstance) -> Void @Environment(\.pluginComponentTheme) private var theme @@ -141,6 +142,9 @@ struct ContainerIndicatorWidgetCard: View { if let failure = state.snapshot.containersFailure { failureText(failure) } + if let failure = state.snapshot.statsFailure { + failureText(failure) + } if state.snapshot.containers.isEmpty { // A failed read reports itself; only a healthy empty list // falls back to the empty message. @@ -159,10 +163,15 @@ struct ContainerIndicatorWidgetCard: View { memoryBytes: container.memoryBytes, storageBytes: container.imageStorageBytes ), + performance: container.performance, isRunning: container.isRunning, startAction: { state.startContainer(id: container.id) }, stopAction: { state.stopContainer(id: container.id) }, - restartAction: { state.restartContainer(id: container.id) } + restartAction: { state.restartContainer(id: container.id) }, + terminalAction: { + state.openTerminal(target: .container(id: container.id)) + }, + detailsAction: { openDetails(container) } ) } } @@ -200,7 +209,11 @@ struct ContainerIndicatorWidgetCard: View { isRunning: machine.isRunning, startAction: { state.startMachine(id: machine.id) }, stopAction: { state.stopMachine(id: machine.id) }, - restartAction: { state.restartMachine(id: machine.id) } + restartAction: { state.restartMachine(id: machine.id) }, + terminalAction: { + state.openTerminal(target: .machine(id: machine.id)) + }, + detailsAction: nil ) } } @@ -215,20 +228,38 @@ struct ContainerIndicatorWidgetCard: View { title: String, subtitle: String?, resources: String?, + performance: ContainerPerformance? = nil, isRunning: Bool, startAction: @escaping () -> Void, stopAction: @escaping () -> Void, - restartAction: @escaping () -> Void + restartAction: @escaping () -> Void, + terminalAction: @escaping () -> Void, + detailsAction: (() -> Void)? ) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 8) { VStack(alignment: .leading, spacing: 2) { - // All three lines share one size; hierarchy comes from the + // Every line shares one size; hierarchy comes from the // primary/secondary colors instead of type scale. - Text(title) - .font(PluginTypography.detail.font) - .lineLimit(1) - .truncationMode(.middle) + HStack(spacing: 4) { + Text(title) + .font(PluginTypography.detail.font) + .lineLimit(1) + .truncationMode(.middle) + if let detailsAction { + Button(action: detailsAction) { + Image(systemName: "info.circle") + .font(PluginTypography.caption.font) + } + .buttonStyle(.plain) + .foregroundStyle(theme.text.tertiary) + .contentShape(Rectangle()) + .help(localization.string("panel.action.details", defaultValue: "查看配置")) + .accessibilityLabel( + localization.string("panel.action.details", defaultValue: "查看配置") + ) + } + } if let subtitle, !subtitle.isEmpty { ImageReferenceLine(reference: subtitle) .foregroundStyle(theme.text.secondary) @@ -239,6 +270,12 @@ struct ContainerIndicatorWidgetCard: View { statusBadge(isRunning: isRunning) if isRunning { + rowButton( + systemName: "terminal", + help: localization.string("panel.action.terminal", defaultValue: "打开终端"), + isEnabled: true, + action: terminalAction + ) rowButton( systemName: "stop.fill", help: localization.string("panel.action.stop", defaultValue: "停止"), @@ -261,14 +298,19 @@ struct ContainerIndicatorWidgetCard: View { } } - // Full row width so CPU, memory, and storage never compete with - // the badge and lifecycle buttons for space. + // Full row width so configuration and live numbers never compete + // with the badge and lifecycle buttons for space. if let resources { - Text(resources) - .font(PluginTypography.detail.font) - .foregroundStyle(theme.text.secondary) - .lineLimit(1) - .truncationMode(.tail) + prefixedLine( + prefix: localization.string("panel.detail.configPrefix", defaultValue: "配置"), + value: resources + ) + } + if isRunning, let performance { + prefixedLine( + prefix: localization.string("panel.detail.livePrefix", defaultValue: "实时"), + value: liveLine(performance) + ) } } .padding(.horizontal, 8) @@ -276,6 +318,37 @@ struct ContainerIndicatorWidgetCard: View { .background(PluginComponentCardBackground(cornerRadius: 8)) } + /// Prefixes a detail line so the configured values and the live readings + /// are never mistaken for each other. + private func prefixedLine(prefix: String, value: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(prefix) + .foregroundStyle(theme.text.tertiary) + .fixedSize() + Text(value) + .foregroundStyle(theme.text.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + .font(PluginTypography.detail.font) + } + + /// One-line live summary. Rates are nil until the second sample, so the + /// first refresh after opening the panel shows a placeholder instead of a + /// fabricated zero. + private func liveLine(_ performance: ContainerPerformance) -> String { + let cpu = performance.cpuPercent.map(ContainerIndicatorFormatting.percent) ?? "—" + let memory = performance.memoryBytes.map(ContainerIndicatorFormatting.bytes) ?? "—" + let disk = performance.diskTotalBytesPerSecond.map(ContainerIndicatorFormatting.rate) ?? "—" + return localization.format( + "panel.detail.liveFormat", + defaultValue: "CPU %1$@ · 内存 %2$@ · 磁盘 %3$@", + cpu, + memory, + disk + ) + } + /// One-line CPU/memory/storage summary; hidden when the CLI reported no /// resource data for the entry at all. private func resourcesLine(cpus: Int, memoryBytes: Int, storageBytes: Int?) -> String? { diff --git a/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift b/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift index 802ea9919..8b86099cf 100644 --- a/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift +++ b/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift @@ -422,16 +422,353 @@ final class ContainerIndicatorPluginTests: XCTestCase { XCTAssertFalse(controller.isMonitoring) } + // MARK: - Configuration and live performance + + func testParserReadsFullContainerConfiguration() throws { + let containers = ContainerIndicatorParser.containers(fromJSON: """ + [{ + "id": "abc123", + "configuration": { + "id": "web", + "image": { + "reference": "docker.io/library/nginx:latest", + "descriptor": {"digest": "sha256:deadbeef"} + }, + "initProcess": { + "executable": "/usr/sbin/nginx", + "arguments": ["-g", "daemon off;"], + "environment": ["PATH=/usr/bin", "DB_PASSWORD=secret"], + "workingDirectory": "/app", + "user": {"raw": {"userString": "www-data"}} + }, + "publishedPorts": [ + {"hostAddress": "127.0.0.1", "hostPort": 8080, "containerPort": 80, "proto": "tcp"} + ], + "mounts": [{"type": "bind", "source": "/tmp/data", "destination": "/data"}], + "networks": [{"network": "default", "options": {"hostname": "web-host", "mtu": 1500}}], + "dns": {"domain": "example.test", "nameservers": ["1.1.1.1"]}, + "platform": {"os": "linux", "architecture": "arm64"}, + "runtimeHandler": "container-runtime", + "readOnly": true, + "rosetta": false, + "ssh": true, + "creationDate": "2026-10-01T12:00:00Z", + "resources": {"cpus": 4, "memoryInBytes": 1073741824, "storage": 10737418240} + }, + "status": {"state": "running"} + }] + """) + + let configuration = try XCTUnwrap(containers.first).configuration + XCTAssertEqual(configuration.command, "/usr/sbin/nginx -g daemon off;") + XCTAssertEqual(configuration.workingDirectory, "/app") + XCTAssertEqual(configuration.user, "www-data") + XCTAssertEqual( + configuration.environmentKeys, + ["PATH", "DB_PASSWORD"], + "Only variable names are kept so secrets never reach the UI" + ) + XCTAssertEqual(configuration.ports, ["127.0.0.1:8080 → 80/tcp"]) + XCTAssertEqual(configuration.mounts, ["/tmp/data → /data"]) + XCTAssertEqual(configuration.networks, ["default"]) + XCTAssertEqual(configuration.hostname, "web-host") + XCTAssertEqual(configuration.dnsDomain, "example.test") + XCTAssertEqual(configuration.dnsServers, ["1.1.1.1"]) + XCTAssertEqual(configuration.platform, "linux/arm64") + XCTAssertEqual(configuration.runtimeHandler, "container-runtime") + XCTAssertTrue(configuration.readOnly) + XCTAssertFalse(configuration.rosetta) + XCTAssertTrue(configuration.ssh) + XCTAssertEqual(configuration.imageDigest, "sha256:deadbeef") + XCTAssertNotNil(configuration.createdAt) + XCTAssertEqual(configuration.storageQuotaBytes, 10737418240) + } + + func testParserReadsStatsCounters() throws { + let counters = ContainerIndicatorParser.stats(fromJSON: """ + [{ + "id": "abc123", + "cpuUsageUsec": 1500000, + "memoryUsageBytes": 2048, + "memoryLimitBytes": 4096, + "blockReadBytes": 100, + "blockWriteBytes": 200, + "networkRxBytes": 300, + "networkTxBytes": 400, + "numProcesses": 5 + }] + """) + + let counter = try XCTUnwrap(counters["abc123"]) + XCTAssertEqual(counter.cpuUsageUsec, 1_500_000) + XCTAssertEqual(counter.memoryUsageBytes, 2048) + XCTAssertEqual(counter.memoryLimitBytes, 4096) + XCTAssertEqual(counter.blockReadBytes, 100) + XCTAssertEqual(counter.blockWriteBytes, 200) + XCTAssertEqual(counter.networkRxBytes, 300) + XCTAssertEqual(counter.networkTxBytes, 400) + XCTAssertEqual(counter.numProcesses, 5) + XCTAssertTrue(ContainerIndicatorParser.stats(fromJSON: "not json").isEmpty) + } + + func testStatsMathDerivesQuotaRelativeCPUAndRates() { + // Four CPU seconds used over four wall seconds on two cores: both + // allotted cores were busy half the time. + XCTAssertEqual( + ContainerIndicatorStatsMath.cpuPercent( + previous: 1_000_000, + current: 5_000_000, + elapsed: 4, + cores: 2 + ) ?? -1, + 50, + accuracy: 0.001 + ) + XCTAssertEqual( + ContainerIndicatorStatsMath.rate(previous: 1000, current: 5000, elapsed: 2) ?? -1, + 2000, + accuracy: 0.001 + ) + } + + func testStatsMathRejectsFirstSampleResetsAndEmptyInterval() { + XCTAssertNil( + ContainerIndicatorStatsMath.cpuPercent( + previous: nil, current: 1_000_000, elapsed: 5, cores: 2 + ), + "The first sample has no baseline" + ) + XCTAssertNil( + ContainerIndicatorStatsMath.cpuPercent( + previous: 5_000_000, current: 1_000_000, elapsed: 5, cores: 2 + ), + "A counter reset must not turn into a negative percentage" + ) + XCTAssertNil( + ContainerIndicatorStatsMath.cpuPercent( + previous: 1_000_000, current: 2_000_000, elapsed: 0, cores: 2 + ) + ) + XCTAssertNil( + ContainerIndicatorStatsMath.rate(previous: 5000, current: 1000, elapsed: 2) + ) + } + + func testSecondRefreshAddsLivePerformance() async throws { + let probe = CommandProbe() + await probe.stubService(exitCode: 0, output: #"{"status":"running"}"#) + await probe.stubContainers( + """ + [{"id":"c1","configuration":{"id":"web","resources":{"cpus":2}},\ + "status":{"state":"running"}}] + """ + ) + await probe.stubStats(output: statsJSON(cpu: 1_000_000, read: 1000, write: 2000)) + var clock = Date(timeIntervalSince1970: 1000) + let controller = makeController(probe: probe, now: { clock }) + + await controller.refresh() + let first = try XCTUnwrap(controller.snapshot.containers.first?.performance) + XCTAssertEqual(first.memoryBytes, 2048) + XCTAssertNil(first.cpuPercent, "CPU needs two samples before it can be derived") + XCTAssertNil(first.diskReadBytesPerSecond) + + clock.addTimeInterval(4) + await probe.stubStats(output: statsJSON(cpu: 5_000_000, read: 5000, write: 10_000)) + await controller.refresh() + + let second = try XCTUnwrap(controller.snapshot.containers.first?.performance) + XCTAssertEqual(second.cpuPercent ?? -1, 50, accuracy: 0.001) + XCTAssertEqual(second.diskReadBytesPerSecond ?? -1, 1000, accuracy: 0.001) + XCTAssertEqual(second.diskWriteBytesPerSecond ?? -1, 2000, accuracy: 0.001) + XCTAssertNil(controller.snapshot.statsFailure) + } + + func testStatsFailureKeepsPreviousPerformanceAndReportsWhy() async throws { + let probe = CommandProbe() + await probe.stubService(exitCode: 0, output: #"{"status":"running"}"#) + await probe.stubContainers( + """ + [{"id":"c1","configuration":{"id":"web","resources":{"cpus":2}},\ + "status":{"state":"running"}}] + """ + ) + await probe.stubStats(output: statsJSON(cpu: 1_000_000, read: 1000, write: 2000)) + let controller = makeController(probe: probe) + + await controller.refresh() + XCTAssertEqual(controller.snapshot.containers.first?.performance?.memoryBytes, 2048) + + await probe.stubStats(exitCode: 1, output: "", error: "error: stats unavailable") + await controller.refresh() + + XCTAssertEqual( + controller.snapshot.statsFailure, + "性能数据读取失败:error: stats unavailable" + ) + XCTAssertEqual( + controller.snapshot.containers.first?.performance?.memoryBytes, + 2048, + "A failed stats read keeps the last numbers on screen" + ) + } + + func testStatsCommandIsSkippedWhenNothingRuns() async { + let probe = CommandProbe() + await probe.stubService(exitCode: 0, output: #"{"status":"running"}"#) + await probe.stubContainers( + "[{\"id\":\"c1\",\"configuration\":{\"id\":\"web\"},\"status\":{\"state\":\"stopped\"}}]" + ) + let controller = makeController(probe: probe) + + await controller.refresh() + + let invocations = await probe.recordedInvocations() + XCTAssertFalse( + invocations.contains(["stats", "--format", "json", "--no-stream"]), + "A stopped container must not pay the two-second stats sampling cost" + ) + XCTAssertNil(controller.snapshot.containers.first?.performance) + } + + // MARK: - Terminal + + func testContainerTerminalScriptPrefersBashAndFallsBackToShell() { + let script = ContainerTerminalLauncher.script( + for: .container(id: "web"), + executable: "/opt/homebrew/bin/container" + ) + XCTAssertTrue(script.contains("CONTAINER='/opt/homebrew/bin/container'")) + XCTAssertTrue(script.contains("ID='web'")) + XCTAssertTrue(script.contains("exec -it \"$ID\" /bin/bash")) + XCTAssertTrue(script.contains("exec -it \"$ID\" /bin/sh")) + XCTAssertTrue(script.contains("test -x /bin/bash")) + } + + func testMachineTerminalScriptUsesMachineRun() { + let script = ContainerTerminalLauncher.script( + for: .machine(id: "default"), + executable: "/opt/homebrew/bin/container" + ) + XCTAssertTrue(script.contains("exec '/opt/homebrew/bin/container' machine run -n 'default'")) + XCTAssertFalse(script.contains("exec -it")) + } + + func testShellQuotingEscapesEmbeddedQuotes() { + XCTAssertEqual(ContainerTerminalLauncher.shellQuoted("it's"), #"'it'\''s'"#) + XCTAssertFalse( + ContainerTerminalLauncher.isValidIdentifier("web; rm -rf /"), + "Shell metacharacters never reach the generated script" + ) + XCTAssertFalse(ContainerTerminalLauncher.isValidIdentifier("")) + XCTAssertTrue(ContainerTerminalLauncher.isValidIdentifier("web-1.local")) + XCTAssertFalse(ContainerTerminalLauncher.isValidExecutablePath("container")) + XCTAssertFalse(ContainerTerminalLauncher.isValidExecutablePath("/usr/bin/container\nrm")) + } + + func testTerminalLauncherWritesExecutableScriptAndOpensIt() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + var openedURL: URL? + let launcher = ContainerTerminalLauncher( + directory: directory, + openURL: { url in + openedURL = url + return true + } + ) + + try launcher.launch( + target: .container(id: "web"), + executable: "/opt/homebrew/bin/container" + ) + + let url = try XCTUnwrap(openedURL) + XCTAssertEqual(url.lastPathComponent, "container-web.command") + let script = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(script.contains("ID='web'")) + XCTAssertTrue(script.contains("exec -it \"$ID\"")) + let permissions = try FileManager.default + .attributesOfItem(atPath: url.path)[.posixPermissions] as? Int + XCTAssertEqual(permissions, 0o755, "The script must be executable by the terminal") + } + + func testTerminalLauncherRejectsInvalidIdentifierBeforeWriting() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let launcher = ContainerTerminalLauncher( + directory: directory, + openURL: { _ in true } + ) + + XCTAssertThrowsError( + try launcher.launch( + target: .container(id: "web; rm -rf /"), + executable: "/opt/homebrew/bin/container" + ) + ) { error in + XCTAssertEqual(error as? ContainerTerminalLaunchError, .invalidIdentifier) + } + XCTAssertFalse(FileManager.default.fileExists(atPath: directory.path)) + } + + func testTerminalOpenFailureSurfacesMessage() { + let probe = CommandProbe() + let launcher = ContainerTerminalLauncher(openURL: { _ in false }) + let controller = ContainerIndicatorController( + commandRunner: probe, + localization: PluginLocalization(bundle: .main), + commandPathProvider: { "/opt/homebrew/bin/container" }, + terminalLauncher: launcher + ) + + controller.openTerminal(target: .container(id: "web")) + + XCTAssertEqual(controller.lastErrorMessage, "无法打开终端") + } + + func testTerminalOpenRejectsInvalidIdentifierWithClearMessage() { + let probe = CommandProbe() + let controller = ContainerIndicatorController( + commandRunner: probe, + localization: PluginLocalization(bundle: .main), + commandPathProvider: { "/opt/homebrew/bin/container" } + ) + + controller.openTerminal(target: .container(id: "web; rm -rf /")) + + XCTAssertEqual(controller.lastErrorMessage, "容器标识无效") + } + // MARK: - Helpers + private func statsJSON( + cpu: UInt64, + read: UInt64, + write: UInt64, + memory: UInt64 = 2048, + limit: UInt64 = 4096 + ) -> String { + """ + [{"id":"c1","cpuUsageUsec":\(cpu),"memoryUsageBytes":\(memory),\ + "memoryLimitBytes":\(limit),"blockReadBytes":\(read),\ + "blockWriteBytes":\(write),"networkRxBytes":0,"networkTxBytes":0,\ + "numProcesses":3}] + """ + } + private func makeController( probe: CommandProbe, - commandPath: String? = "/opt/homebrew/bin/container" + commandPath: String? = "/opt/homebrew/bin/container", + now: @escaping () -> Date = Date.init ) -> ContainerIndicatorController { ContainerIndicatorController( commandRunner: probe, localization: PluginLocalization(bundle: .main), - commandPathProvider: { commandPath } + commandPathProvider: { commandPath }, + now: now ) } @@ -473,6 +810,9 @@ private actor CommandProbe: ContainerCommandRunning { private var containersOutput = "[]" private var machinesOutput = "[]" private var imagesOutput = "[]" + private var statsOutput = "[]" + private var statsExitCode: Int32 = 0 + private var statsError = "" private var machineInspectOutputs: [String: String] = [:] private var overrides: [String: ContainerCommandResult] = [:] private var heldCommands: Set = [] @@ -539,6 +879,12 @@ private actor CommandProbe: ContainerCommandRunning { imagesOutput = output } + func stubStats(exitCode: Int32 = 0, output: String, error: String = "") { + statsExitCode = exitCode + statsOutput = output + statsError = error + } + func stubMachineInspect(_ id: String, _ output: String) { machineInspectOutputs[id] = output } @@ -577,6 +923,12 @@ private actor CommandProbe: ContainerCommandRunning { return ContainerCommandResult(exitCode: 0, standardOutput: await machinesOutputValue(), standardError: "") case ["image", "list", "--format", "json"]: return ContainerCommandResult(exitCode: 0, standardOutput: await imagesOutputValue(), standardError: "") + case ["stats", "--format", "json", "--no-stream"]: + return ContainerCommandResult( + exitCode: await statsExitCodeValue(), + standardOutput: await statsOutputValue(), + standardError: await statsErrorValue() + ) case ["system", "stop"]: await applyServiceState(exitCode: 1, output: "") return ContainerCommandResult(exitCode: 0, standardOutput: "", standardError: "") @@ -614,6 +966,9 @@ private actor CommandProbe: ContainerCommandRunning { private func containersOutputValue() -> String { containersOutput } private func machinesOutputValue() -> String { machinesOutput } private func imagesOutputValue() -> String { imagesOutput } + private func statsExitCodeValue() -> Int32 { statsExitCode } + private func statsOutputValue() -> String { statsOutput } + private func statsErrorValue() -> String { statsError } private func machineInspectValue(for id: String) -> String { machineInspectOutputs[id] ?? "" } diff --git a/changes/unreleased/container-indicator-performance.md b/changes/unreleased/container-indicator-performance.md new file mode 100644 index 000000000..4f119e28f --- /dev/null +++ b/changes/unreleased/container-indicator-performance.md @@ -0,0 +1,7 @@ +--- +release: plugin +type: added +area: Container Indicator +--- + +The Apple Container status card now shows live CPU, memory, and disk usage. Containers gain a configuration window and one-click terminal access, and machines can also open a terminal. From db4124efaab4aa32d371a13cd27fa5f5b1d1f9b6 Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Thu, 29 Oct 2026 13:54:24 +0800 Subject: [PATCH 2/6] fix(container-indicator): bring the configuration window to the front --- .../Sources/ContainerIndicatorDetailWindow.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift index 23253f64d..64ee909c2 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift @@ -36,10 +36,15 @@ final class ContainerIndicatorDetailPresenter { self?.onVisibilityChange?(false) } + // The card lives in a menu-bar popover, whose window is ordered above + // ordinary floating panels. Without lifting the inspector above that + // level it opens behind the popover the user just clicked. + window.level = NSWindow.Level(rawValue: NSWindow.Level.popUpMenu.rawValue + 1) PluginPresentationSafety.prepareForWindowOrdering(window) position(window) - window.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() self.window = window onVisibilityChange?(true) } From b08fb3ccaae58874bd91101099e748f5ea2f18ba Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Thu, 29 Oct 2026 16:02:56 +0800 Subject: [PATCH 3/6] fix terminal failed open --- .../Resources/Localizable.xcstrings | 924 ++++++++++++++++++ .../ContainerIndicatorController.swift | 9 +- .../Sources/ContainerIndicatorPlugin.swift | 118 ++- .../ContainerIndicatorTerminalLauncher.swift | 165 +++- .../Tests/ContainerIndicatorPluginTests.swift | 291 +++++- Plugins/ContainerIndicator/plugin.json | 6 +- .../container-indicator-performance.md | 2 +- 7 files changed, 1490 insertions(+), 25 deletions(-) diff --git a/Plugins/ContainerIndicator/Resources/Localizable.xcstrings b/Plugins/ContainerIndicator/Resources/Localizable.xcstrings index d64e44d72..de13b4df9 100644 --- a/Plugins/ContainerIndicator/Resources/Localizable.xcstrings +++ b/Plugins/ContainerIndicator/Resources/Localizable.xcstrings @@ -5544,6 +5544,930 @@ } } } + }, + "settings.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض وإدارة خدمة container والحاويات والأجهزة الافتراضية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dienst, Container und virtuelle Maschinen von container überwachen und verwalten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Monitor and manage the container service, containers, and virtual machines." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Supervisa y gestiona el servicio container, los contenedores y las máquinas virtuales." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Surveillez et gérez le service container, les conteneurs et les machines virtuelles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "container サービス、コンテナ、仮想マシンを監視および管理します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "container 서비스, 컨테이너, 가상 머신을 모니터링하고 관리합니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Monitore e gerencie o serviço container, os contêineres e as máquinas virtuais." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Наблюдение и управление службой container, контейнерами и виртуальными машинами." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "container hizmetini, konteynerleri ve sanal makineleri izleyin ve yönetin." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "监控并管理 container 服务、容器与虚拟机。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "監控並管理 container 服務、容器與虛擬機。" + } + } + } + }, + "settings.terminal.application.alacritty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Alacritty" + } + } + } + }, + "settings.terminal.application.description": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التطبيق المستخدم لفتح طرفية تفاعلية للحاويات أو الأجهزة الافتراضية." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die App, die ein interaktives Terminal für Container oder virtuelle Maschinen öffnet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The app used to open an interactive terminal for containers or virtual machines." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La aplicación que abre una terminal interactiva para contenedores o máquinas virtuales." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L'application utilisée pour ouvrir un terminal interactif pour les conteneurs ou les machines virtuelles." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンテナや仮想マシンの対話型ターミナルを開くアプリ。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컨테이너 또는 가상 머신의 대화형 터미널을 여는 앱입니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "O aplicativo usado para abrir um terminal interativo para contêineres ou máquinas virtuais." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приложение для открытия интерактивного терминала контейнера или виртуальной машины." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Konteynerler veya sanal makineler için etkileşimli terminal açan uygulama." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开容器或虚拟机交互式终端时使用的应用。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟容器或虛擬機互動式終端機時使用的應用程式。" + } + } + } + }, + "settings.terminal.application.ghostty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Ghostty" + } + } + } + }, + "settings.terminal.application.iterm": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "iTerm" + } + } + } + }, + "settings.terminal.application.kitty": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "kitty" + } + } + } + }, + "settings.terminal.application.systemDefault": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "النظام الافتراضي" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemstandard" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "System Default" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Predeterminado del sistema" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Par défaut du système" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムデフォルト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "시스템 기본값" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Padrão do sistema" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Системный по умолчанию" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sistem Varsayılanı" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "系统默认" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "系統預設" + } + } + } + }, + "settings.terminal.application.terminal": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Терминал" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機" + } + } + } + }, + "settings.terminal.application.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تطبيق الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal-App" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal app" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aplicación de terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Application de terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルアプリ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널 앱" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Aplicativo de terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Приложение терминала" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal uygulaması" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端应用" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機應用程式" + } + } + } + }, + "settings.terminal.application.wezterm": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "WezTerm" + } + } + } + }, + "settings.terminal.footer": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تُعرض التطبيقات المثبّتة فقط. يفتح Ghostty وiTerm علامة تبويب جديدة في النافذة الحالية؛ وتتبع الطرفيات الأخرى سلوكها الخاص." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Es werden nur installierte Terminals angezeigt. Ghostty und iTerm öffnen einen neuen Tab im vorhandenen Fenster; andere Terminals folgen ihrem eigenen Verhalten." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Only installed terminals are listed. Ghostty and iTerm open a new tab in the existing window; other terminals follow their own behavior." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Solo se muestran las terminales instaladas. Ghostty e iTerm abren una pestaña nueva en la ventana existente; las demás terminales siguen su propio comportamiento." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Seuls les terminaux installés sont affichés. Ghostty et iTerm ouvrent un nouvel onglet dans la fenêtre existante ; les autres terminaux suivent leur propre comportement." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "インストール済みのターミナルのみ表示されます。Ghostty と iTerm は既存のウインドウに新しいタブを開き、その他のターミナルは各アプリの動作に従います。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "설치된 터미널만 표시됩니다. Ghostty와 iTerm은 기존 창에 새 탭을 열고, 다른 터미널은 각자의 동작을 따릅니다." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Apenas os terminais instalados são listados. Ghostty e iTerm abrem uma nova aba na janela existente; os demais terminais seguem o próprio comportamento." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Показаны только установленные терминалы. Ghostty и iTerm открывают новую вкладку в существующем окне; остальные терминалы работают по-своему." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yalnızca yüklü terminaller listelenir. Ghostty ve iTerm mevcut pencerede yeni bir sekme açar; diğer terminaller kendi davranışlarını izler." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "仅列出已安装的终端应用。Ghostty 和 iTerm 会在现有窗口中新建标签页,其他终端由各自行为决定。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "僅列出已安裝的終端機應用程式。Ghostty 和 iTerm 會在現有視窗中新增標籤頁,其他終端機依各自行為開啟。" + } + } + } + }, + "settings.terminal.title": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الطرفية" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "터미널" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Терминал" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Terminal" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "终端" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "終端機" + } + } + } } }, "version": "1.0" diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift index 34843e664..6692a637a 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift @@ -22,6 +22,9 @@ public final class ContainerIndicatorController: ObservableObject { } public var onStateChange: (() -> Void)? + /// The terminal chosen in settings. The launcher falls back to the system + /// default if the app has since been removed. + public var terminalApplication: ContainerTerminalApplication = .systemDefault private let commandRunner: any ContainerCommandRunning private let commandPathProvider: () -> String? @@ -445,7 +448,11 @@ public final class ContainerIndicatorController: ObservableObject { operationErrorMessage = nil do { - try terminalLauncher.launch(target: target, executable: path) + try terminalLauncher.launch( + target: target, + executable: path, + application: terminalApplication + ) } catch ContainerTerminalLaunchError.invalidIdentifier { operationErrorMessage = localization.string( "panel.error.terminalInvalidIdentifier", diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift index ad2fc96ea..ed7011ba1 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorPlugin.swift @@ -15,7 +15,13 @@ private struct ContainerIndicatorPluginProvider: PluginProvider { func makePlugins() -> [any MacToolsPlugin] { let localization = PluginLocalization(bundle: context.resourceBundle) let controller = ContainerIndicatorController(localization: localization) - return [ContainerIndicatorPlugin(controller: controller, localization: localization)] + return [ + ContainerIndicatorPlugin( + controller: controller, + localization: localization, + storage: context.storage + ), + ] } } @@ -39,13 +45,46 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { private let detailPresenter = ContainerIndicatorDetailPresenter() private var detailWindowIsOpen = false + private let storage: any PluginStorage + private let installedTerminalApplications: () -> [ContainerTerminalApplication] + private var terminalApplication: ContainerTerminalApplication + private enum ItemID { static let widget = "overview" } - init(controller: ContainerIndicatorController, localization: PluginLocalization) { + private enum ControlID { + static let terminalApplication = "terminal-application" + } + + private enum StorageKey { + static let terminalApplication = "terminal.application.v1" + } + + init( + controller: ContainerIndicatorController, + localization: PluginLocalization, + storage: (any PluginStorage)? = nil, + installedTerminalApplications: (() -> [ContainerTerminalApplication])? = nil + ) { + let resolvedStorage = storage + ?? UserDefaultsPluginStorage(pluginID: "container-indicator") + let resolvedInstalledApplications = installedTerminalApplications + ?? { ContainerTerminalApplicationCatalog.installedApplications() } self.controller = controller self.localization = localization + self.storage = resolvedStorage + self.installedTerminalApplications = resolvedInstalledApplications + let storedApplication = resolvedStorage.string(forKey: StorageKey.terminalApplication) + .flatMap(ContainerTerminalApplication.init(rawValue:)) + // Prefer a terminal that can add a tab to the existing window when the + // user has not chosen one yet. Terminal.app has no such command, so it + // remains the fallback instead of the default. + let preferredApplication = resolvedInstalledApplications() + .first(where: { $0 == .ghostty || $0 == .iterm }) + ?? .systemDefault + let selectedApplication = storedApplication ?? preferredApplication + self.terminalApplication = selectedApplication self.metadata = PluginMetadata( id: "container-indicator", title: localization.string("metadata.title", defaultValue: "Apple Container状态"), @@ -57,6 +96,7 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { defaultValue: "监控并管理 container 服务、容器与虚拟机" ) ) + controller.terminalApplication = selectedApplication controller.onStateChange = { [weak self] in self?.onStateChange?() } @@ -141,6 +181,80 @@ final class ContainerIndicatorPlugin: MacToolsPlugin { } } + // MARK: - Settings + + var settingsPage: PluginSettingsPage? { + var applications = installedTerminalApplications() + if !applications.contains(.systemDefault) { + applications.insert(.systemDefault, at: 0) + } + // A terminal removed since the setting was saved must not leave the + // picker pointing at an option that is no longer listed. + let selection = applications.contains(terminalApplication) + ? terminalApplication + : .systemDefault + let options = applications.map { application in + PluginSettingsOption( + id: application.optionID, + title: localization.string( + application.localizationKey, + defaultValue: application.defaultTitle + ) + ) + } + + return .form( + description: localization.string( + "settings.description", + defaultValue: "监控并管理 container 服务、容器与虚拟机。" + ), + sections: [ + PluginSettingsSection( + id: "terminal", + title: localization.string("settings.terminal.title", defaultValue: "终端"), + systemImage: "terminal", + footer: localization.string( + "settings.terminal.footer", + defaultValue: "仅列出已安装的终端应用;选择系统默认时由 macOS 决定打开方式。" + ), + rows: [ + PluginSettingsRow( + id: ControlID.terminalApplication, + title: localization.string( + "settings.terminal.application.title", + defaultValue: "终端应用" + ), + description: localization.string( + "settings.terminal.application.description", + defaultValue: "打开容器或虚拟机交互式终端时使用的应用。" + ), + systemImage: "macwindow", + control: .picker( + selectionID: selection.optionID, + options: options, + style: .menu + ) + ), + ] + ), + ] + ) + } + + func handleSettingsAction(_ action: PluginSettingsAction) { + guard case let .setSelection(controlID, optionID) = action, + controlID == ControlID.terminalApplication, + let application = ContainerTerminalApplication(rawValue: optionID) + else { + return + } + + terminalApplication = application + controller.terminalApplication = application + storage.set(application.rawValue, forKey: StorageKey.terminalApplication) + onStateChange?() + } + // MARK: - Details private func openDetails(_ container: ContainerInstance) { diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift index 4f61b4378..992246b0e 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalLauncher.swift @@ -23,13 +23,17 @@ public enum ContainerTerminalLaunchError: Error, Equatable, Sendable { @MainActor public protocol ContainerTerminalLaunching { - func launch(target: ContainerTerminalTarget, executable: String) throws + func launch( + target: ContainerTerminalTarget, + executable: String, + application: ContainerTerminalApplication + ) throws } -/// Opens an interactive shell in the user's default terminal. The plugin runs +/// Opens an interactive shell in the user's selected terminal. The plugin runs /// inside the host app and has no PTY, so it writes a small `.command` script -/// and hands it to LaunchServices: that keeps the terminal choice with the -/// user and avoids AppleScript automation prompts. +/// and hands it to LaunchServices: that avoids AppleScript automation prompts +/// and keeps the terminal window attached to the user's own session. @MainActor public struct ContainerTerminalLauncher: ContainerTerminalLaunching { /// Container and machine identifiers are user-chosen names; anything @@ -37,21 +41,53 @@ public struct ContainerTerminalLauncher: ContainerTerminalLaunching { private static let identifierPattern = "^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$" private let directory: URL - private let openURL: (URL) -> Bool private let fileManager: FileManager + private let applicationResolver: (ContainerTerminalApplication) -> URL? + private let defaultApplicationResolver: (URL) -> URL? + private let runOpen: ([String]) -> Bool + private let runAppleScript: (String) -> Bool public init( directory: URL = FileManager.default.temporaryDirectory .appendingPathComponent("MacTools/ContainerIndicator/Terminal", isDirectory: true), fileManager: FileManager = .default, - openURL: @escaping (URL) -> Bool = { NSWorkspace.shared.open($0) } + applicationResolver: @escaping (ContainerTerminalApplication) -> URL? = { + ContainerTerminalApplicationCatalog.applicationURL(for: $0) + }, + defaultApplicationResolver: ((URL) -> URL?)? = nil, + runOpen: @escaping ([String]) -> Bool = { arguments in + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/open") + process.arguments = arguments + do { + try process.run() + process.waitUntilExit() + return process.terminationStatus == 0 + } catch { + return false + } + }, + runAppleScript: ((String) -> Bool)? = nil ) { self.directory = directory self.fileManager = fileManager - self.openURL = openURL + self.applicationResolver = applicationResolver + self.defaultApplicationResolver = defaultApplicationResolver + ?? { NSWorkspace.shared.urlForApplication(toOpen: $0) } + self.runOpen = runOpen + self.runAppleScript = runAppleScript ?? { source in + var error: NSDictionary? + let script = NSAppleScript(source: source) + _ = script?.executeAndReturnError(&error) + return error == nil + } } - public func launch(target: ContainerTerminalTarget, executable: String) throws { + public func launch( + target: ContainerTerminalTarget, + executable: String, + application: ContainerTerminalApplication + ) throws { guard Self.isValidIdentifier(target.identifier) else { throw ContainerTerminalLaunchError.invalidIdentifier } @@ -81,11 +117,122 @@ public struct ContainerTerminalLauncher: ContainerTerminalLaunching { throw ContainerTerminalLaunchError.scriptWriteFailed } - guard openURL(fileURL) else { + let resolvedApplication: ContainerTerminalApplication + if application == .systemDefault { + resolvedApplication = defaultApplicationResolver(fileURL) + .flatMap(ContainerTerminalApplication.init(applicationURL:)) + ?? .systemDefault + } else { + resolvedApplication = application + } + let applicationURL = resolvedApplication == .systemDefault + ? nil + : applicationResolver(resolvedApplication) + // An app removed since the setting was saved falls back to the system + // default instead of failing the whole terminal action. + let effectiveApplication = applicationURL == nil ? .systemDefault : resolvedApplication + + // Ghostty and iTerm expose scripting commands for opening a tab in the + // existing window. Terminal.app has no equivalent command, so it keeps + // the document-based launch path. + if let source = Self.appleScript( + application: effectiveApplication, + scriptURL: fileURL + ) { + guard runAppleScript(source) else { + throw ContainerTerminalLaunchError.openFailed + } + return + } + + let arguments = Self.openArguments( + application: effectiveApplication, + applicationURL: applicationURL, + scriptURL: fileURL + ) + + guard runOpen(arguments) else { throw ContainerTerminalLaunchError.openFailed } } + /// AppleScript that opens a new tab when the terminal already has a + /// window, and a first window when it does not. + public static func appleScript( + application: ContainerTerminalApplication, + scriptURL: URL + ) -> String? { + let command = appleScriptQuoted(scriptURL.path) + + switch application { + case .ghostty: + return """ + tell application "Ghostty" + activate + set cfg to new surface configuration + set command of cfg to "\(command)" + if (count of windows) = 0 then + set win to new window with configuration cfg + else + set win to front window + set t to new tab in win with configuration cfg + select tab t + end if + end tell + """ + case .iterm: + return """ + tell application "iTerm" + activate + if (count of windows) = 0 then + create window with default profile command "\(command)" + else + tell current window + create tab with default profile command "\(command)" + end tell + end if + end tell + """ + default: + return nil + } + } + + static func appleScriptQuoted(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + } + + /// The arguments handed to `/usr/bin/open`. Explicit applications use + /// their bundle path so a same-named app elsewhere cannot intercept the + /// launch, and terminals that only accept a command line get `--args`. + public static func openArguments( + application: ContainerTerminalApplication, + applicationURL: URL?, + scriptURL: URL + ) -> [String] { + guard application != .systemDefault, let applicationURL else { + return [scriptURL.path] + } + + switch application { + case .systemDefault: + return [scriptURL.path] + case .terminal, .iterm: + // Both are document-based: opening the `.command` file runs it. + return ["-a", applicationURL.path, scriptURL.path] + case .ghostty: + return ["-na", applicationURL.path, "--args", "-e", scriptURL.path] + case .wezterm: + return ["-na", applicationURL.path, "--args", "start", "--", scriptURL.path] + case .kitty: + return ["-na", applicationURL.path, "--args", scriptURL.path] + case .alacritty: + return ["-na", applicationURL.path, "--args", "-e", scriptURL.path] + } + } + /// The script content, exposed for tests. It prefers bash when the image /// has it and falls back to the POSIX shell. public static func script( diff --git a/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift b/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift index 8b86099cf..19ea9bc34 100644 --- a/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift +++ b/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift @@ -670,21 +670,23 @@ final class ContainerIndicatorPluginTests: XCTestCase { let directory = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) defer { try? FileManager.default.removeItem(at: directory) } - var openedURL: URL? + var openedArguments: [String]? let launcher = ContainerTerminalLauncher( directory: directory, - openURL: { url in - openedURL = url + defaultApplicationResolver: { _ in nil }, + runOpen: { arguments in + openedArguments = arguments return true } ) try launcher.launch( target: .container(id: "web"), - executable: "/opt/homebrew/bin/container" + executable: "/opt/homebrew/bin/container", + application: .systemDefault ) - let url = try XCTUnwrap(openedURL) + let url = try XCTUnwrap(openedArguments?.first.map(URL.init(fileURLWithPath:))) XCTAssertEqual(url.lastPathComponent, "container-web.command") let script = try String(contentsOf: url, encoding: .utf8) XCTAssertTrue(script.contains("ID='web'")) @@ -700,13 +702,14 @@ final class ContainerIndicatorPluginTests: XCTestCase { defer { try? FileManager.default.removeItem(at: directory) } let launcher = ContainerTerminalLauncher( directory: directory, - openURL: { _ in true } + runOpen: { _ in true } ) XCTAssertThrowsError( try launcher.launch( target: .container(id: "web; rm -rf /"), - executable: "/opt/homebrew/bin/container" + executable: "/opt/homebrew/bin/container", + application: .systemDefault ) ) { error in XCTAssertEqual(error as? ContainerTerminalLaunchError, .invalidIdentifier) @@ -716,7 +719,7 @@ final class ContainerIndicatorPluginTests: XCTestCase { func testTerminalOpenFailureSurfacesMessage() { let probe = CommandProbe() - let launcher = ContainerTerminalLauncher(openURL: { _ in false }) + let launcher = ContainerTerminalLauncher(runOpen: { _ in false }) let controller = ContainerIndicatorController( commandRunner: probe, localization: PluginLocalization(bundle: .main), @@ -742,6 +745,235 @@ final class ContainerIndicatorPluginTests: XCTestCase { XCTAssertEqual(controller.lastErrorMessage, "容器标识无效") } + func testTerminalApplicationArgumentsTargetTheSelectedApp() { + let script = URL(fileURLWithPath: "/tmp/container-web.command") + let terminal = URL(fileURLWithPath: "/System/Applications/Utilities/Terminal.app") + let iterm = URL(fileURLWithPath: "/Applications/iTerm.app") + let ghostty = URL(fileURLWithPath: "/Applications/Ghostty.app") + let wezterm = URL(fileURLWithPath: "/Applications/WezTerm.app") + let kitty = URL(fileURLWithPath: "/Applications/kitty.app") + let alacritty = URL(fileURLWithPath: "/Applications/Alacritty.app") + + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .systemDefault, + applicationURL: nil, + scriptURL: script + ), + [script.path] + ) + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .terminal, + applicationURL: terminal, + scriptURL: script + ), + ["-a", terminal.path, script.path] + ) + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .iterm, + applicationURL: iterm, + scriptURL: script + ), + ["-a", iterm.path, script.path] + ) + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .ghostty, + applicationURL: ghostty, + scriptURL: script + ), + ["-na", ghostty.path, "--args", "-e", script.path] + ) + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .wezterm, + applicationURL: wezterm, + scriptURL: script + ), + ["-na", wezterm.path, "--args", "start", "--", script.path] + ) + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .kitty, + applicationURL: kitty, + scriptURL: script + ), + ["-na", kitty.path, "--args", script.path] + ) + XCTAssertEqual( + ContainerTerminalLauncher.openArguments( + application: .alacritty, + applicationURL: alacritty, + scriptURL: script + ), + ["-na", alacritty.path, "--args", "-e", script.path] + ) + } + + func testGhosttyOpensANewTabWithAppleScript() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let ghostty = URL(fileURLWithPath: "/Applications/Ghostty.app") + var source: String? + var openedArguments: [String]? + let launcher = ContainerTerminalLauncher( + directory: directory, + applicationResolver: { application in + application == .ghostty ? ghostty : nil + }, + runOpen: { arguments in + openedArguments = arguments + return true + }, + runAppleScript: { script in + source = script + return true + } + ) + + try launcher.launch( + target: .container(id: "web"), + executable: "/opt/homebrew/bin/container", + application: .ghostty + ) + + let script = try XCTUnwrap(source) + XCTAssertTrue(script.contains("new tab in win with configuration cfg")) + XCTAssertTrue(script.contains("set command of cfg to")) + XCTAssertTrue(script.contains("container-web.command")) + XCTAssertNil(openedArguments, "A scripted terminal must not fall back to open") + } + + func testITermOpensANewTabWithAppleScript() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let iterm = URL(fileURLWithPath: "/Applications/iTerm.app") + var source: String? + let launcher = ContainerTerminalLauncher( + directory: directory, + applicationResolver: { application in + application == .iterm ? iterm : nil + }, + runAppleScript: { script in + source = script + return true + } + ) + + try launcher.launch( + target: .machine(id: "default"), + executable: "/opt/homebrew/bin/container", + application: .iterm + ) + + let script = try XCTUnwrap(source) + XCTAssertTrue(script.contains("create tab with default profile command")) + XCTAssertTrue(script.contains("machine-default.command")) + } + + func testTerminalLauncherFallsBackToSystemDefaultWhenAppIsMissing() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + var openedArguments: [String]? + let launcher = ContainerTerminalLauncher( + directory: directory, + applicationResolver: { _ in nil }, + runOpen: { arguments in + openedArguments = arguments + return true + } + ) + + try launcher.launch( + target: .machine(id: "default"), + executable: "/opt/homebrew/bin/container", + application: .iterm + ) + + XCTAssertEqual(openedArguments?.count, 1, "A missing app falls back to the system default") + XCTAssertEqual(openedArguments?.first?.hasSuffix(".command"), true) + } + + func testSettingsListInstalledTerminalsAndPersistSelection() throws { + let storage = InMemoryPluginStorage() + let plugin = makePlugin( + probe: CommandProbe(), + storage: storage, + installedApplications: [.systemDefault, .ghostty] + ) + + let row = try XCTUnwrap(settingsRows(of: plugin).first) + guard case let .picker(selectionID, options, _) = row.control else { + return XCTFail("Expected a terminal picker") + } + XCTAssertEqual( + selectionID, + "ghostty", + "An installed tab-capable terminal becomes the initial default" + ) + XCTAssertEqual(options.map(\.id), ["systemDefault", "ghostty"]) + + plugin.handleSettingsAction( + .setSelection(controlID: row.id, optionID: "systemDefault") + ) + + XCTAssertEqual(storage.string(forKey: "terminal.application.v1"), "systemDefault") + XCTAssertEqual(plugin.controller.terminalApplication, .systemDefault) + + let updatedRow = try XCTUnwrap(settingsRows(of: plugin).first) + guard case let .picker(updatedSelection, _, _) = updatedRow.control else { + return XCTFail("Expected a terminal picker") + } + XCTAssertEqual(updatedSelection, "systemDefault") + } + + func testDefaultTerminalPrefersInstalledTabCapableApp() { + let plugin = makePlugin( + probe: CommandProbe(), + installedApplications: [.systemDefault, .terminal, .ghostty] + ) + + XCTAssertEqual( + plugin.controller.terminalApplication, + .ghostty, + "A tab-capable terminal is the initial default when installed" + ) + } + + func testDefaultTerminalFallsBackToSystemDefaultWithoutTabCapableApp() { + let plugin = makePlugin( + probe: CommandProbe(), + installedApplications: [.systemDefault, .terminal] + ) + + XCTAssertEqual(plugin.controller.terminalApplication, .systemDefault) + } + + func testSettingsFallBackToSystemDefaultWhenSavedTerminalIsGone() throws { + let storage = InMemoryPluginStorage() + storage.set("iterm", forKey: "terminal.application.v1") + let plugin = makePlugin( + probe: CommandProbe(), + storage: storage, + installedApplications: [.systemDefault, .ghostty] + ) + + let row = try XCTUnwrap(settingsRows(of: plugin).first) + guard case let .picker(selectionID, _, _) = row.control else { + return XCTFail("Expected a terminal picker") + } + XCTAssertEqual( + selectionID, + "systemDefault", + "A terminal removed since the setting was saved is not offered" + ) + } + // MARK: - Helpers private func statsJSON( @@ -774,15 +1006,35 @@ final class ContainerIndicatorPluginTests: XCTestCase { private func makePlugin( probe: CommandProbe, - commandPath: String? = "/opt/homebrew/bin/container" + commandPath: String? = "/opt/homebrew/bin/container", + storage: (any PluginStorage)? = nil, + installedApplications: [ContainerTerminalApplication] = ContainerTerminalApplication.allCases ) -> ContainerIndicatorPlugin { let controller = makeController(probe: probe, commandPath: commandPath) return ContainerIndicatorPlugin( controller: controller, - localization: PluginLocalization(bundle: .main) + localization: PluginLocalization(bundle: .main), + storage: storage ?? InMemoryPluginStorage(), + installedTerminalApplications: { installedApplications } ) } + private func settingsRows( + of plugin: ContainerIndicatorPlugin + ) throws -> [PluginSettingsRow] { + let page = try XCTUnwrap(plugin.settingsPage) + guard case let .form(sections) = page.body else { + XCTFail("Expected a form settings page") + return [] + } + let section = try XCTUnwrap(sections.first) + guard case let .rows(rows) = section.content else { + XCTFail("Expected declarative settings rows") + return [] + } + return rows + } + private func waitUntil( _ condition: @MainActor () async -> Bool, file: StaticString = #filePath, @@ -973,3 +1225,22 @@ private actor CommandProbe: ContainerCommandRunning { machineInspectOutputs[id] ?? "" } } + +@MainActor +private final class InMemoryPluginStorage: PluginStorage { + private var store: [String: Any] = [:] + + func object(forKey key: String) -> Any? { store[key] } + func data(forKey key: String) -> Data? { store[key] as? Data } + func string(forKey key: String) -> String? { store[key] as? String } + func stringArray(forKey key: String) -> [String]? { store[key] as? [String] } + func integer(forKey key: String) -> Int { store[key] as? Int ?? 0 } + func bool(forKey key: String) -> Bool { store[key] as? Bool ?? false } + func set(_ value: Any?, forKey key: String) { store[key] = value } + func removeObject(forKey key: String) { store.removeValue(forKey: key) } + func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) { + guard store[key] == nil, let value = store[legacyKey] else { return } + store[key] = value + store.removeValue(forKey: legacyKey) + } +} diff --git a/Plugins/ContainerIndicator/plugin.json b/Plugins/ContainerIndicator/plugin.json index 26d084f3d..64ab911e0 100644 --- a/Plugins/ContainerIndicator/plugin.json +++ b/Plugins/ContainerIndicator/plugin.json @@ -71,7 +71,7 @@ "panelItems": [ "widget" ], - "settings": "none" + "settings": "form" }, "permissions": [], "category": "monitoring", @@ -181,7 +181,9 @@ "dataObserved": [ "container-service-state" ], - "dataPersisted": [], + "dataPersisted": [ + "plugin-configuration" + ], "retention": { "policy": "none" }, diff --git a/changes/unreleased/container-indicator-performance.md b/changes/unreleased/container-indicator-performance.md index 4f119e28f..ed1d0dac1 100644 --- a/changes/unreleased/container-indicator-performance.md +++ b/changes/unreleased/container-indicator-performance.md @@ -4,4 +4,4 @@ type: added area: Container Indicator --- -The Apple Container status card now shows live CPU, memory, and disk usage. Containers gain a configuration window and one-click terminal access, and machines can also open a terminal. +The Apple Container status card now shows live CPU, memory, and disk usage. Containers gain a configuration window and one-click terminal access, and machines can also open a terminal in the app chosen in settings. From 4c04b6f2d090a4a5dd19ab9cfc80240eecd8e719 Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Thu, 29 Oct 2026 22:55:01 +0800 Subject: [PATCH 4/6] fix(container-indicator): add the missing terminal application catalog --- ...ontainerIndicatorTerminalApplication.swift | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalApplication.swift diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalApplication.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalApplication.swift new file mode 100644 index 000000000..0120549b4 --- /dev/null +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorTerminalApplication.swift @@ -0,0 +1,101 @@ +import AppKit +import Foundation + +/// Terminals the plugin can hand an interactive container shell to. Only the +/// system default and installed applications are offered in settings, so a +/// missing app never becomes a dead choice. +public enum ContainerTerminalApplication: String, CaseIterable, Equatable, Sendable { + case systemDefault + case terminal + case iterm + case ghostty + case wezterm + case kitty + case alacritty + + public var bundleIdentifier: String? { + switch self { + case .systemDefault: + return nil + case .terminal: + return "com.apple.Terminal" + case .iterm: + return "com.googlecode.iterm2" + case .ghostty: + return "com.mitchellh.ghostty" + case .wezterm: + return "com.github.wez.wezterm" + case .kitty: + return "net.kovidgoyal.kitty" + case .alacritty: + return "org.alacritty" + } + } + + /// Stable settings option identifier: the raw value. + public var optionID: String { rawValue } + + /// Brand names stay untranslated; only the system default needs copy. + public var localizationKey: String { + switch self { + case .systemDefault: + return "settings.terminal.application.systemDefault" + default: + return "settings.terminal.application.\(rawValue)" + } + } + + public var defaultTitle: String { + switch self { + case .systemDefault: + return "系统默认" + case .terminal: + return "终端" + case .iterm: + return "iTerm" + case .ghostty: + return "Ghostty" + case .wezterm: + return "WezTerm" + case .kitty: + return "kitty" + case .alacritty: + return "Alacritty" + } + } + + /// Maps a resolved application bundle back to a supported terminal. + public init?(applicationURL: URL) { + guard let bundleIdentifier = Bundle(url: applicationURL)?.bundleIdentifier, + let match = Self.allCases.first(where: { + $0.bundleIdentifier == bundleIdentifier + }) + else { + return nil + } + self = match + } +} + +/// Resolves the selected terminal to a concrete application bundle. +@MainActor +public enum ContainerTerminalApplicationCatalog { + public static func applicationURL( + for application: ContainerTerminalApplication, + workspace: NSWorkspace = .shared + ) -> URL? { + guard let bundleIdentifier = application.bundleIdentifier else { return nil } + return workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) + } + + /// The picker keeps the system default first and then every terminal that + /// is actually installed, so the list matches what the launcher can use. + public static func installedApplications( + workspace: NSWorkspace = .shared + ) -> [ContainerTerminalApplication] { + ContainerTerminalApplication.allCases.filter { application in + application == .systemDefault + || applicationURL(for: application, workspace: workspace) != nil + } + } +} From 2f6344d27223b1496069393999d33a4bdc93374b Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Thu, 29 Oct 2026 22:55:06 +0800 Subject: [PATCH 5/6] feat(container-indicator): chart live metrics with hover readouts --- .../Resources/Localizable.xcstrings | 77 ++++++ .../ContainerIndicatorController.swift | 29 +++ .../ContainerIndicatorDetailWindow.swift | 238 ++++++++++++++++-- .../ContainerIndicatorMetricSeries.swift | 76 ++++++ .../Tests/ContainerIndicatorPluginTests.swift | 146 +++++++++++ Plugins/ContainerIndicator/project.yml | 3 + .../container-indicator-performance.md | 2 +- 7 files changed, 553 insertions(+), 18 deletions(-) create mode 100644 Plugins/ContainerIndicator/Sources/ContainerIndicatorMetricSeries.swift create mode 100644 Plugins/ContainerIndicator/project.yml diff --git a/Plugins/ContainerIndicator/Resources/Localizable.xcstrings b/Plugins/ContainerIndicator/Resources/Localizable.xcstrings index de13b4df9..a33e14b43 100644 --- a/Plugins/ContainerIndicator/Resources/Localizable.xcstrings +++ b/Plugins/ContainerIndicator/Resources/Localizable.xcstrings @@ -1926,6 +1926,83 @@ } } }, + "detail.live.collecting": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الجمع…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird erfasst…" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Collecting…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Recopilando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Collecte…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "収集中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수집 중…" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Coletando…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сбор данных…" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Toplanıyor…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "采集中…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "收集中…" + } + } + } + }, "detail.section.live": { "extractionState": "manual", "localizations": { diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift index 6692a637a..3e4d8e533 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorController.swift @@ -7,6 +7,8 @@ import MacToolsPluginKit @MainActor public final class ContainerIndicatorController: ObservableObject { public static let pollInterval: TimeInterval = 5 + /// At the five-second poll this covers roughly twelve minutes of history. + public static let performanceHistoryLimit = 150 @Published public private(set) var snapshot: ContainerSnapshot = .unavailable @Published public private(set) var commandPath: String? @@ -16,6 +18,9 @@ public final class ContainerIndicatorController: ObservableObject { /// the follow-up refresh so the user can still read what went wrong. @Published public private(set) var operationErrorMessage: String? @Published public private(set) var checkErrorMessage: String? + /// Retained live samples per running container, oldest first. The detail + /// window charts them; entries disappear as soon as a container stops. + @Published public private(set) var performanceHistory: [String: [ContainerPerformance]] = [:] public var lastErrorMessage: String? { operationErrorMessage ?? checkErrorMessage @@ -119,6 +124,9 @@ public final class ContainerIndicatorController: ObservableObject { // gap would describe an average instead of the current load. previousStats = [:] previousStatsAt = nil + // The chart has no honest way to bridge the pause: a resumed line + // would describe a gap it never sampled. + performanceHistory = [:] // Cancelling the refresh also cancels the CLI command it is waiting on, // so a hidden panel never leaves a `container` process running behind it. cancelInFlightRefresh() @@ -350,6 +358,7 @@ public final class ContainerIndicatorController: ObservableObject { } previousStats = counters previousStatsAt = sampledAt + recordPerformanceHistory(containers) } else { statsFailure = listFailureMessage( label: localization.string( @@ -377,6 +386,7 @@ public final class ContainerIndicatorController: ObservableObject { } else { previousStats = [:] previousStatsAt = nil + performanceHistory = [:] } isChecking = false @@ -395,6 +405,25 @@ public final class ContainerIndicatorController: ObservableObject { ) } + /// Appends the newest sample per running container and drops entries for + /// containers that stopped, so the charts only ever describe what is live. + private func recordPerformanceHistory(_ containers: [ContainerInstance]) { + var history = performanceHistory + var runningIDs: Set = [] + for container in containers where container.isRunning { + runningIDs.insert(container.id) + guard let performance = container.performance else { continue } + var samples = history[container.id, default: []] + samples.append(performance) + if samples.count > Self.performanceHistoryLimit { + samples.removeFirst(samples.count - Self.performanceHistoryLimit) + } + history[container.id] = samples + } + history = history.filter { runningIDs.contains($0.key) } + performanceHistory = history + } + // MARK: - Operations public func setServiceRunning(_ running: Bool) { diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift index 64ee909c2..32a2282e9 100644 --- a/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorDetailWindow.swift @@ -1,4 +1,5 @@ import AppKit +import Charts import MacToolsPluginKit import SwiftUI @@ -86,7 +87,7 @@ final class ContainerIndicatorDetailWindow: NSPanel { let size = NSSize(width: 520, height: 620) super.init( contentRect: NSRect(origin: .zero, size: size), - styleMask: [.titled, .closable, .resizable], + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false ) @@ -97,17 +98,44 @@ final class ContainerIndicatorDetailWindow: NSPanel { hidesOnDeactivate = false isReleasedWhenClosed = false collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] - backgroundColor = .windowBackgroundColor + isMovableByWindowBackground = true + titlebarAppearsTransparent = true + titleVisibility = .hidden + titlebarSeparatorStyle = .none + backgroundColor = .clear + isOpaque = false + hasShadow = true + animationBehavior = .utilityWindow minSize = NSSize(width: 440, height: 420) setContentSize(size) - contentView = NSHostingView( + let effectView = NSVisualEffectView() + effectView.material = .popover + effectView.blendingMode = .behindWindow + effectView.state = .active + effectView.wantsLayer = true + effectView.layer?.cornerRadius = 14 + effectView.layer?.masksToBounds = true + + let hostingView = NSHostingView( rootView: ContainerIndicatorDetailView( containerID: containerID, controller: controller, localization: localization ) ) + hostingView.translatesAutoresizingMaskIntoConstraints = false + hostingView.wantsLayer = true + hostingView.layer?.backgroundColor = NSColor.clear.cgColor + effectView.addSubview(hostingView) + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: effectView.leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: effectView.trailingAnchor), + hostingView.topAnchor.constraint(equalTo: effectView.topAnchor), + hostingView.bottomAnchor.constraint(equalTo: effectView.bottomAnchor), + ]) + + contentView = effectView } override var canBecomeKey: Bool { true } @@ -154,11 +182,13 @@ private struct ContainerIndicatorDetailView: View { } } .formStyle(.grouped) + .scrollContentBackground(.hidden) } } else { missingState } } + .background(Color.clear) .environment(\.locale, runtimeLocale.locale) .environment( \.layoutDirection, @@ -192,7 +222,9 @@ private struct ContainerIndicatorDetailView: View { } } .padding(.horizontal, 20) - .padding(.top, 16) + // Clear the traffic lights that float over the full-size glass + // content view. + .padding(.top, 34) .padding(.bottom, 10) } @@ -360,19 +392,48 @@ private struct ContainerIndicatorDetailView: View { } private func liveSection(_ container: ContainerInstance) -> some View { - Section { - field( - localization.string("detail.field.cpu", defaultValue: "CPU"), - container.performance?.cpuPercent.map(ContainerIndicatorFormatting.percent) ?? placeholder - ) - field( - localization.string("detail.field.memory", defaultValue: "内存"), - memoryUsage(container.performance) - ) - field( - localization.string("detail.field.disk", defaultValue: "磁盘"), - diskUsage(container.performance) - ) + let history = controller.performanceHistory[container.id] ?? [] + return Section { + VStack(spacing: 10) { + ContainerIndicatorLiveMetricCard( + title: localization.string("detail.field.cpu", defaultValue: "CPU"), + value: container.performance?.cpuPercent + .map(ContainerIndicatorFormatting.percent) ?? placeholder, + points: ContainerIndicatorMetricSeries.cpu(history), + color: Color(nsColor: .systemBlue), + locale: runtimeLocale.locale, + collectingText: localization.string( + "detail.live.collecting", + defaultValue: "采集中…" + ), + format: ContainerIndicatorFormatting.percent + ) + ContainerIndicatorLiveMetricCard( + title: localization.string("detail.field.memory", defaultValue: "内存"), + value: memoryUsage(container.performance), + points: ContainerIndicatorMetricSeries.memoryMegabytes(history), + color: Color(nsColor: .systemPurple), + locale: runtimeLocale.locale, + collectingText: localization.string( + "detail.live.collecting", + defaultValue: "采集中…" + ), + format: { ContainerIndicatorFormatting.bytes(Int($0 * 1_048_576)) } + ) + ContainerIndicatorLiveMetricCard( + title: localization.string("detail.field.disk", defaultValue: "磁盘"), + value: diskUsage(container.performance), + points: ContainerIndicatorMetricSeries.diskKilobytesPerSecond(history), + color: Color(nsColor: .systemOrange), + locale: runtimeLocale.locale, + collectingText: localization.string( + "detail.live.collecting", + defaultValue: "采集中…" + ), + format: { ContainerIndicatorFormatting.rate($0 * 1024) } + ) + } + .padding(.vertical, 2) } header: { sectionTitle(localization.string("detail.section.live", defaultValue: "实时")) } @@ -466,3 +527,146 @@ private struct ContainerIndicatorDetailView: View { ) } } + +// MARK: - Live metric card + +/// One live metric: the latest reading next to a sparkline of the retained +/// samples. The axes stay hidden because the value above carries the number +/// and the shape carries the trend. Hovering a chart replaces the reading with +/// the hovered sample's time and value. +private struct ContainerIndicatorLiveMetricCard: View { + let title: String + let value: String + let points: [ContainerIndicatorMetricPoint] + let color: Color + let locale: Locale + let collectingText: String + let format: (Double) -> String + + @State private var hoveredIndex: Int? + + private var hoveredPoint: ContainerIndicatorMetricPoint? { + guard let hoveredIndex, points.indices.contains(hoveredIndex) else { + return nil + } + return points[hoveredIndex] + } + + private var displayValue: String { + guard let hoveredPoint else { return value } + let time = hoveredPoint.timestamp.formatted( + Date.FormatStyle(date: .omitted, time: .standard).locale(locale) + ) + return "\(time) · \(format(hoveredPoint.value))" + } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(title) + .font(PluginTypography.caption.font) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + Text(displayValue) + .font(PluginTypography.value.font) + .lineLimit(1) + .monospacedDigit() + } + chart + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color.primary.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 0.5) + ) + } + + @ViewBuilder + private var chart: some View { + if points.count >= 2 { + Chart { + ForEach(points) { point in + AreaMark( + x: .value("Sample", point.index), + y: .value("Value", point.value) + ) + .foregroundStyle(color.opacity(0.15)) + LineMark( + x: .value("Sample", point.index), + y: .value("Value", point.value) + ) + .foregroundStyle(color) + .interpolationMethod(.monotone) + } + if let hoveredPoint { + RuleMark(x: .value("Sample", hoveredPoint.index)) + .foregroundStyle(color.opacity(0.35)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 2])) + PointMark( + x: .value("Sample", hoveredPoint.index), + y: .value("Value", hoveredPoint.value) + ) + .foregroundStyle(color) + .symbolSize(28) + } + } + .chartXAxis(.hidden) + .chartYAxis(.hidden) + // Zero-anchored areas turn a nearly constant series into a solid + // block; the padded range keeps the line's shape visible. + .chartYScale(domain: .automatic(includesZero: false)) + .frame(height: 46) + .chartOverlay { proxy in + GeometryReader { geometry in + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .onContinuousHover { phase in + updateHover(phase, proxy: proxy, geometry: geometry) + } + } + } + } else { + RoundedRectangle(cornerRadius: 5) + .fill(color.opacity(0.08)) + .frame(height: 46) + .overlay { + Text(collectingText) + .font(PluginTypography.caption.font) + .foregroundStyle(.tertiary) + } + } + } + + private func updateHover( + _ phase: HoverPhase, + proxy: ChartProxy, + geometry: GeometryProxy + ) { + switch phase { + case .active(let location): + guard let plotFrame = proxy.plotFrame else { + hoveredIndex = nil + return + } + let frame = geometry[plotFrame] + guard frame.contains(location) else { + hoveredIndex = nil + return + } + let sample: Double? = proxy.value(atX: location.x - frame.origin.x) + guard let sample else { return } + hoveredIndex = min(max(Int(sample.rounded()), 0), points.count - 1) + case .ended: + hoveredIndex = nil + @unknown default: + hoveredIndex = nil + } + } +} diff --git a/Plugins/ContainerIndicator/Sources/ContainerIndicatorMetricSeries.swift b/Plugins/ContainerIndicator/Sources/ContainerIndicatorMetricSeries.swift new file mode 100644 index 000000000..cbea520e0 --- /dev/null +++ b/Plugins/ContainerIndicator/Sources/ContainerIndicatorMetricSeries.swift @@ -0,0 +1,76 @@ +import Foundation + +/// One chartable live sample: the value plus the moment it was read, so a +/// hover can name both. +public struct ContainerIndicatorMetricPoint: Hashable, Sendable, Identifiable { + public let index: Int + public let timestamp: Date + public let value: Double + + public var id: Int { index } + + public init(index: Int, timestamp: Date, value: Double) { + self.index = index + self.timestamp = timestamp + self.value = value + } +} + +/// Turns the retained live samples into the numeric series the detail window +/// charts. Each metric has its own availability rules — CPU and disk need two +/// daemon samples — so the mapping skips missing points instead of plotting a +/// fabricated zero. +public enum ContainerIndicatorMetricSeries { + /// Percentage of the container's configured CPU allotment. + public static func cpu(_ history: [ContainerPerformance]) -> [ContainerIndicatorMetricPoint] { + points(history, value: \.cpuPercent) + } + + /// Resident memory in megabytes. + public static func memoryMegabytes( + _ history: [ContainerPerformance] + ) -> [ContainerIndicatorMetricPoint] { + points(history) { performance in + performance.memoryBytes.map { Double($0) / 1_048_576 } + } + } + + /// Combined block read and write throughput in kilobytes per second. + public static func diskKilobytesPerSecond( + _ history: [ContainerPerformance] + ) -> [ContainerIndicatorMetricPoint] { + points(history, value: \.diskTotalBytesPerSecond) + .map { point in + ContainerIndicatorMetricPoint( + index: point.index, + timestamp: point.timestamp, + value: point.value / 1024 + ) + } + } + + private static func points( + _ history: [ContainerPerformance], + value: KeyPath + ) -> [ContainerIndicatorMetricPoint] { + points(history) { $0[keyPath: value] } + } + + private static func points( + _ history: [ContainerPerformance], + value: (ContainerPerformance) -> Double? + ) -> [ContainerIndicatorMetricPoint] { + history + .compactMap { performance in + value(performance).map { (performance.sampledAt, $0) } + } + .enumerated() + .map { offset, sample in + ContainerIndicatorMetricPoint( + index: offset, + timestamp: sample.0, + value: sample.1 + ) + } + } +} diff --git a/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift b/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift index 19ea9bc34..de48969b4 100644 --- a/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift +++ b/Plugins/ContainerIndicator/Tests/ContainerIndicatorPluginTests.swift @@ -631,6 +631,152 @@ final class ContainerIndicatorPluginTests: XCTestCase { XCTAssertNil(controller.snapshot.containers.first?.performance) } + // MARK: - Performance history + + func testPerformanceHistoryRetainsSamplesAndCapsLength() async throws { + let probe = CommandProbe() + await probe.stubService(exitCode: 0, output: #"{"status":"running"}"#) + await probe.stubContainers( + """ + [{"id":"c1","configuration":{"id":"web","resources":{"cpus":2}},\ + "status":{"state":"running"}}] + """ + ) + var clock = Date(timeIntervalSince1970: 1000) + let controller = makeController(probe: probe, now: { clock }) + let limit = ContainerIndicatorController.performanceHistoryLimit + + for index in 0...(limit + 4) { + clock.addTimeInterval(ContainerIndicatorController.pollInterval) + await probe.stubStats( + output: statsJSON( + cpu: UInt64(1_000_000 + index * 1_000_000), + read: 1000, + write: 2000 + ) + ) + await controller.refresh() + } + + let history = try XCTUnwrap(controller.performanceHistory["c1"]) + XCTAssertEqual( + history.count, + limit, + "The chart keeps a bounded window instead of growing without limit" + ) + XCTAssertEqual( + history.map(\.sampledAt), + history.map(\.sampledAt).sorted(), + "Samples stay in the order they were collected" + ) + XCTAssertEqual( + history.last?.sampledAt, + clock, + "The newest sample is the one just published" + ) + } + + func testPerformanceHistoryDropsContainersThatStop() async throws { + let probe = CommandProbe() + await probe.stubService(exitCode: 0, output: #"{"status":"running"}"#) + await probe.stubContainers( + """ + [{"id":"c1","configuration":{"id":"web","resources":{"cpus":2}},\ + "status":{"state":"running"}}] + """ + ) + await probe.stubStats(output: statsJSON(cpu: 1_000_000, read: 1000, write: 2000)) + let controller = makeController(probe: probe) + + await controller.refresh() + XCTAssertEqual(controller.performanceHistory["c1"]?.count, 1) + + await probe.stubContainers( + "[{\"id\":\"c1\",\"configuration\":{\"id\":\"web\"},\"status\":{\"state\":\"stopped\"}}]" + ) + await controller.refresh() + + XCTAssertNil( + controller.performanceHistory["c1"], + "A stopped container has no live series to chart" + ) + } + + func testStoppingMonitoringClearsPerformanceHistory() async throws { + let probe = CommandProbe() + await probe.stubService(exitCode: 0, output: #"{"status":"running"}"#) + await probe.stubContainers( + """ + [{"id":"c1","configuration":{"id":"web","resources":{"cpus":2}},\ + "status":{"state":"running"}}] + """ + ) + await probe.stubStats(output: statsJSON(cpu: 1_000_000, read: 1000, write: 2000)) + let controller = makeController(probe: probe) + + await controller.refresh() + XCTAssertFalse(controller.performanceHistory.isEmpty) + + controller.stopMonitoring() + + XCTAssertTrue( + controller.performanceHistory.isEmpty, + "A paused monitor cannot bridge the gap honestly, so the chart restarts" + ) + } + + func testMetricSeriesSkipsSamplesWithoutDerivedRates() { + let sampledAt = Date(timeIntervalSince1970: 0) + let later = Date(timeIntervalSince1970: 5) + let history = [ + ContainerPerformance( + cpuPercent: nil, + memoryBytes: 2_097_152, + memoryLimitBytes: 4_194_304, + diskReadBytesPerSecond: nil, + diskWriteBytesPerSecond: nil, + sampledAt: sampledAt + ), + ContainerPerformance( + cpuPercent: 25, + memoryBytes: 1_048_576, + memoryLimitBytes: 4_194_304, + diskReadBytesPerSecond: 1024, + diskWriteBytesPerSecond: 2048, + sampledAt: later + ), + ContainerPerformance( + cpuPercent: 75, + memoryBytes: nil, + memoryLimitBytes: nil, + diskReadBytesPerSecond: 512, + diskWriteBytesPerSecond: 512, + sampledAt: later + ), + ] + + let cpu = ContainerIndicatorMetricSeries.cpu(history) + XCTAssertEqual( + cpu.map(\.value), + [25, 75], + "The first sample has no CPU baseline and must not plot a zero" + ) + XCTAssertEqual( + cpu.map(\.timestamp), + [later, later], + "Hovering needs the moment each sample was read" + ) + XCTAssertEqual( + ContainerIndicatorMetricSeries.memoryMegabytes(history).map(\.value), + [2, 1] + ) + XCTAssertEqual( + ContainerIndicatorMetricSeries.diskKilobytesPerSecond(history).map(\.value), + [3, 1], + "Read and write are charted as one combined throughput" + ) + } + // MARK: - Terminal func testContainerTerminalScriptPrefersBashAndFallsBackToShell() { diff --git a/Plugins/ContainerIndicator/project.yml b/Plugins/ContainerIndicator/project.yml new file mode 100644 index 000000000..4c341f32d --- /dev/null +++ b/Plugins/ContainerIndicator/project.yml @@ -0,0 +1,3 @@ +settings: + base: + OTHER_LDFLAGS: -framework Charts diff --git a/changes/unreleased/container-indicator-performance.md b/changes/unreleased/container-indicator-performance.md index ed1d0dac1..b260c0024 100644 --- a/changes/unreleased/container-indicator-performance.md +++ b/changes/unreleased/container-indicator-performance.md @@ -4,4 +4,4 @@ type: added area: Container Indicator --- -The Apple Container status card now shows live CPU, memory, and disk usage. Containers gain a configuration window and one-click terminal access, and machines can also open a terminal in the app chosen in settings. +The Apple Container card now shows live CPU, memory, and disk usage. Containers gain a configuration window with CPU, memory, and disk I/O charts plus terminal access, and machines can open a terminal too. From 9d396cb272c3c8a6e952ef17c0806cc752dff0b3 Mon Sep 17 00:00:00 2001 From: AndrewDi Date: Thu, 29 Oct 2026 22:57:09 +0800 Subject: [PATCH 6/6] chore(site): regenerate plugin data for container indicator settings --- site/src/generated/plugins.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/generated/plugins.json b/site/src/generated/plugins.json index bce6f4c03..4b040ce21 100644 --- a/site/src/generated/plugins.json +++ b/site/src/generated/plugins.json @@ -7029,7 +7029,7 @@ "panelItems": [ "widget" ], - "settings": "none" + "settings": "form" }, "category": "monitoring", "displayName": "Apple Container状态", @@ -7218,7 +7218,9 @@ "dataObserved": [ "container-service-state" ], - "dataPersisted": [], + "dataPersisted": [ + "plugin-configuration" + ], "diagnosticExportsContainUserData": false, "networkDomains": [], "networkUse": "none",