[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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';
}

View 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',
};

View 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,
};
}

View 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);
});

View 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)}`);
}

View 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),
};
}

View 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;
}

View 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\//);
});

View 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;
}

View 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';
}

View 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;
}

View 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);
});

View 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;
}

View 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}`;
}

View 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;
}