[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
23
domain/systemManager/containerState.ts
Normal file
23
domain/systemManager/containerState.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { DockerContainerInfo } from './types';
|
||||
|
||||
/**
|
||||
* A paused container reports Status "Up 5 minutes (Paused)", so a bare
|
||||
* /up/i test classifies it as running too. Paused must win, and the
|
||||
* machine-readable State field is preferred over the human Status text.
|
||||
*/
|
||||
export function getContainerFlags(container: DockerContainerInfo): {
|
||||
isRunning: boolean;
|
||||
isPaused: boolean;
|
||||
} {
|
||||
const isPaused = container.state === 'paused' || /paused/i.test(container.status);
|
||||
const isRunning = !isPaused
|
||||
&& (container.state === 'running' || /up/i.test(container.status));
|
||||
return { isRunning, isPaused };
|
||||
}
|
||||
|
||||
export function getContainerTone(container: DockerContainerInfo): 'success' | 'warning' | 'muted' {
|
||||
const { isRunning, isPaused } = getContainerFlags(container);
|
||||
if (isPaused) return 'warning';
|
||||
if (isRunning) return 'success';
|
||||
return 'muted';
|
||||
}
|
||||
176
domain/systemManager/dockerIconBundled.ts
Normal file
176
domain/systemManager/dockerIconBundled.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
// Auto-generated by scripts/sync-docker-icons.mjs — do not edit.
|
||||
export const BUNDLED_DOCKER_ICON_IDS = new Set<string>([
|
||||
'adminer',
|
||||
'airbyte',
|
||||
'airflow',
|
||||
'almalinux',
|
||||
'alpine',
|
||||
'ansible',
|
||||
'apache',
|
||||
'appwrite',
|
||||
'arangodb',
|
||||
'archlinux',
|
||||
'argo',
|
||||
'authelia',
|
||||
'authentik',
|
||||
'bitwarden',
|
||||
'bookstack',
|
||||
'buildkite',
|
||||
'caddy',
|
||||
'cassandra',
|
||||
'centos',
|
||||
'cilium',
|
||||
'circleci',
|
||||
'clickhouse',
|
||||
'cloudflare',
|
||||
'cockroachdb',
|
||||
'confluence',
|
||||
'consul',
|
||||
'container',
|
||||
'containerd',
|
||||
'couchbase',
|
||||
'couchdb',
|
||||
'databricks',
|
||||
'datadog',
|
||||
'debian',
|
||||
'deno',
|
||||
'directus',
|
||||
'docker',
|
||||
'drone',
|
||||
'druid',
|
||||
'drupal',
|
||||
'duckdb',
|
||||
'elasticsearch',
|
||||
'emby',
|
||||
'envoy',
|
||||
'esphome',
|
||||
'etcd',
|
||||
'fedora',
|
||||
'flink',
|
||||
'fluentbit',
|
||||
'fluentd',
|
||||
'forgejo',
|
||||
'gentoo',
|
||||
'ghost',
|
||||
'gitea',
|
||||
'github',
|
||||
'gitlab',
|
||||
'golang',
|
||||
'grafana',
|
||||
'hadoop',
|
||||
'harbor',
|
||||
'hasura',
|
||||
'hbase',
|
||||
'helm',
|
||||
'hive',
|
||||
'hivemq',
|
||||
'homeassistant',
|
||||
'homebridge',
|
||||
'icinga',
|
||||
'immich',
|
||||
'influxdb',
|
||||
'istio',
|
||||
'jaeger',
|
||||
'jellyfin',
|
||||
'jenkins',
|
||||
'jfrog',
|
||||
'jira',
|
||||
'jitsi',
|
||||
'k3s',
|
||||
'kafka',
|
||||
'keycloak',
|
||||
'kibana',
|
||||
'kong',
|
||||
'kubernetes',
|
||||
'linkerd',
|
||||
'listmonk',
|
||||
'logstash',
|
||||
'mariadb',
|
||||
'matrix',
|
||||
'mattermost',
|
||||
'mautic',
|
||||
'meilisearch',
|
||||
'memcached',
|
||||
'metabase',
|
||||
'milvus',
|
||||
'minio',
|
||||
'mongodb',
|
||||
'mosquitto',
|
||||
'mysql',
|
||||
'nacos',
|
||||
'nats',
|
||||
'neo4j',
|
||||
'netdata',
|
||||
'nextcloud',
|
||||
'nginx',
|
||||
'nifi',
|
||||
'nodejs',
|
||||
'nodered',
|
||||
'nomad',
|
||||
'ollama',
|
||||
'openjdk',
|
||||
'opensearch',
|
||||
'opensuse',
|
||||
'opentelemetry',
|
||||
'packer',
|
||||
'php',
|
||||
'phpmyadmin',
|
||||
'plex',
|
||||
'pocketbase',
|
||||
'podman',
|
||||
'polaris',
|
||||
'portainer',
|
||||
'postgresql',
|
||||
'prefect',
|
||||
'presto',
|
||||
'prometheus',
|
||||
'pulsar',
|
||||
'pulumi',
|
||||
'python',
|
||||
'pytorch',
|
||||
'qdrant',
|
||||
'rabbitmq',
|
||||
'radarr',
|
||||
'rancher',
|
||||
'redhat',
|
||||
'redis',
|
||||
'rocketmq',
|
||||
'rockylinux',
|
||||
'ruby',
|
||||
'rust',
|
||||
'scylladb',
|
||||
'sentry',
|
||||
'singlestore',
|
||||
'snowflake',
|
||||
'solr',
|
||||
'sonarqube',
|
||||
'sonarr',
|
||||
'spark',
|
||||
'springboot',
|
||||
'strapi',
|
||||
'supabase',
|
||||
'superset',
|
||||
'tailscale',
|
||||
'tasmota',
|
||||
'temporal',
|
||||
'tensorflow',
|
||||
'terraform',
|
||||
'thanos',
|
||||
'tidb',
|
||||
'timescaledb',
|
||||
'tomcat',
|
||||
'traefik',
|
||||
'trino',
|
||||
'ubuntu',
|
||||
'vault',
|
||||
'vaultwarden',
|
||||
'victoriametrics',
|
||||
'vitess',
|
||||
'wireguard',
|
||||
'wordpress',
|
||||
]);
|
||||
|
||||
/** Non-default filenames (e.g. official PNG assets). Default is `${id}.svg`. */
|
||||
export const DOCKER_ICON_FILES: Record<string, string> = {
|
||||
'memcached': 'memcached.png',
|
||||
};
|
||||
607
domain/systemManager/dockerImageIcons.ts
Normal file
607
domain/systemManager/dockerImageIcons.ts
Normal file
@@ -0,0 +1,607 @@
|
||||
import { BUNDLED_DOCKER_ICON_IDS, DOCKER_ICON_FILES } from './dockerIconBundled';
|
||||
|
||||
const IMAGE_ICON_RULES: Array<{ pattern: RegExp; icon: string }> = [
|
||||
// Service discovery / mesh (specific first)
|
||||
{ pattern: /nacos/i, icon: 'nacos' },
|
||||
{ pattern: /polaris/i, icon: 'polaris' },
|
||||
{ pattern: /mosquitto/i, icon: 'mosquitto' },
|
||||
{ pattern: /kong/i, icon: 'kong' },
|
||||
{ pattern: /istio/i, icon: 'istio' },
|
||||
{ pattern: /linkerd/i, icon: 'linkerd' },
|
||||
{ pattern: /cilium/i, icon: 'cilium' },
|
||||
{ pattern: /envoy/i, icon: 'envoy' },
|
||||
// Orchestration / IaC
|
||||
{ pattern: /kubernetes|k8s/i, icon: 'kubernetes' },
|
||||
{ pattern: /k3s/i, icon: 'k3s' },
|
||||
{ pattern: /helm/i, icon: 'helm' },
|
||||
{ pattern: /rancher/i, icon: 'rancher' },
|
||||
{ pattern: /argo-?cd|argocd/i, icon: 'argo' },
|
||||
{ pattern: /nomad/i, icon: 'nomad' },
|
||||
{ pattern: /terraform/i, icon: 'terraform' },
|
||||
{ pattern: /ansible/i, icon: 'ansible' },
|
||||
{ pattern: /pulumi/i, icon: 'pulumi' },
|
||||
{ pattern: /packer/i, icon: 'packer' },
|
||||
{ pattern: /containerd/i, icon: 'containerd' },
|
||||
{ pattern: /podman/i, icon: 'podman' },
|
||||
// Web / proxy
|
||||
{ pattern: /nginx/i, icon: 'nginx' },
|
||||
{ pattern: /traefik/i, icon: 'traefik' },
|
||||
{ pattern: /caddy/i, icon: 'caddy' },
|
||||
{ pattern: /apache|httpd/i, icon: 'apache' },
|
||||
{ pattern: /tomcat/i, icon: 'tomcat' },
|
||||
{ pattern: /wordpress/i, icon: 'wordpress' },
|
||||
{ pattern: /ghost/i, icon: 'ghost' },
|
||||
{ pattern: /drupal/i, icon: 'drupal' },
|
||||
// Language runtimes
|
||||
{ pattern: /golang|^go$|^go[-:]/i, icon: 'golang' },
|
||||
{ pattern: /node|nodedotjs/i, icon: 'nodejs' },
|
||||
{ pattern: /python/i, icon: 'python' },
|
||||
{ pattern: /temurin|corretto|azul|openjdk|\bjava(?!script)\b/i, icon: 'openjdk' },
|
||||
{ pattern: /springboot|spring-boot|spring/i, icon: 'springboot' },
|
||||
{ pattern: /rust/i, icon: 'rust' },
|
||||
{ pattern: /deno/i, icon: 'deno' },
|
||||
{ pattern: /php(?!myadmin)/i, icon: 'php' },
|
||||
{ pattern: /ruby/i, icon: 'ruby' },
|
||||
// Databases
|
||||
{ pattern: /postgres|postgresql/i, icon: 'postgresql' },
|
||||
{ pattern: /mariadb|galera|percona/i, icon: 'mariadb' },
|
||||
{ pattern: /mysql/i, icon: 'mysql' },
|
||||
{ pattern: /mongo/i, icon: 'mongodb' },
|
||||
{ pattern: /valkey/i, icon: 'valkey' },
|
||||
{ pattern: /dragonfly/i, icon: 'dragonfly' },
|
||||
{ pattern: /redis/i, icon: 'redis' },
|
||||
{ pattern: /memcached/i, icon: 'memcached' },
|
||||
{ pattern: /clickhouse/i, icon: 'clickhouse' },
|
||||
{ pattern: /cockroach/i, icon: 'cockroachdb' },
|
||||
{ pattern: /neo4j/i, icon: 'neo4j' },
|
||||
{ pattern: /cassandra/i, icon: 'cassandra' },
|
||||
{ pattern: /couchdb/i, icon: 'couchdb' },
|
||||
{ pattern: /couchbase/i, icon: 'couchbase' },
|
||||
{ pattern: /arangodb/i, icon: 'arangodb' },
|
||||
{ pattern: /tidb/i, icon: 'tidb' },
|
||||
{ pattern: /vitess/i, icon: 'vitess' },
|
||||
{ pattern: /duckdb/i, icon: 'duckdb' },
|
||||
{ pattern: /scylla/i, icon: 'scylladb' },
|
||||
{ pattern: /singlestore/i, icon: 'singlestore' },
|
||||
{ pattern: /trino/i, icon: 'trino' },
|
||||
{ pattern: /presto/i, icon: 'presto' },
|
||||
{ pattern: /influx/i, icon: 'influxdb' },
|
||||
{ pattern: /timescale/i, icon: 'timescaledb' },
|
||||
{ pattern: /snowflake/i, icon: 'snowflake' },
|
||||
{ pattern: /databricks/i, icon: 'databricks' },
|
||||
// Messaging / streaming
|
||||
{ pattern: /rabbitmq/i, icon: 'rabbitmq' },
|
||||
{ pattern: /kafka/i, icon: 'kafka' },
|
||||
{ pattern: /pulsar/i, icon: 'pulsar' },
|
||||
{ pattern: /rocketmq/i, icon: 'rocketmq' },
|
||||
{ pattern: /nats/i, icon: 'nats' },
|
||||
{ pattern: /emqx/i, icon: 'emqx' },
|
||||
{ pattern: /hivemq/i, icon: 'hivemq' },
|
||||
{ pattern: /flink/i, icon: 'flink' },
|
||||
{ pattern: /nifi/i, icon: 'nifi' },
|
||||
{ pattern: /temporal/i, icon: 'temporal' },
|
||||
// Search / analytics
|
||||
{ pattern: /opensearch/i, icon: 'opensearch' },
|
||||
{ pattern: /elasticsearch/i, icon: 'elasticsearch' },
|
||||
{ pattern: /meilisearch/i, icon: 'meilisearch' },
|
||||
{ pattern: /kibana/i, icon: 'kibana' },
|
||||
{ pattern: /logstash/i, icon: 'logstash' },
|
||||
{ pattern: /solr/i, icon: 'solr' },
|
||||
{ pattern: /druid/i, icon: 'druid' },
|
||||
{ pattern: /hadoop/i, icon: 'hadoop' },
|
||||
{ pattern: /hbase/i, icon: 'hbase' },
|
||||
{ pattern: /hive(?!mq)/i, icon: 'hive' },
|
||||
// Observability
|
||||
{ pattern: /grafana/i, icon: 'grafana' },
|
||||
{ pattern: /prometheus/i, icon: 'prometheus' },
|
||||
{ pattern: /victoria(?:metrics)?|vmagent|vmalert/i, icon: 'victoriametrics' },
|
||||
{ pattern: /thanos/i, icon: 'thanos' },
|
||||
{ pattern: /jaeger/i, icon: 'jaeger' },
|
||||
{ pattern: /opentelemetry|otel/i, icon: 'opentelemetry' },
|
||||
{ pattern: /fluent-?bit/i, icon: 'fluentbit' },
|
||||
{ pattern: /fluentd/i, icon: 'fluentd' },
|
||||
{ pattern: /sentry/i, icon: 'sentry' },
|
||||
{ pattern: /datadog/i, icon: 'datadog' },
|
||||
{ pattern: /netdata/i, icon: 'netdata' },
|
||||
{ pattern: /icinga/i, icon: 'icinga' },
|
||||
{ pattern: /sonarqube/i, icon: 'sonarqube' },
|
||||
// AI / ML
|
||||
{ pattern: /ollama/i, icon: 'ollama' },
|
||||
{ pattern: /milvus/i, icon: 'milvus' },
|
||||
{ pattern: /qdrant/i, icon: 'qdrant' },
|
||||
{ pattern: /pytorch/i, icon: 'pytorch' },
|
||||
{ pattern: /tensorflow/i, icon: 'tensorflow' },
|
||||
// Infra / secrets
|
||||
{ pattern: /vaultwarden/i, icon: 'vaultwarden' },
|
||||
{ pattern: /vault/i, icon: 'vault' },
|
||||
{ pattern: /consul/i, icon: 'consul' },
|
||||
{ pattern: /etcd/i, icon: 'etcd' },
|
||||
{ pattern: /zookeeper|zoo-?keeper/i, icon: 'zookeeper' },
|
||||
{ pattern: /portainer/i, icon: 'portainer' },
|
||||
{ pattern: /harbor/i, icon: 'harbor' },
|
||||
{ pattern: /jfrog|artifactory|nexus/i, icon: 'jfrog' },
|
||||
{ pattern: /keycloak/i, icon: 'keycloak' },
|
||||
{ pattern: /authelia/i, icon: 'authelia' },
|
||||
{ pattern: /authentik/i, icon: 'authentik' },
|
||||
{ pattern: /tailscale/i, icon: 'tailscale' },
|
||||
{ pattern: /wireguard/i, icon: 'wireguard' },
|
||||
{ pattern: /cloudflare/i, icon: 'cloudflare' },
|
||||
// CI/CD & dev tools
|
||||
{ pattern: /jenkins/i, icon: 'jenkins' },
|
||||
{ pattern: /gitlab/i, icon: 'gitlab' },
|
||||
{ pattern: /gitea/i, icon: 'gitea' },
|
||||
{ pattern: /forgejo/i, icon: 'forgejo' },
|
||||
{ pattern: /github/i, icon: 'github' },
|
||||
{ pattern: /drone/i, icon: 'drone' },
|
||||
{ pattern: /circleci/i, icon: 'circleci' },
|
||||
{ pattern: /buildkite/i, icon: 'buildkite' },
|
||||
{ pattern: /airflow/i, icon: 'airflow' },
|
||||
{ pattern: /prefect/i, icon: 'prefect' },
|
||||
{ pattern: /spark/i, icon: 'spark' },
|
||||
// Apps / CMS / collaboration
|
||||
{ pattern: /supabase/i, icon: 'supabase' },
|
||||
{ pattern: /strapi/i, icon: 'strapi' },
|
||||
{ pattern: /directus/i, icon: 'directus' },
|
||||
{ pattern: /hasura/i, icon: 'hasura' },
|
||||
{ pattern: /pocketbase/i, icon: 'pocketbase' },
|
||||
{ pattern: /appwrite/i, icon: 'appwrite' },
|
||||
{ pattern: /mattermost/i, icon: 'mattermost' },
|
||||
{ pattern: /jitsi/i, icon: 'jitsi' },
|
||||
{ pattern: /matrix|synapse|element/i, icon: 'matrix' },
|
||||
{ pattern: /metabase/i, icon: 'metabase' },
|
||||
{ pattern: /superset/i, icon: 'superset' },
|
||||
{ pattern: /airbyte/i, icon: 'airbyte' },
|
||||
{ pattern: /bookstack/i, icon: 'bookstack' },
|
||||
{ pattern: /jira/i, icon: 'jira' },
|
||||
{ pattern: /confluence/i, icon: 'confluence' },
|
||||
{ pattern: /bitwarden/i, icon: 'bitwarden' },
|
||||
{ pattern: /listmonk/i, icon: 'listmonk' },
|
||||
{ pattern: /mautic/i, icon: 'mautic' },
|
||||
{ pattern: /adminer/i, icon: 'adminer' },
|
||||
{ pattern: /phpmyadmin/i, icon: 'phpmyadmin' },
|
||||
// Storage / media / smart home
|
||||
{ pattern: /minio/i, icon: 'minio' },
|
||||
{ pattern: /nextcloud/i, icon: 'nextcloud' },
|
||||
{ pattern: /homeassistant|home-assistant/i, icon: 'homeassistant' },
|
||||
{ pattern: /homebridge/i, icon: 'homebridge' },
|
||||
{ pattern: /nodered|node-red/i, icon: 'nodered' },
|
||||
{ pattern: /esphome/i, icon: 'esphome' },
|
||||
{ pattern: /tasmota/i, icon: 'tasmota' },
|
||||
{ pattern: /immich/i, icon: 'immich' },
|
||||
{ pattern: /jellyfin/i, icon: 'jellyfin' },
|
||||
{ pattern: /plex/i, icon: 'plex' },
|
||||
{ pattern: /emby/i, icon: 'emby' },
|
||||
{ pattern: /sonarr/i, icon: 'sonarr' },
|
||||
{ pattern: /radarr/i, icon: 'radarr' },
|
||||
// Docker engine (before generic base images)
|
||||
{ pattern: /^docker$|docker\/|dind/i, icon: 'docker' },
|
||||
// Base images (broad — match last)
|
||||
{ pattern: /alpine/i, icon: 'alpine' },
|
||||
{ pattern: /ubuntu/i, icon: 'ubuntu' },
|
||||
{ pattern: /debian/i, icon: 'debian' },
|
||||
{ pattern: /centos/i, icon: 'centos' },
|
||||
{ pattern: /fedora/i, icon: 'fedora' },
|
||||
{ pattern: /rocky/i, icon: 'rockylinux' },
|
||||
{ pattern: /alma/i, icon: 'almalinux' },
|
||||
{ pattern: /redhat|red-hat|rhel/i, icon: 'redhat' },
|
||||
{ pattern: /archlinux|arch-linux/i, icon: 'archlinux' },
|
||||
{ pattern: /opensuse|suse/i, icon: 'opensuse' },
|
||||
{ pattern: /gentoo/i, icon: 'gentoo' },
|
||||
];
|
||||
|
||||
/** Local asset base — files live in public/docker-icons/ (see scripts/sync-docker-icons.mjs) */
|
||||
const DOCKER_ICON_BASE = '/docker-icons';
|
||||
|
||||
/** Internal icon id → Simple Icons slug (https://simpleicons.org/) */
|
||||
/** Only official Simple Icons slugs — no cross-brand substitutes. */
|
||||
/** Parsed at build/sync time by scripts/sync-docker-icons.mjs (not referenced at runtime). */
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars -- consumed by scripts/sync-docker-icons.mjs
|
||||
const SIMPLE_ICONS_SLUG: Record<string, string> = {
|
||||
container: 'docker',
|
||||
mosquitto: 'eclipsemosquitto',
|
||||
kong: 'kong',
|
||||
istio: 'istio',
|
||||
linkerd: 'linkerd',
|
||||
cilium: 'cilium',
|
||||
envoy: 'envoyproxy',
|
||||
kubernetes: 'kubernetes',
|
||||
k3s: 'k3s',
|
||||
helm: 'helm',
|
||||
rancher: 'rancher',
|
||||
argo: 'argo',
|
||||
nomad: 'nomad',
|
||||
terraform: 'terraform',
|
||||
ansible: 'ansible',
|
||||
pulumi: 'pulumi',
|
||||
packer: 'packer',
|
||||
containerd: 'containerd',
|
||||
podman: 'podman',
|
||||
nginx: 'nginx',
|
||||
traefik: 'traefikproxy',
|
||||
caddy: 'caddy',
|
||||
apache: 'apache',
|
||||
tomcat: 'apachetomcat',
|
||||
wordpress: 'wordpress',
|
||||
ghost: 'ghost',
|
||||
drupal: 'drupal',
|
||||
golang: 'go',
|
||||
nodejs: 'nodedotjs',
|
||||
python: 'python',
|
||||
openjdk: 'openjdk',
|
||||
springboot: 'springboot',
|
||||
rust: 'rust',
|
||||
deno: 'deno',
|
||||
php: 'php',
|
||||
ruby: 'ruby',
|
||||
postgresql: 'postgresql',
|
||||
mariadb: 'mariadb',
|
||||
mysql: 'mysql',
|
||||
mongodb: 'mongodb',
|
||||
redis: 'redis',
|
||||
clickhouse: 'clickhouse',
|
||||
cockroachdb: 'cockroachlabs',
|
||||
neo4j: 'neo4j',
|
||||
cassandra: 'apachecassandra',
|
||||
couchdb: 'apachecouchdb',
|
||||
couchbase: 'couchbase',
|
||||
arangodb: 'arangodb',
|
||||
tidb: 'tidb',
|
||||
vitess: 'vitess',
|
||||
duckdb: 'duckdb',
|
||||
scylladb: 'scylladb',
|
||||
singlestore: 'singlestore',
|
||||
trino: 'trino',
|
||||
presto: 'presto',
|
||||
influxdb: 'influxdb',
|
||||
timescaledb: 'timescale',
|
||||
snowflake: 'snowflake',
|
||||
databricks: 'databricks',
|
||||
rabbitmq: 'rabbitmq',
|
||||
kafka: 'apachekafka',
|
||||
pulsar: 'apachepulsar',
|
||||
rocketmq: 'apacherocketmq',
|
||||
nats: 'natsdotio',
|
||||
hivemq: 'hivemq',
|
||||
flink: 'apacheflink',
|
||||
nifi: 'apachenifi',
|
||||
temporal: 'temporal',
|
||||
opensearch: 'opensearch',
|
||||
elasticsearch: 'elasticsearch',
|
||||
meilisearch: 'meilisearch',
|
||||
kibana: 'kibana',
|
||||
logstash: 'logstash',
|
||||
solr: 'apachesolr',
|
||||
druid: 'apachedruid',
|
||||
hadoop: 'apachehadoop',
|
||||
hbase: 'apachehbase',
|
||||
hive: 'apachehive',
|
||||
grafana: 'grafana',
|
||||
prometheus: 'prometheus',
|
||||
victoriametrics: 'victoriametrics',
|
||||
thanos: 'thanos',
|
||||
jaeger: 'jaeger',
|
||||
opentelemetry: 'opentelemetry',
|
||||
fluentbit: 'fluentbit',
|
||||
fluentd: 'fluentd',
|
||||
sentry: 'sentry',
|
||||
datadog: 'datadog',
|
||||
netdata: 'netdata',
|
||||
icinga: 'icinga',
|
||||
sonarqube: 'sonarqubeserver',
|
||||
ollama: 'ollama',
|
||||
milvus: 'milvus',
|
||||
qdrant: 'qdrant',
|
||||
pytorch: 'pytorch',
|
||||
tensorflow: 'tensorflow',
|
||||
vaultwarden: 'vaultwarden',
|
||||
vault: 'vault',
|
||||
consul: 'consul',
|
||||
etcd: 'etcd',
|
||||
portainer: 'portainer',
|
||||
harbor: 'harbor',
|
||||
jfrog: 'jfrog',
|
||||
keycloak: 'keycloak',
|
||||
authelia: 'authelia',
|
||||
authentik: 'authentik',
|
||||
tailscale: 'tailscale',
|
||||
wireguard: 'wireguard',
|
||||
cloudflare: 'cloudflare',
|
||||
jenkins: 'jenkins',
|
||||
gitlab: 'gitlab',
|
||||
gitea: 'gitea',
|
||||
forgejo: 'forgejo',
|
||||
github: 'github',
|
||||
drone: 'drone',
|
||||
circleci: 'circleci',
|
||||
buildkite: 'buildkite',
|
||||
airflow: 'apacheairflow',
|
||||
prefect: 'prefect',
|
||||
spark: 'apachespark',
|
||||
supabase: 'supabase',
|
||||
strapi: 'strapi',
|
||||
directus: 'directus',
|
||||
hasura: 'hasura',
|
||||
pocketbase: 'pocketbase',
|
||||
appwrite: 'appwrite',
|
||||
mattermost: 'mattermost',
|
||||
jitsi: 'jitsi',
|
||||
matrix: 'matrix',
|
||||
metabase: 'metabase',
|
||||
superset: 'apachesuperset',
|
||||
airbyte: 'airbyte',
|
||||
bookstack: 'bookstack',
|
||||
jira: 'jira',
|
||||
confluence: 'confluence',
|
||||
bitwarden: 'bitwarden',
|
||||
listmonk: 'listmonk',
|
||||
mautic: 'mautic',
|
||||
adminer: 'adminer',
|
||||
phpmyadmin: 'phpmyadmin',
|
||||
minio: 'minio',
|
||||
nextcloud: 'nextcloud',
|
||||
homeassistant: 'homeassistant',
|
||||
homebridge: 'homebridge',
|
||||
nodered: 'nodered',
|
||||
esphome: 'esphome',
|
||||
tasmota: 'tasmota',
|
||||
immich: 'immich',
|
||||
jellyfin: 'jellyfin',
|
||||
plex: 'plex',
|
||||
emby: 'emby',
|
||||
sonarr: 'sonarr',
|
||||
radarr: 'radarr',
|
||||
docker: 'docker',
|
||||
alpine: 'alpinelinux',
|
||||
ubuntu: 'ubuntu',
|
||||
debian: 'debian',
|
||||
centos: 'centos',
|
||||
fedora: 'fedora',
|
||||
rockylinux: 'rockylinux',
|
||||
almalinux: 'almalinux',
|
||||
redhat: 'redhat',
|
||||
archlinux: 'archlinux',
|
||||
opensuse: 'opensuse',
|
||||
gentoo: 'gentoo',
|
||||
};
|
||||
|
||||
export interface DockerIconTileStyle {
|
||||
/** Tile background (brand primary) */
|
||||
background: string;
|
||||
/** Logo fill baked into the local SVG — chosen for contrast on `background` */
|
||||
iconColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-brand tile: brand-colored background + logo color that stays readable.
|
||||
* Dark tiles → white/light logos; light tiles → dark logos.
|
||||
*/
|
||||
const ICON_TILE_STYLE: Record<string, DockerIconTileStyle> = {
|
||||
container: { background: '#2496ED', iconColor: 'ffffff' },
|
||||
nacos: { background: '#FF6A00', iconColor: 'ffffff' },
|
||||
polaris: { background: '#006EFF', iconColor: 'ffffff' },
|
||||
mosquitto: { background: '#3C5280', iconColor: 'ffffff' },
|
||||
kong: { background: '#003459', iconColor: 'ffffff' },
|
||||
istio: { background: '#466BB0', iconColor: 'ffffff' },
|
||||
linkerd: { background: '#2BEDA7', iconColor: '000000' },
|
||||
cilium: { background: '#F8C517', iconColor: '000000' },
|
||||
envoy: { background: '#AC6199', iconColor: 'ffffff' },
|
||||
kubernetes: { background: '#326CE5', iconColor: 'ffffff' },
|
||||
k3s: { background: '#FFC61C', iconColor: '000000' },
|
||||
helm: { background: '#0F1689', iconColor: 'ffffff' },
|
||||
rancher: { background: '#0075A8', iconColor: 'ffffff' },
|
||||
argo: { background: '#EF7B4D', iconColor: 'ffffff' },
|
||||
nomad: { background: '#00CA8E', iconColor: '000000' },
|
||||
terraform: { background: '#844FBA', iconColor: 'ffffff' },
|
||||
ansible: { background: '#EE0000', iconColor: 'ffffff' },
|
||||
pulumi: { background: '#8A3391', iconColor: 'ffffff' },
|
||||
packer: { background: '#02A8EF', iconColor: 'ffffff' },
|
||||
containerd: { background: '#575757', iconColor: 'ffffff' },
|
||||
podman: { background: '#892CA0', iconColor: 'ffffff' },
|
||||
nginx: { background: '#009639', iconColor: 'ffffff' },
|
||||
traefik: { background: '#24A1C1', iconColor: 'ffffff' },
|
||||
caddy: { background: '#1F88C0', iconColor: 'ffffff' },
|
||||
apache: { background: '#D22128', iconColor: 'ffffff' },
|
||||
tomcat: { background: '#30261C', iconColor: 'F8DC75' },
|
||||
wordpress: { background: '#21759B', iconColor: 'ffffff' },
|
||||
ghost: { background: '#15171A', iconColor: 'ffffff' },
|
||||
drupal: { background: '#0678BE', iconColor: 'ffffff' },
|
||||
golang: { background: '#00ADD8', iconColor: 'ffffff' },
|
||||
nodejs: { background: '#339933', iconColor: 'ffffff' },
|
||||
python: { background: '#3776AB', iconColor: 'ffffff' },
|
||||
openjdk: { background: '#437291', iconColor: 'ffffff' },
|
||||
springboot: { background: '#6DB33F', iconColor: 'ffffff' },
|
||||
rust: { background: '#000000', iconColor: 'ffffff' },
|
||||
deno: { background: '#000000', iconColor: 'ffffff' },
|
||||
php: { background: '#777BB4', iconColor: 'ffffff' },
|
||||
ruby: { background: '#CC342D', iconColor: 'ffffff' },
|
||||
postgresql: { background: '#4169E1', iconColor: 'ffffff' },
|
||||
mariadb: { background: '#003545', iconColor: 'ffffff' },
|
||||
mysql: { background: '#4479A1', iconColor: 'ffffff' },
|
||||
mongodb: { background: '#47A248', iconColor: 'ffffff' },
|
||||
valkey: { background: '#A41E34', iconColor: 'ffffff' },
|
||||
dragonfly: { background: '#5B4B8A', iconColor: 'ffffff' },
|
||||
redis: { background: '#DC382D', iconColor: 'ffffff' },
|
||||
memcached: { background: '#00AA00', iconColor: 'ffffff' },
|
||||
emqx: { background: '#5C4B9E', iconColor: 'ffffff' },
|
||||
clickhouse: { background: '#FFCC01', iconColor: '000000' },
|
||||
cockroachdb: { background: '#6933FF', iconColor: 'ffffff' },
|
||||
neo4j: { background: '#4581C3', iconColor: 'ffffff' },
|
||||
cassandra: { background: '#1287B1', iconColor: 'ffffff' },
|
||||
couchdb: { background: '#E42528', iconColor: 'ffffff' },
|
||||
couchbase: { background: '#EA2328', iconColor: 'ffffff' },
|
||||
arangodb: { background: '#D12C2F', iconColor: 'ffffff' },
|
||||
tidb: { background: '#E7008A', iconColor: 'ffffff' },
|
||||
vitess: { background: '#F16728', iconColor: 'ffffff' },
|
||||
duckdb: { background: '#FFF000', iconColor: '000000' },
|
||||
scylladb: { background: '#6C2BD9', iconColor: 'ffffff' },
|
||||
singlestore: { background: '#AA00FF', iconColor: 'ffffff' },
|
||||
trino: { background: '#DD00A1', iconColor: 'ffffff' },
|
||||
presto: { background: '#5890FF', iconColor: 'ffffff' },
|
||||
influxdb: { background: '#22ADF6', iconColor: 'ffffff' },
|
||||
timescaledb: { background: '#FDB515', iconColor: '000000' },
|
||||
snowflake: { background: '#29B5E8', iconColor: 'ffffff' },
|
||||
databricks: { background: '#FF3621', iconColor: 'ffffff' },
|
||||
rabbitmq: { background: '#FF6600', iconColor: 'ffffff' },
|
||||
kafka: { background: '#231F20', iconColor: 'ffffff' },
|
||||
pulsar: { background: '#188FFF', iconColor: 'ffffff' },
|
||||
rocketmq: { background: '#D77310', iconColor: 'ffffff' },
|
||||
nats: { background: '#27AAE1', iconColor: 'ffffff' },
|
||||
hivemq: { background: '#FFCD00', iconColor: '000000' },
|
||||
flink: { background: '#E6526F', iconColor: 'ffffff' },
|
||||
nifi: { background: '#728E9B', iconColor: 'ffffff' },
|
||||
temporal: { background: '#000000', iconColor: 'ffffff' },
|
||||
opensearch: { background: '#005EB8', iconColor: 'ffffff' },
|
||||
elasticsearch: { background: '#005571', iconColor: 'ffffff' },
|
||||
meilisearch: { background: '#FF5CAA', iconColor: 'ffffff' },
|
||||
kibana: { background: '#005571', iconColor: 'ffffff' },
|
||||
logstash: { background: '#005571', iconColor: 'ffffff' },
|
||||
solr: { background: '#D9411E', iconColor: 'ffffff' },
|
||||
druid: { background: '#29F1FB', iconColor: '000000' },
|
||||
hadoop: { background: '#66CCFF', iconColor: '000000' },
|
||||
hbase: { background: '#BE160F', iconColor: 'ffffff' },
|
||||
hive: { background: '#FDEE21', iconColor: '000000' },
|
||||
grafana: { background: '#F46800', iconColor: 'ffffff' },
|
||||
prometheus: { background: '#E6522C', iconColor: 'ffffff' },
|
||||
victoriametrics: { background: '#621773', iconColor: 'ffffff' },
|
||||
thanos: { background: '#6D41FF', iconColor: 'ffffff' },
|
||||
jaeger: { background: '#2D2D2D', iconColor: '60D0E4' },
|
||||
opentelemetry: { background: '#000000', iconColor: 'ffffff' },
|
||||
fluentbit: { background: '#49BDA5', iconColor: 'ffffff' },
|
||||
fluentd: { background: '#0E83C8', iconColor: 'ffffff' },
|
||||
sentry: { background: '#362D59', iconColor: 'ffffff' },
|
||||
datadog: { background: '#632CA6', iconColor: 'ffffff' },
|
||||
netdata: { background: '#00AB44', iconColor: 'ffffff' },
|
||||
icinga: { background: '#060606', iconColor: 'ffffff' },
|
||||
sonarqube: { background: '#4E9BCD', iconColor: 'ffffff' },
|
||||
ollama: { background: '#000000', iconColor: 'ffffff' },
|
||||
milvus: { background: '#00A1EA', iconColor: 'ffffff' },
|
||||
qdrant: { background: '#DC244C', iconColor: 'ffffff' },
|
||||
pytorch: { background: '#EE4C2C', iconColor: 'ffffff' },
|
||||
tensorflow: { background: '#FF6F00', iconColor: 'ffffff' },
|
||||
vaultwarden: { background: '#175DDC', iconColor: 'ffffff' },
|
||||
vault: { background: '#1B1F23', iconColor: 'FFEC6E' },
|
||||
consul: { background: '#F24C53', iconColor: 'ffffff' },
|
||||
etcd: { background: '#419EDA', iconColor: 'ffffff' },
|
||||
zookeeper: { background: '#56290C', iconColor: 'ffffff' },
|
||||
portainer: { background: '#13BEF9', iconColor: 'ffffff' },
|
||||
harbor: { background: '#60B932', iconColor: 'ffffff' },
|
||||
jfrog: { background: '#40BE46', iconColor: 'ffffff' },
|
||||
keycloak: { background: '#4D4D4D', iconColor: 'ffffff' },
|
||||
authelia: { background: '#113155', iconColor: 'ffffff' },
|
||||
authentik: { background: '#FD4B2D', iconColor: 'ffffff' },
|
||||
tailscale: { background: '#242424', iconColor: 'ffffff' },
|
||||
wireguard: { background: '#88171A', iconColor: 'ffffff' },
|
||||
cloudflare: { background: '#F38020', iconColor: 'ffffff' },
|
||||
jenkins: { background: '#D24939', iconColor: 'ffffff' },
|
||||
gitlab: { background: '#FC6D26', iconColor: 'ffffff' },
|
||||
gitea: { background: '#609926', iconColor: 'ffffff' },
|
||||
forgejo: { background: '#FB923C', iconColor: '000000' },
|
||||
github: { background: '#181717', iconColor: 'ffffff' },
|
||||
drone: { background: '#212121', iconColor: 'ffffff' },
|
||||
circleci: { background: '#343434', iconColor: 'ffffff' },
|
||||
buildkite: { background: '#14B8A6', iconColor: 'ffffff' },
|
||||
airflow: { background: '#017CEE', iconColor: 'ffffff' },
|
||||
prefect: { background: '#070E10', iconColor: 'ffffff' },
|
||||
spark: { background: '#E25A1C', iconColor: 'ffffff' },
|
||||
supabase: { background: '#3FCF8E', iconColor: '000000' },
|
||||
strapi: { background: '#4945FF', iconColor: 'ffffff' },
|
||||
directus: { background: '#6644FF', iconColor: 'ffffff' },
|
||||
hasura: { background: '#1EB4D4', iconColor: 'ffffff' },
|
||||
pocketbase: { background: '#B8DBE4', iconColor: '000000' },
|
||||
appwrite: { background: '#FD366E', iconColor: 'ffffff' },
|
||||
mattermost: { background: '#0058CC', iconColor: 'ffffff' },
|
||||
jitsi: { background: '#97979A', iconColor: 'ffffff' },
|
||||
matrix: { background: '#000000', iconColor: 'ffffff' },
|
||||
metabase: { background: '#509EE3', iconColor: 'ffffff' },
|
||||
superset: { background: '#20A6C9', iconColor: 'ffffff' },
|
||||
airbyte: { background: '#615EFF', iconColor: 'ffffff' },
|
||||
bookstack: { background: '#0288D1', iconColor: 'ffffff' },
|
||||
jira: { background: '#0052CC', iconColor: 'ffffff' },
|
||||
confluence: { background: '#172B4D', iconColor: 'ffffff' },
|
||||
bitwarden: { background: '#175DDC', iconColor: 'ffffff' },
|
||||
listmonk: { background: '#0052CC', iconColor: 'ffffff' },
|
||||
mautic: { background: '#4E5E9E', iconColor: 'ffffff' },
|
||||
adminer: { background: '#34567C', iconColor: 'ffffff' },
|
||||
phpmyadmin: { background: '#F29111', iconColor: 'ffffff' },
|
||||
minio: { background: '#C72E49', iconColor: 'ffffff' },
|
||||
nextcloud: { background: '#0082C9', iconColor: 'ffffff' },
|
||||
homeassistant: { background: '#18BCF2', iconColor: 'ffffff' },
|
||||
homebridge: { background: '#491F59', iconColor: 'ffffff' },
|
||||
nodered: { background: '#8F0000', iconColor: 'ffffff' },
|
||||
esphome: { background: '#000000', iconColor: 'ffffff' },
|
||||
tasmota: { background: '#1A1A1A', iconColor: 'ffffff' },
|
||||
immich: { background: '#4250AF', iconColor: 'ffffff' },
|
||||
jellyfin: { background: '#00A4DC', iconColor: 'ffffff' },
|
||||
plex: { background: '#EBAF00', iconColor: '000000' },
|
||||
emby: { background: '#52B54B', iconColor: 'ffffff' },
|
||||
sonarr: { background: '#35C5F4', iconColor: '000000' },
|
||||
radarr: { background: '#FFCC00', iconColor: '000000' },
|
||||
docker: { background: '#2496ED', iconColor: 'ffffff' },
|
||||
alpine: { background: '#0D597F', iconColor: 'ffffff' },
|
||||
ubuntu: { background: '#E95420', iconColor: 'ffffff' },
|
||||
debian: { background: '#A81D33', iconColor: 'ffffff' },
|
||||
centos: { background: '#262577', iconColor: 'ffffff' },
|
||||
fedora: { background: '#294172', iconColor: 'ffffff' },
|
||||
rockylinux: { background: '#10B981', iconColor: 'ffffff' },
|
||||
almalinux: { background: '#0F4266', iconColor: 'ffffff' },
|
||||
redhat: { background: '#EE0000', iconColor: 'ffffff' },
|
||||
archlinux: { background: '#1793D1', iconColor: 'ffffff' },
|
||||
opensuse: { background: '#73BA25', iconColor: 'ffffff' },
|
||||
gentoo: { background: '#54487A', iconColor: 'ffffff' },
|
||||
};
|
||||
|
||||
const DEFAULT_TILE_STYLE: DockerIconTileStyle = {
|
||||
background: '#52525b',
|
||||
iconColor: 'ffffff',
|
||||
};
|
||||
|
||||
export function resolveDockerImageIcon(image: string): string {
|
||||
const repo = (image || '').split(':')[0].split('/').pop() || '';
|
||||
for (const rule of IMAGE_ICON_RULES) {
|
||||
if (rule.pattern.test(repo) || rule.pattern.test(image)) {
|
||||
return rule.icon;
|
||||
}
|
||||
}
|
||||
return 'container';
|
||||
}
|
||||
|
||||
export function dockerIconTileStyle(iconId: string): DockerIconTileStyle {
|
||||
return ICON_TILE_STYLE[iconId] ?? DEFAULT_TILE_STYLE;
|
||||
}
|
||||
|
||||
const CONTAINER_ICON_ID = 'container';
|
||||
|
||||
function dockerIconFileName(iconId: string): string {
|
||||
return DOCKER_ICON_FILES[iconId] ?? `${iconId}.svg`;
|
||||
}
|
||||
|
||||
/** Local bundled asset, or null when no official icon is available. */
|
||||
export function dockerIconUrl(iconId: string): string | null {
|
||||
if (!BUNDLED_DOCKER_ICON_IDS.has(iconId)) return null;
|
||||
return `${DOCKER_ICON_BASE}/${encodeURIComponent(dockerIconFileName(iconId))}`;
|
||||
}
|
||||
|
||||
export interface DockerIconPresentation {
|
||||
/** Icon id used for tile color and logo */
|
||||
displayIconId: string;
|
||||
iconUrl: string;
|
||||
/** True when showing the matched brand icon (not Docker fallback) */
|
||||
isBrandIcon: boolean;
|
||||
}
|
||||
|
||||
/** Official brand icon when bundled; otherwise Docker logo + Docker tile. */
|
||||
export function resolveDockerIconPresentation(
|
||||
iconId: string,
|
||||
options?: { imageFailed?: boolean },
|
||||
): DockerIconPresentation {
|
||||
const useBrand =
|
||||
!options?.imageFailed &&
|
||||
iconId !== CONTAINER_ICON_ID &&
|
||||
BUNDLED_DOCKER_ICON_IDS.has(iconId);
|
||||
const displayIconId = useBrand ? iconId : CONTAINER_ICON_ID;
|
||||
return {
|
||||
displayIconId,
|
||||
iconUrl: `${DOCKER_ICON_BASE}/${encodeURIComponent(dockerIconFileName(displayIconId))}`,
|
||||
isBrandIcon: useBrand,
|
||||
};
|
||||
}
|
||||
30
domain/systemManager/dockerShell.test.ts
Normal file
30
domain/systemManager/dockerShell.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from './dockerShell.ts';
|
||||
|
||||
test('buildDockerExecShellCommand probes plain Docker before sudo fallback', () => {
|
||||
const command = buildDockerExecShellCommand('587abcdef123');
|
||||
|
||||
assert.match(command, /^sh -c /);
|
||||
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
|
||||
assert.match(command, /docker inspect 587abcdef123/);
|
||||
assert.match(command, /exec docker exec -it 587abcdef123/);
|
||||
assert.match(command, /exec sudo docker exec -it 587abcdef123/);
|
||||
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
|
||||
assert.doesNotMatch(command, /sudo -S/);
|
||||
assert.equal(command.includes('\n'), false);
|
||||
});
|
||||
|
||||
test('buildDockerLogsCommand probes plain Docker before sudo fallback', () => {
|
||||
const command = buildDockerLogsCommand('587abcdef123');
|
||||
|
||||
assert.match(command, /^sh -c /);
|
||||
assert.match(command, /printf .*\\033\[H\\033\[2J\\033\[3J/);
|
||||
assert.match(command, /docker inspect 587abcdef123/);
|
||||
assert.match(command, /exec docker logs -f --tail 200 587abcdef123/);
|
||||
assert.match(command, /exec sudo docker logs -f --tail 200 587abcdef123/);
|
||||
assert.match(command, /permission\\ denied.*docker.sock.*docker.sock.*permission\\ denied/);
|
||||
assert.doesNotMatch(command, /sudo -S/);
|
||||
assert.equal(command.includes('\n'), false);
|
||||
});
|
||||
83
domain/systemManager/dockerShell.ts
Normal file
83
domain/systemManager/dockerShell.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/** Sanitize Docker container/image IDs — must match electron/bridges/systemManager/dockerOps.cjs */
|
||||
export function sanitizeDockerContainerId(id: string): string {
|
||||
return String(id || '').replace(/[^a-zA-Z0-9]/g, '').slice(0, 64);
|
||||
}
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT_POSIX = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
const CLEAR_STARTUP_OUTPUT_WINDOWS = ""; // Windows console doesn't support ANSI clear via printf; let the terminal handle it
|
||||
|
||||
function shQuote(value: string): string {
|
||||
return `'${String(value).replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function buildDockerCommandWithSudoFallback(containerId: string, dockerArgs: string): string {
|
||||
const plainCommand = `docker ${dockerArgs}`;
|
||||
const sudoCommand = `sudo ${plainCommand}`;
|
||||
const script = [
|
||||
CLEAR_STARTUP_OUTPUT_POSIX,
|
||||
`_nc_docker_err=$(docker inspect ${containerId} 2>&1 >/dev/null);`,
|
||||
'_nc_docker_status=$?;',
|
||||
`if [ "$_nc_docker_status" -eq 0 ]; then exec ${plainCommand}; fi;`,
|
||||
'_nc_docker_lc=$(printf \'%s\' "$_nc_docker_err" | tr \'[:upper:]\' \'[:lower:]\');',
|
||||
'case "$_nc_docker_lc" in',
|
||||
[
|
||||
'*permission\\ denied*docker\\ daemon*',
|
||||
'*docker\\ daemon*permission\\ denied*',
|
||||
'*permission\\ denied*docker.sock*',
|
||||
'*docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*/var/run/docker.sock*',
|
||||
'*/var/run/docker.sock*permission\\ denied*',
|
||||
'*permission\\ denied*connect\\ to\\ the\\ docker\\ daemon*',
|
||||
'*connect\\ to\\ the\\ docker\\ daemon*permission\\ denied*',
|
||||
].join('|') + `) exec ${sudoCommand} ;;`,
|
||||
'*) printf \'%s\\n\' "$_nc_docker_err" >&2; exit "$_nc_docker_status" ;;',
|
||||
'esac',
|
||||
].join(' ');
|
||||
return `sh -c ${shQuote(script)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows fallback — no sudo (Docker Desktop on Windows runs as current user),
|
||||
* no sh wrapper, just direct docker command.
|
||||
*/
|
||||
function buildDockerCommandWindows(containerId: string, dockerArgs: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo Invalid container id';
|
||||
return `docker ${dockerArgs}`;
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — prefer bash, fall back to sh. */
|
||||
export function buildDockerExecShellCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(
|
||||
safeId,
|
||||
`exec -it ${safeId} sh -c 'command -v bash >/dev/null 2>&1 && exec bash || exec sh'`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Interactive shell into a container — Windows host variant.
|
||||
* Docker Desktop on Windows runs Linux containers by default, so the
|
||||
* container shell is still bash/sh (not powershell.exe).
|
||||
* The Windows variant avoids wrapping the whole thing in a host `sh -c`
|
||||
* script (sh.exe is not available on Windows). Instead we exec bash
|
||||
* directly and fall back to sh inside the container.
|
||||
*/
|
||||
export function buildDockerExecShellCommandWindows(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo Invalid container id';
|
||||
// Use cmd /c to chain two docker exec attempts — first bash, then sh.
|
||||
// cmd /c is available on all Windows hosts.
|
||||
return `cmd /c "docker exec -it ${safeId} bash || docker exec -it ${safeId} sh"`;
|
||||
}
|
||||
|
||||
export function buildDockerLogsCommand(containerId: string): string {
|
||||
const safeId = sanitizeDockerContainerId(containerId);
|
||||
if (!safeId) return 'echo "Invalid container id"';
|
||||
return buildDockerCommandWithSudoFallback(safeId, `logs -f --tail 200 ${safeId}`);
|
||||
}
|
||||
|
||||
/** Windows variant — no sh wrapper, no sudo. */
|
||||
export function buildDockerLogsCommandWindows(containerId: string): string {
|
||||
return buildDockerCommandWindows(containerId, `logs -f --tail 200 ${sanitizeDockerContainerId(containerId)}`);
|
||||
}
|
||||
187
domain/systemManager/inspectView.ts
Normal file
187
domain/systemManager/inspectView.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* View models for `docker inspect` payloads (the summarized objects produced
|
||||
* by electron/bridges/systemManager/dockerOps.cjs). All fields are optional —
|
||||
* the raw payload shape varies across docker versions, so every accessor is
|
||||
* defensive.
|
||||
*/
|
||||
|
||||
export interface ContainerInspectView {
|
||||
id?: string;
|
||||
image?: string;
|
||||
status?: string;
|
||||
startedAt?: string;
|
||||
createdAt?: string;
|
||||
restartPolicy?: string;
|
||||
command?: string;
|
||||
ports: string[];
|
||||
networks: string[];
|
||||
mounts: string[];
|
||||
env: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
export interface ImageInspectView {
|
||||
id?: string;
|
||||
tags: string[];
|
||||
digests: string[];
|
||||
createdAt?: string;
|
||||
size?: string;
|
||||
platform?: string;
|
||||
entrypoint?: string;
|
||||
cmd?: string;
|
||||
workdir?: string;
|
||||
exposedPorts: string[];
|
||||
env: string[];
|
||||
labels: string[];
|
||||
}
|
||||
|
||||
type Dict = Record<string, unknown>;
|
||||
|
||||
function str(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function rec(value: unknown): Dict | undefined {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Dict)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function strArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.map((v) => String(v)).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
export function shortDockerId(value: unknown): string | undefined {
|
||||
const raw = str(value);
|
||||
if (!raw) return undefined;
|
||||
return raw.replace(/^sha256:/, '').slice(0, 12);
|
||||
}
|
||||
|
||||
function formatIsoDate(value: unknown): string | undefined {
|
||||
const raw = str(value);
|
||||
if (!raw) return undefined;
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw;
|
||||
// Docker uses 0001-01-01T00:00:00Z for "never".
|
||||
if (date.getTime() <= 0) return undefined;
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatBytes(value: unknown): string | undefined {
|
||||
const num = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(num) || num < 0) return undefined;
|
||||
if (num >= 1024 ** 3) return `${(num / 1024 ** 3).toFixed(2)} GB`;
|
||||
if (num >= 1024 ** 2) return `${(num / 1024 ** 2).toFixed(1)} MB`;
|
||||
if (num >= 1024) return `${(num / 1024).toFixed(1)} KB`;
|
||||
return `${num} B`;
|
||||
}
|
||||
|
||||
function labelLines(value: unknown): string[] {
|
||||
const labels = rec(value);
|
||||
if (!labels) return [];
|
||||
return Object.entries(labels).map(([key, val]) => `${key}=${String(val ?? '')}`);
|
||||
}
|
||||
|
||||
/** "0.0.0.0:8080->80/tcp" for published ports, "80/tcp" for exposed-only. */
|
||||
function portLines(portsMap: unknown): string[] {
|
||||
const ports = rec(portsMap);
|
||||
if (!ports) return [];
|
||||
const lines: string[] = [];
|
||||
for (const [containerPort, bindings] of Object.entries(ports)) {
|
||||
if (!Array.isArray(bindings) || bindings.length === 0) {
|
||||
lines.push(containerPort);
|
||||
continue;
|
||||
}
|
||||
for (const binding of bindings) {
|
||||
const bind = rec(binding);
|
||||
const hostIp = str(bind?.HostIp) ?? '0.0.0.0';
|
||||
const hostPort = str(bind?.HostPort);
|
||||
lines.push(hostPort ? `${hostIp}:${hostPort} -> ${containerPort}` : containerPort);
|
||||
}
|
||||
}
|
||||
return [...new Set(lines)];
|
||||
}
|
||||
|
||||
function commandLine(path: unknown, args: unknown): string | undefined {
|
||||
const parts = [str(path), ...strArray(args)].filter(Boolean) as string[];
|
||||
return parts.length ? parts.join(' ') : undefined;
|
||||
}
|
||||
|
||||
function joinCommand(value: unknown): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const joined = value.map((v) => String(v)).join(' ').trim();
|
||||
return joined || undefined;
|
||||
}
|
||||
return str(value);
|
||||
}
|
||||
|
||||
export function buildContainerInspectView(data: Dict): ContainerInspectView {
|
||||
const state = rec(data.state);
|
||||
const network = rec(data.network);
|
||||
|
||||
let status = str(state?.Status);
|
||||
const exitCode = typeof state?.ExitCode === 'number' ? state.ExitCode : undefined;
|
||||
if (status && status !== 'running' && exitCode !== undefined && exitCode !== 0) {
|
||||
status = `${status} (exit ${exitCode})`;
|
||||
}
|
||||
|
||||
const networks: string[] = [];
|
||||
const networksMap = rec(network?.Networks);
|
||||
if (networksMap) {
|
||||
for (const [name, info] of Object.entries(networksMap)) {
|
||||
const ip = str(rec(info)?.IPAddress);
|
||||
networks.push(ip ? `${name} · ${ip}` : name);
|
||||
}
|
||||
} else {
|
||||
const ip = str(network?.IPAddress);
|
||||
if (ip) networks.push(ip);
|
||||
}
|
||||
|
||||
const mounts = Array.isArray(data.mounts)
|
||||
? data.mounts.map((mount) => {
|
||||
const m = rec(mount);
|
||||
const source = str(m?.Source) ?? str(m?.Name) ?? str(m?.Type) ?? '?';
|
||||
const destination = str(m?.Destination) ?? '?';
|
||||
const mode = m?.RW === false ? 'ro' : 'rw';
|
||||
return `${source} -> ${destination} (${mode})`;
|
||||
})
|
||||
: [];
|
||||
|
||||
const restartPolicy = str(rec(data.restartPolicy)?.Name);
|
||||
|
||||
return {
|
||||
id: shortDockerId(data.id),
|
||||
image: str(data.image),
|
||||
status,
|
||||
startedAt: formatIsoDate(state?.StartedAt),
|
||||
createdAt: formatIsoDate(data.created),
|
||||
restartPolicy,
|
||||
command: commandLine(data.path, data.args),
|
||||
ports: portLines(network?.Ports),
|
||||
networks,
|
||||
mounts,
|
||||
env: strArray(data.env),
|
||||
labels: labelLines(data.labels),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildImageInspectView(data: Dict): ImageInspectView {
|
||||
const config = rec(data.config);
|
||||
const os = str(data.os);
|
||||
const arch = str(data.architecture);
|
||||
|
||||
return {
|
||||
id: shortDockerId(data.id),
|
||||
tags: strArray(data.repoTags),
|
||||
digests: strArray(data.repoDigests),
|
||||
createdAt: formatIsoDate(data.created),
|
||||
size: formatBytes(data.size),
|
||||
platform: os && arch ? `${os}/${arch}` : os ?? arch,
|
||||
entrypoint: joinCommand(config?.entrypoint),
|
||||
cmd: joinCommand(config?.cmd),
|
||||
workdir: str(config?.workingDir),
|
||||
exposedPorts: Object.keys(rec(config?.exposedPorts) ?? {}),
|
||||
env: strArray(config?.env),
|
||||
labels: labelLines(config?.labels),
|
||||
};
|
||||
}
|
||||
67
domain/systemManager/pollEquals.ts
Normal file
67
domain/systemManager/pollEquals.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
DockerContainerInfo,
|
||||
DockerImageInfo,
|
||||
ListeningPortInfo,
|
||||
SystemdUnitInfo,
|
||||
SystemProcessInfo,
|
||||
TmuxSessionInfo,
|
||||
} from './types';
|
||||
|
||||
export function systemProcessInfoEqual(a: SystemProcessInfo, b: SystemProcessInfo): boolean {
|
||||
return a.pid === b.pid
|
||||
&& a.ppid === b.ppid
|
||||
&& a.user === b.user
|
||||
&& a.stat === b.stat
|
||||
&& a.cpuPercent === b.cpuPercent
|
||||
&& a.memPercent === b.memPercent
|
||||
&& a.rssKb === b.rssKb
|
||||
&& a.vszKb === b.vszKb
|
||||
&& a.elapsed === b.elapsed
|
||||
&& a.command === b.command;
|
||||
}
|
||||
|
||||
export function tmuxSessionInfoEqual(a: TmuxSessionInfo, b: TmuxSessionInfo): boolean {
|
||||
return a.name === b.name
|
||||
&& a.windows === b.windows
|
||||
&& a.attached === b.attached
|
||||
&& a.created === b.created
|
||||
&& a.activity === b.activity
|
||||
&& a.group === b.group;
|
||||
}
|
||||
|
||||
export function dockerContainerInfoEqual(a: DockerContainerInfo, b: DockerContainerInfo): boolean {
|
||||
return a.id === b.id
|
||||
&& a.name === b.name
|
||||
&& a.image === b.image
|
||||
&& a.status === b.status
|
||||
&& a.state === b.state
|
||||
&& a.ports === b.ports
|
||||
&& a.createdAt === b.createdAt;
|
||||
}
|
||||
|
||||
export function dockerImageInfoEqual(a: DockerImageInfo, b: DockerImageInfo): boolean {
|
||||
return a.id === b.id
|
||||
&& a.repository === b.repository
|
||||
&& a.tag === b.tag
|
||||
&& a.name === b.name
|
||||
&& a.size === b.size
|
||||
&& a.createdAt === b.createdAt;
|
||||
}
|
||||
|
||||
export function listeningPortInfoEqual(a: ListeningPortInfo, b: ListeningPortInfo): boolean {
|
||||
return a.id === b.id
|
||||
&& a.protocol === b.protocol
|
||||
&& a.address === b.address
|
||||
&& a.port === b.port
|
||||
&& a.pid === b.pid
|
||||
&& a.processName === b.processName;
|
||||
}
|
||||
|
||||
export function systemdUnitInfoEqual(a: SystemdUnitInfo, b: SystemdUnitInfo): boolean {
|
||||
return a.name === b.name
|
||||
&& a.loadState === b.loadState
|
||||
&& a.activeState === b.activeState
|
||||
&& a.subState === b.subState
|
||||
&& a.description === b.description
|
||||
&& a.scope === b.scope;
|
||||
}
|
||||
28
domain/systemManager/pollListStable.test.ts
Normal file
28
domain/systemManager/pollListStable.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
import { mergePollListByKey, nextPollData } from "./pollListStable.ts";
|
||||
|
||||
test("nextPollData reuses previous reference when payload is unchanged", () => {
|
||||
const prev = { a: 1 };
|
||||
assert.equal(nextPollData(prev, { a: 1 }), prev);
|
||||
assert.notEqual(nextPollData(prev, { a: 2 }), prev);
|
||||
});
|
||||
|
||||
test("mergePollListByKey reuses unchanged row references", () => {
|
||||
const prev = [{ id: "1", v: 1 }, { id: "2", v: 2 }];
|
||||
const next = [{ id: "1", v: 1 }, { id: "2", v: 3 }];
|
||||
const merged = mergePollListByKey(prev, next, (item) => item.id);
|
||||
assert.equal(merged[0], prev[0]);
|
||||
assert.deepEqual(merged[1], { id: "2", v: 3 });
|
||||
});
|
||||
|
||||
test("useSystemManager imports nextPollData from domain", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../application/state/useSystemManager.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /from ['"]\.\.\/\.\.\/domain\/systemManager\/pollListStable['"]/);
|
||||
assert.doesNotMatch(source, /from ['"].*components\//);
|
||||
});
|
||||
50
domain/systemManager/pollListStable.ts
Normal file
50
domain/systemManager/pollListStable.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
function pollSnapshotEqual<T>(prev: T | null, next: T): boolean {
|
||||
if (prev === next) return true;
|
||||
if (prev === null) return false;
|
||||
try {
|
||||
return JSON.stringify(prev) === JSON.stringify(next);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function itemSnapshotEqual<T>(a: T, b: T): boolean {
|
||||
if (a === b) return true;
|
||||
try {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Skip React state updates when polled payload is unchanged. */
|
||||
export function nextPollData<T>(prev: T | null, next: T): T {
|
||||
return pollSnapshotEqual(prev, next) ? prev as T : next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge polled list rows by key, reusing previous item references when unchanged.
|
||||
* Keeps React.memo row components from re-rendering when other rows update.
|
||||
*/
|
||||
export function mergePollListByKey<T, K extends string | number>(
|
||||
prev: T[] | null,
|
||||
next: T[],
|
||||
getKey: (item: T) => K,
|
||||
isEqual: (a: T, b: T) => boolean = itemSnapshotEqual,
|
||||
): T[] {
|
||||
if (prev === null) return next;
|
||||
if (prev.length !== next.length) return next;
|
||||
|
||||
const nextByKey = new Map(next.map((item) => [getKey(item), item]));
|
||||
if (prev.some((item) => !nextByKey.has(getKey(item)))) return next;
|
||||
if (next.some((item) => !prev.some((p) => getKey(p) === getKey(item)))) return next;
|
||||
|
||||
let changed = false;
|
||||
const merged = prev.map((oldItem) => {
|
||||
const newItem = nextByKey.get(getKey(oldItem))!;
|
||||
if (isEqual(oldItem, newItem)) return oldItem;
|
||||
changed = true;
|
||||
return newItem;
|
||||
});
|
||||
return changed ? merged : prev;
|
||||
}
|
||||
37
domain/systemManager/processState.ts
Normal file
37
domain/systemManager/processState.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { SystemProcessInfo } from './types';
|
||||
|
||||
export function getProcessFlags(proc: SystemProcessInfo): {
|
||||
isStopped: boolean;
|
||||
isZombie: boolean;
|
||||
isRunning: boolean;
|
||||
isSleeping: boolean;
|
||||
} {
|
||||
const stat = proc.stat || '';
|
||||
const isZombie = /Z/i.test(stat);
|
||||
const isStopped = /T/i.test(stat);
|
||||
const isRunning = /R/i.test(stat);
|
||||
const isSleeping = /[SD]/i.test(stat) && !isStopped && !isZombie;
|
||||
return { isStopped, isZombie, isRunning, isSleeping };
|
||||
}
|
||||
|
||||
export function getProcessTone(proc: SystemProcessInfo): 'success' | 'warning' | 'muted' {
|
||||
const { isStopped, isZombie, isRunning } = getProcessFlags(proc);
|
||||
if (isZombie) return 'muted';
|
||||
if (isStopped) return 'warning';
|
||||
if (isRunning) return 'success';
|
||||
return 'muted';
|
||||
}
|
||||
|
||||
export type ProcessStatusLabelKey =
|
||||
| 'systemManager.processes.state.running'
|
||||
| 'systemManager.processes.state.sleeping'
|
||||
| 'systemManager.processes.state.stopped'
|
||||
| 'systemManager.processes.state.zombie';
|
||||
|
||||
export function getProcessStatusLabelKey(proc: SystemProcessInfo): ProcessStatusLabelKey {
|
||||
const { isStopped, isZombie, isRunning } = getProcessFlags(proc);
|
||||
if (isZombie) return 'systemManager.processes.state.zombie';
|
||||
if (isStopped) return 'systemManager.processes.state.stopped';
|
||||
if (isRunning) return 'systemManager.processes.state.running';
|
||||
return 'systemManager.processes.state.sleeping';
|
||||
}
|
||||
21
domain/systemManager/resolveSystemSession.ts
Normal file
21
domain/systemManager/resolveSystemSession.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { collectSessionIds } from '../workspace';
|
||||
import type { TerminalSession, Workspace } from '../../types';
|
||||
|
||||
/** Resolve which terminal session the system sidebar should target (workspace focus-aware). */
|
||||
export function resolveSystemSidebarSession(
|
||||
sessions: TerminalSession[],
|
||||
activeWorkspace: Workspace | undefined,
|
||||
focusedSessionId: string | undefined,
|
||||
activeSession: TerminalSession | undefined,
|
||||
): TerminalSession | null {
|
||||
if (activeWorkspace) {
|
||||
const workspaceSessionIds = collectSessionIds(activeWorkspace.root);
|
||||
const idSet = new Set(workspaceSessionIds);
|
||||
const preferredId = focusedSessionId && idSet.has(focusedSessionId)
|
||||
? focusedSessionId
|
||||
: workspaceSessionIds.find((id) => sessions.some((session) => session.id === id));
|
||||
if (!preferredId) return null;
|
||||
return sessions.find((session) => session.id === preferredId) ?? null;
|
||||
}
|
||||
return activeSession ?? null;
|
||||
}
|
||||
183
domain/systemManager/systemTarget.test.ts
Normal file
183
domain/systemManager/systemTarget.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
shouldCollectServerStats,
|
||||
shouldShowGpuTab,
|
||||
shouldShowPortsTab,
|
||||
shouldShowProcessesTab,
|
||||
shouldShowServicesTab,
|
||||
} from "./systemTarget.ts";
|
||||
import type { SessionCapabilities } from "./types.ts";
|
||||
|
||||
function caps(partial: Partial<SessionCapabilities> = {}): SessionCapabilities {
|
||||
return {
|
||||
targetOs: "linux",
|
||||
hasTmux: false,
|
||||
hasDocker: false,
|
||||
hasNvidiaSmi: false,
|
||||
hasNpuSmi: false,
|
||||
hasSs: false,
|
||||
hasNetstat: false,
|
||||
hasLsof: false,
|
||||
hasSystemctl: false,
|
||||
probedAt: 1,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
test("system manager shows overview before detailed management tabs", () => {
|
||||
assert.deepEqual(buildSystemManagerTabs(null, undefined, null), ["overview", "processes"]);
|
||||
});
|
||||
|
||||
test("gpu tab appears only after nvidia-smi or npu-smi is detected", () => {
|
||||
assert.equal(shouldShowGpuTab(undefined), false);
|
||||
assert.equal(shouldShowGpuTab(caps()), false);
|
||||
assert.equal(shouldShowGpuTab(caps({ hasNvidiaSmi: true })), true);
|
||||
|
||||
const host = {
|
||||
id: "host-gpu",
|
||||
label: "GPU",
|
||||
hostname: "gpu.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
};
|
||||
assert.deepEqual(
|
||||
buildSystemManagerTabs(host, caps({
|
||||
hasTmux: true,
|
||||
hasDocker: true,
|
||||
hasNpuSmi: true,
|
||||
hasSs: true,
|
||||
hasSystemctl: true,
|
||||
}), null),
|
||||
["overview", "processes", "ports", "services", "tmux", "docker", "gpu"],
|
||||
);
|
||||
});
|
||||
|
||||
test("ports and services tabs are detect-first like GPU", () => {
|
||||
assert.equal(shouldShowPortsTab(undefined), false);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasSs: true })), true);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasNetstat: true })), true);
|
||||
assert.equal(shouldShowPortsTab(caps({ hasLsof: true })), true);
|
||||
assert.equal(shouldShowServicesTab(undefined), false);
|
||||
assert.equal(shouldShowServicesTab(caps({ hasSystemctl: true })), true);
|
||||
});
|
||||
|
||||
test("network appliances keep Ports/Services read-only", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Router",
|
||||
hostname: "router.local",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
deviceType: "network" as const,
|
||||
};
|
||||
assert.equal(allowSystemManagerMutations(host), false);
|
||||
assert.equal(allowSystemManagerMutations({
|
||||
id: "host-2",
|
||||
label: "Linux",
|
||||
hostname: "linux.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("network devices hide processes until OS probe confirms a real target", () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Router",
|
||||
hostname: "router.local",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
deviceType: "network" as const,
|
||||
};
|
||||
assert.equal(shouldShowProcessesTab(host, undefined), false);
|
||||
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview"]);
|
||||
assert.equal(shouldShowProcessesTab(host, caps({ targetOs: "linux" })), true);
|
||||
assert.deepEqual(
|
||||
buildSystemManagerTabs(host, caps({ targetOs: "linux", hasSs: true }), null),
|
||||
["overview", "processes", "ports"],
|
||||
);
|
||||
});
|
||||
|
||||
test("system overview stats skip network devices even when a Linux icon was selected", () => {
|
||||
assert.equal(
|
||||
shouldCollectServerStats(
|
||||
{
|
||||
id: "host-1",
|
||||
label: "Router",
|
||||
hostname: "router.local",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
deviceType: "network",
|
||||
},
|
||||
undefined,
|
||||
null,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("system overview stats run for Linux and macOS targets", () => {
|
||||
assert.equal(
|
||||
shouldCollectServerStats(
|
||||
{
|
||||
id: "host-1",
|
||||
label: "Linux",
|
||||
distro: "ubuntu",
|
||||
hostname: "linux.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
},
|
||||
undefined,
|
||||
null,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollectServerStats(
|
||||
{
|
||||
id: "host-2",
|
||||
label: "Mac",
|
||||
hostname: "mac.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "macos",
|
||||
},
|
||||
undefined,
|
||||
null,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("FreeBSD icon detection does not enable unsupported system features", () => {
|
||||
const host = {
|
||||
id: "host-3",
|
||||
label: "FreeBSD",
|
||||
hostname: "freebsd.local",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
distro: "freebsd",
|
||||
};
|
||||
|
||||
assert.equal(shouldCollectServerStats(host, undefined, null), false);
|
||||
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ["overview", "processes"]);
|
||||
});
|
||||
|
||||
test('default Linux and cosmetic icons cannot enable Linux commands before detection', () => {
|
||||
const host = {id:'unknown', label:'Host', hostname:'host', username:'user', tags:[], os:'linux' as const, manualDistro:'ubuntu', distroMode:'manual' as const};
|
||||
assert.equal(shouldCollectServerStats(host, undefined, null), false);
|
||||
assert.deepEqual(buildSystemManagerTabs(host, undefined, null), ['overview','processes']);
|
||||
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu'},undefined,null),true);
|
||||
assert.equal(shouldCollectServerStats({...host,distro:'ubuntu',osOverride:'windows'},undefined,null),true);
|
||||
});
|
||||
118
domain/systemManager/systemTarget.ts
Normal file
118
domain/systemManager/systemTarget.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { classifyDistroId, resolveHostOs } from '../host';
|
||||
import type { Host } from '../models/connection';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import type { SessionCapabilities, SystemManagerSubTab } from './types';
|
||||
|
||||
export function isNetworkDeviceTarget(host: Host | null | undefined): boolean {
|
||||
if (host?.deviceType === 'network') return true;
|
||||
return classifyDistroId(host?.distro) === 'network-device';
|
||||
}
|
||||
|
||||
export function isDefiniteLinuxTarget(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
_session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') return capabilities.targetOs === 'linux';
|
||||
if (isNetworkDeviceTarget(host)) return false;
|
||||
if (resolveHostOs(host) === 'linux') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowProcessesTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
): boolean {
|
||||
// Network appliances often lack a usable process table; only show after OS probe confirms.
|
||||
if (isNetworkDeviceTarget(host)) {
|
||||
const os = capabilities?.targetOs;
|
||||
return os === 'linux' || os === 'darwin' || os === 'win32';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function shouldShowTmuxTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
// Network appliances: detect-first only — do not guess from a Linux-like probe.
|
||||
if (isNetworkDeviceTarget(host)) return capabilities?.hasTmux === true;
|
||||
if (isDefiniteLinuxTarget(host, capabilities, session)) return true;
|
||||
if (capabilities?.targetOs === 'darwin') return true;
|
||||
if (resolveHostOs(host) === 'macos') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldShowDockerTab(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
if (capabilities?.hasDocker === true) return true;
|
||||
// Network appliances: never show Docker from an OS guess alone.
|
||||
if (isNetworkDeviceTarget(host)) return false;
|
||||
return isDefiniteLinuxTarget(host, capabilities, session);
|
||||
}
|
||||
|
||||
/** GPU tab only appears after nvidia-smi / npu-smi is actually detected. */
|
||||
export function shouldShowGpuTab(
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
): boolean {
|
||||
return capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
}
|
||||
|
||||
/** Ports tab only appears after a collector binary is detected (same detect-first model as GPU). */
|
||||
export function shouldShowPortsTab(
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
capabilities?.hasSs === true
|
||||
|| capabilities?.hasNetstat === true
|
||||
|| capabilities?.hasLsof === true
|
||||
);
|
||||
}
|
||||
|
||||
/** Destructive port/service actions stay off for network appliances. */
|
||||
export function allowSystemManagerMutations(
|
||||
host: Host | null | undefined,
|
||||
): boolean {
|
||||
return !isNetworkDeviceTarget(host);
|
||||
}
|
||||
|
||||
/** Services tab only appears after systemctl is detected. */
|
||||
export function shouldShowServicesTab(
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
): boolean {
|
||||
return capabilities?.hasSystemctl === true;
|
||||
}
|
||||
|
||||
export function shouldCollectServerStats(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
_session: TerminalSession | null | undefined,
|
||||
): boolean {
|
||||
const detectedDeviceClass = classifyDistroId(host?.distro);
|
||||
if (isNetworkDeviceTarget(host) || detectedDeviceClass === 'network-device') return false;
|
||||
if (capabilities?.targetOs && capabilities.targetOs !== 'unknown') {
|
||||
return capabilities.targetOs === 'linux' || capabilities.targetOs === 'darwin' || capabilities.targetOs === 'windows';
|
||||
}
|
||||
const hostOs = resolveHostOs(host);
|
||||
if (hostOs === 'linux' || hostOs === 'macos' || hostOs === 'windows') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function buildSystemManagerTabs(
|
||||
host: Host | null | undefined,
|
||||
capabilities: SessionCapabilities | undefined,
|
||||
session: TerminalSession | null | undefined,
|
||||
): SystemManagerSubTab[] {
|
||||
const tabs: SystemManagerSubTab[] = ['overview'];
|
||||
if (shouldShowProcessesTab(host, capabilities)) tabs.push('processes');
|
||||
if (shouldShowPortsTab(capabilities)) tabs.push('ports');
|
||||
if (shouldShowServicesTab(capabilities)) tabs.push('services');
|
||||
if (shouldShowTmuxTab(host, capabilities, session)) tabs.push('tmux');
|
||||
if (shouldShowDockerTab(host, capabilities, session)) tabs.push('docker');
|
||||
if (shouldShowGpuTab(capabilities)) tabs.push('gpu');
|
||||
return tabs;
|
||||
}
|
||||
13
domain/systemManager/tmuxShell.ts
Normal file
13
domain/systemManager/tmuxShell.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** POSIX single-quote escaping for remote shell commands built in the renderer. */
|
||||
export function shQuote(str: string): string {
|
||||
return `'${String(str).replace(/'/g, "'\"'\"'")}'`;
|
||||
}
|
||||
|
||||
const CLEAR_STARTUP_OUTPUT = "printf '\\033[H\\033[2J\\033[3J';";
|
||||
|
||||
export function buildTmuxAttachCommand(sessionName: string, windowIndex?: number): string {
|
||||
const target = windowIndex !== undefined
|
||||
? `${shQuote(sessionName)}:${windowIndex}`
|
||||
: shQuote(sessionName);
|
||||
return `${CLEAR_STARTUP_OUTPUT} exec tmux attach -t ${target}`;
|
||||
}
|
||||
232
domain/systemManager/types.ts
Normal file
232
domain/systemManager/types.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
export type TargetOs = 'linux' | 'darwin' | 'win32' | 'unknown';
|
||||
|
||||
export interface SessionCapabilities {
|
||||
targetOs: TargetOs;
|
||||
hasTmux: boolean;
|
||||
hasDocker: boolean;
|
||||
hasNvidiaSmi: boolean;
|
||||
hasNpuSmi: boolean;
|
||||
/** `ss` binary present (preferred listening-port collector). */
|
||||
hasSs?: boolean;
|
||||
/** `netstat` binary present (ports fallback). */
|
||||
hasNetstat?: boolean;
|
||||
/** `lsof` binary present (macOS / process-aware ports fallback). */
|
||||
hasLsof?: boolean;
|
||||
/** `systemctl` binary present. */
|
||||
hasSystemctl?: boolean;
|
||||
probedAt: number;
|
||||
}
|
||||
|
||||
export type ListeningPortProtocol = 'tcp' | 'udp' | 'tcp6' | 'udp6' | 'unknown';
|
||||
|
||||
export interface ListeningPortInfo {
|
||||
protocol: ListeningPortProtocol;
|
||||
address: string;
|
||||
port: number;
|
||||
pid: number | null;
|
||||
processName: string;
|
||||
/** Stable row id for list merging: protocol|address|port|pid */
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type SystemdUnitActiveState =
|
||||
| 'active'
|
||||
| 'inactive'
|
||||
| 'failed'
|
||||
| 'activating'
|
||||
| 'deactivating'
|
||||
| 'reloading'
|
||||
| 'unknown';
|
||||
|
||||
export type SystemdUnitLoadState = 'loaded' | 'not-found' | 'bad-setting' | 'error' | 'masked' | 'unknown';
|
||||
export type SystemdUnitSubState = string;
|
||||
|
||||
export interface SystemdUnitInfo {
|
||||
name: string;
|
||||
loadState: SystemdUnitLoadState;
|
||||
activeState: SystemdUnitActiveState;
|
||||
subState: SystemdUnitSubState;
|
||||
description: string;
|
||||
/** system or --user instance */
|
||||
scope: 'system' | 'user';
|
||||
}
|
||||
|
||||
export type SystemdUnitAction = 'start' | 'stop' | 'restart' | 'enable' | 'disable' | 'reload';
|
||||
|
||||
export type AcceleratorVendor = 'nvidia' | 'ascend';
|
||||
|
||||
export interface AcceleratorDeviceInfo {
|
||||
vendor: AcceleratorVendor;
|
||||
index: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
utilizationPercent: number | null;
|
||||
memoryUsedMb: number | null;
|
||||
memoryTotalMb: number | null;
|
||||
temperatureC: number | null;
|
||||
powerDrawW: number | null;
|
||||
powerLimitW: number | null;
|
||||
fanPercent: number | null;
|
||||
driverVersion: string | null;
|
||||
health: string | null;
|
||||
}
|
||||
|
||||
export interface AcceleratorProcessInfo {
|
||||
vendor: AcceleratorVendor;
|
||||
gpuIndex: number;
|
||||
pid: number;
|
||||
processName: string;
|
||||
memoryUsedMb: number | null;
|
||||
}
|
||||
|
||||
export interface AcceleratorSnapshot {
|
||||
devices: AcceleratorDeviceInfo[];
|
||||
processes: AcceleratorProcessInfo[];
|
||||
nvidiaDriverVersion: string | null;
|
||||
probedAt: number;
|
||||
}
|
||||
|
||||
export interface SystemProcessInfo {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
user: string;
|
||||
stat: string;
|
||||
cpuPercent: number;
|
||||
memPercent: number;
|
||||
rssKb: number;
|
||||
vszKb: number;
|
||||
elapsed: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface TmuxSessionInfo {
|
||||
name: string;
|
||||
windows: number;
|
||||
attached: boolean;
|
||||
created: number;
|
||||
activity?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
export interface TmuxWindowInfo {
|
||||
index: number;
|
||||
name: string;
|
||||
panes: number;
|
||||
active: boolean;
|
||||
layout: string;
|
||||
}
|
||||
|
||||
export interface TmuxPaneInfo {
|
||||
index: number;
|
||||
title: string;
|
||||
command: string;
|
||||
active: boolean;
|
||||
pid: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface TmuxClientInfo {
|
||||
name: string;
|
||||
tty: string;
|
||||
activity: string;
|
||||
session: string;
|
||||
}
|
||||
|
||||
export type TmuxManageAction =
|
||||
| { action: 'killSession'; sessionName: string }
|
||||
| { action: 'renameSession'; sessionName: string; newName: string }
|
||||
| { action: 'detachSession'; sessionName: string }
|
||||
| { action: 'createWindow'; sessionName: string; windowName?: string }
|
||||
| { action: 'killWindow'; sessionName: string; windowIndex: number }
|
||||
| { action: 'renameWindow'; sessionName: string; windowIndex: number; newName: string }
|
||||
| { action: 'killPane'; sessionName: string; windowIndex: number; paneIndex: number }
|
||||
| { action: 'splitPane'; sessionName: string; windowIndex: number; paneIndex?: number; direction: 'horizontal' | 'vertical' }
|
||||
| { action: 'sendKeys'; sessionName: string; windowIndex: number; paneIndex: number; keys: string; enter?: boolean }
|
||||
| { action: 'selectWindow'; sessionName: string; windowIndex: number }
|
||||
| { action: 'killServer' };
|
||||
|
||||
export interface DockerContainerInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state: string;
|
||||
ports: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DockerStatInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
cpuPercent: number;
|
||||
memUsage: string;
|
||||
memPercent: number;
|
||||
netIO: string;
|
||||
blockIO: string;
|
||||
pids: number;
|
||||
}
|
||||
|
||||
export interface DockerImageInfo {
|
||||
id: string;
|
||||
repository: string;
|
||||
tag: string;
|
||||
size: string;
|
||||
createdAt: string;
|
||||
digest?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Unique per `docker images` row — same layer id can have multiple repo:tag lines. */
|
||||
export function dockerImageRowKey(image: DockerImageInfo): string {
|
||||
return `${image.id}\0${image.repository}\0${image.tag}`;
|
||||
}
|
||||
|
||||
export type DockerContainerAction =
|
||||
| 'start'
|
||||
| 'stop'
|
||||
| 'restart'
|
||||
| 'rm'
|
||||
| 'pause'
|
||||
| 'unpause'
|
||||
| 'kill'
|
||||
| 'rename';
|
||||
|
||||
export type DockerImageManageAction =
|
||||
| { action: 'pull'; imageRef: string }
|
||||
| { action: 'rm'; imageId: string; force?: boolean }
|
||||
| { action: 'prune'; all?: boolean }
|
||||
| { action: 'tag'; imageId: string; repository: string; tag?: string };
|
||||
|
||||
export type SystemManagerSubTab =
|
||||
| 'overview'
|
||||
| 'processes'
|
||||
| 'ports'
|
||||
| 'services'
|
||||
| 'tmux'
|
||||
| 'docker'
|
||||
| 'gpu';
|
||||
|
||||
export interface TerminalPopupIcon {
|
||||
kind: 'image';
|
||||
src: string;
|
||||
backgroundColor?: string;
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export interface TerminalPopupPayload {
|
||||
popupId?: string;
|
||||
title: string;
|
||||
icon?: TerminalPopupIcon;
|
||||
parentSessionId: string;
|
||||
sourceSession: import('../../types').TerminalSession;
|
||||
startupCommand: string;
|
||||
localShellType?: import('../../types').TerminalSession['shellType'];
|
||||
/**
|
||||
* When set, the popup attaches to this already-running backend session
|
||||
* (same PTY) instead of starting a new shell. Used for AI silent sessions.
|
||||
*/
|
||||
attachSessionId?: string;
|
||||
/** Ephemeral main-process grant bound to the attach popup window. */
|
||||
attachAuthorization?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user