mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
469 lines
14 KiB
TypeScript
469 lines
14 KiB
TypeScript
/**
|
|
* Docker and Podman command builders and output parsers.
|
|
*
|
|
* Container ids and names are checked with CONTAINER_ID_RE before they go on
|
|
* a command line, so they need no quoting. Anything free form is quoted.
|
|
*/
|
|
import { HostScriptError } from "./text.js";
|
|
|
|
export type ContainerRuntime = "docker" | "podman";
|
|
|
|
export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
|
return value === "podman" ? "podman" : "docker";
|
|
}
|
|
|
|
export function getRuntimeLabel(runtime: ContainerRuntime): string {
|
|
return runtime === "podman" ? "Podman" : "Docker";
|
|
}
|
|
|
|
/**
|
|
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
|
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
|
*/
|
|
export const EXTRA_PATH_DIRS =
|
|
'/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:"$HOME/.orbstack/bin"';
|
|
|
|
export function containerCommand(
|
|
runtime: ContainerRuntime | undefined,
|
|
args: string,
|
|
isWindows = false,
|
|
): string {
|
|
const cli = `${normalizeContainerRuntime(runtime)} ${args}`;
|
|
// cmd and PowerShell don't understand the POSIX env prefix.
|
|
return isWindows ? cli : `PATH="${EXTRA_PATH_DIRS}:$PATH" ${cli}`;
|
|
}
|
|
|
|
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
|
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
|
|
|
export function isContainerId(value: unknown): value is string {
|
|
return (
|
|
typeof value === "string" &&
|
|
value.length <= 256 &&
|
|
CONTAINER_ID_RE.test(value)
|
|
);
|
|
}
|
|
|
|
function requireContainer(id: string): string {
|
|
if (!isContainerId(id))
|
|
throw new HostScriptError("That is not a valid container id");
|
|
return id;
|
|
}
|
|
|
|
export const CONTAINER_ACTIONS = [
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"pause",
|
|
"unpause",
|
|
] as const;
|
|
export type ContainerAction = (typeof CONTAINER_ACTIONS)[number];
|
|
|
|
const LIST_FORMAT_UNIX = `'{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}' `;
|
|
const LIST_FORMAT_WINDOWS = `"{\\"id\\":\\"{{.ID}}\\",\\"name\\":\\"{{.Names}}\\",\\"image\\":\\"{{.Image}}\\",\\"status\\":\\"{{.Status}}\\",\\"state\\":\\"{{.State}}\\",\\"ports\\":\\"{{.Ports}}\\",\\"created\\":\\"{{.CreatedAt}}\\"}"`;
|
|
const STATS_FORMAT_UNIX = `'{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}' `;
|
|
const STATS_FORMAT_WINDOWS = `"{\\"cpu\\":\\"{{.CPUPerc}}\\",\\"memory\\":\\"{{.MemUsage}}\\",\\"memoryPercent\\":\\"{{.MemPerc}}\\",\\"netIO\\":\\"{{.NetIO}}\\",\\"blockIO\\":\\"{{.BlockIO}}\\",\\"pids\\":\\"{{.PIDs}}\\"}"`;
|
|
|
|
export function listFormat(isWindows: boolean): string {
|
|
return isWindows ? LIST_FORMAT_WINDOWS : LIST_FORMAT_UNIX;
|
|
}
|
|
|
|
export function statsFormat(isWindows: boolean): string {
|
|
return isWindows ? STATS_FORMAT_WINDOWS : STATS_FORMAT_UNIX;
|
|
}
|
|
|
|
/** One row of `ps --format listFormat()`. */
|
|
export interface ContainerRow {
|
|
id: string;
|
|
name: string;
|
|
image: string;
|
|
status: string;
|
|
state: string;
|
|
ports: string;
|
|
created: string;
|
|
}
|
|
|
|
/** CLI arguments, without the runtime, for each container command. */
|
|
export const dockerArgs = {
|
|
version: () => "--version",
|
|
ping: () => "ps",
|
|
list: (all: boolean, isWindows = false) =>
|
|
`ps ${all ? "-a " : ""}--format ${listFormat(isWindows)}`,
|
|
inspect: (id: string) => `inspect ${requireContainer(id)}`,
|
|
stats: (id: string, isWindows = false) =>
|
|
`stats ${requireContainer(id)} --no-stream --format ${statsFormat(isWindows)}`,
|
|
action: (action: ContainerAction, id: string) => {
|
|
if (!CONTAINER_ACTIONS.includes(action))
|
|
throw new HostScriptError("Unknown action");
|
|
return `${action} ${requireContainer(id)}`;
|
|
},
|
|
remove: (id: string, force: boolean) =>
|
|
`rm ${force ? "-f " : ""}${requireContainer(id)}`,
|
|
logs: (
|
|
id: string,
|
|
options: {
|
|
tail?: number;
|
|
timestamps?: boolean;
|
|
since?: string;
|
|
until?: string;
|
|
} = {},
|
|
) => {
|
|
let args = `logs ${requireContainer(id)}`;
|
|
if (options.tail && options.tail > 0)
|
|
args += ` --tail ${Math.floor(options.tail)}`;
|
|
if (options.timestamps) args += " --timestamps";
|
|
if (options.since && DOCKER_TIMESTAMP_RE.test(options.since)) {
|
|
args += ` --since ${options.since}`;
|
|
}
|
|
if (options.until && DOCKER_TIMESTAMP_RE.test(options.until)) {
|
|
args += ` --until ${options.until}`;
|
|
}
|
|
return `${args} 2>&1`;
|
|
},
|
|
states: () => `ps -a --format ${PS_STATE_FORMAT}`,
|
|
composeProjects: () => "compose ls -a --format json",
|
|
};
|
|
|
|
/** The version from `docker --version` or `podman --version`. */
|
|
export function parseRuntimeVersion(output: string): string {
|
|
return output.match(/(?:Docker|podman) version ([^\s,]+)/i)?.[1] ?? "unknown";
|
|
}
|
|
|
|
export type RuntimeProblem =
|
|
"NOT_INSTALLED" | "DAEMON_NOT_RUNNING" | "PERMISSION_DENIED" | "DOCKER_ERROR";
|
|
|
|
/** Sorts a failed `docker ps` into a reason the UI can explain. */
|
|
export function classifyRuntimeError(message: string): RuntimeProblem {
|
|
if (
|
|
/command not found|not found: (docker|podman)|No such file or directory/i.test(
|
|
message,
|
|
)
|
|
) {
|
|
return "NOT_INSTALLED";
|
|
}
|
|
if (message.includes("Cannot connect to the Docker daemon"))
|
|
return "DAEMON_NOT_RUNNING";
|
|
if (/permission denied/i.test(message)) return "PERMISSION_DENIED";
|
|
return "DOCKER_ERROR";
|
|
}
|
|
|
|
/** Parses `ps --format listFormat()` output, skipping lines that are not JSON. */
|
|
export function parseContainerList(output: string): Record<string, unknown>[] {
|
|
return output
|
|
.split("\n")
|
|
.filter((line) => line.trim())
|
|
.flatMap((line) => {
|
|
try {
|
|
return [JSON.parse(line) as Record<string, unknown>];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
export interface ContainerStats {
|
|
cpu: string;
|
|
memoryUsed: string;
|
|
memoryLimit: string;
|
|
memoryPercent: string;
|
|
netInput: string;
|
|
netOutput: string;
|
|
blockRead: string;
|
|
blockWrite: string;
|
|
pids: string;
|
|
}
|
|
|
|
/** Parses one `stats --no-stream --format statsFormat()` line. Throws on bad JSON. */
|
|
export function parseContainerStats(output: string): ContainerStats {
|
|
const raw = JSON.parse(output.trim()) as Record<string, string>;
|
|
const split = (value: string | undefined) => {
|
|
const [first, second] = (value ?? "").split(" / ");
|
|
return [first?.trim() || "0B", second?.trim() || "0B"];
|
|
};
|
|
const [memoryUsed, memoryLimit] = split(raw.memory);
|
|
const [netInput, netOutput] = split(raw.netIO);
|
|
const [blockRead, blockWrite] = split(raw.blockIO);
|
|
return {
|
|
cpu: raw.cpu,
|
|
memoryUsed,
|
|
memoryLimit,
|
|
memoryPercent: raw.memoryPercent,
|
|
netInput,
|
|
netOutput,
|
|
blockRead,
|
|
blockWrite,
|
|
pids: raw.pids,
|
|
};
|
|
}
|
|
|
|
export interface ContainerState {
|
|
/** Docker's own state word: running, exited, restarting, ... */
|
|
state: string;
|
|
/** Health from the status text, when the image declares a healthcheck. */
|
|
unhealthy: boolean;
|
|
}
|
|
|
|
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
|
|
|
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
|
|
|
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
|
export function parseContainerStates(
|
|
output: string,
|
|
): Map<string, ContainerState> {
|
|
const states = new Map<string, ContainerState>();
|
|
for (const line of output.split("\n")) {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) continue;
|
|
try {
|
|
const parsed = JSON.parse(trimmed) as {
|
|
name?: string;
|
|
state?: string;
|
|
status?: string;
|
|
};
|
|
if (!parsed.name) continue;
|
|
states.set(parsed.name, {
|
|
state: (parsed.state ?? "").toLowerCase(),
|
|
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
|
});
|
|
} catch {
|
|
// A partial line is not worth failing the whole poll over.
|
|
}
|
|
}
|
|
return states;
|
|
}
|
|
|
|
/**
|
|
* The events a pair of snapshots implies. A container missing from the
|
|
* previous snapshot counts as newly seen rather than started, so the first
|
|
* poll after a restart does not replay every running container.
|
|
*/
|
|
export function diffContainerStates(
|
|
previous: Map<string, ContainerState>,
|
|
current: Map<string, ContainerState>,
|
|
): Array<{ container: string; event: DockerEventName }> {
|
|
const events: Array<{ container: string; event: DockerEventName }> = [];
|
|
for (const [name, now] of current) {
|
|
const before = previous.get(name);
|
|
if (!before) continue;
|
|
if (before.state !== now.state) {
|
|
if (now.state === "exited")
|
|
events.push({ container: name, event: "exited" });
|
|
else if (now.state === "running")
|
|
events.push({ container: name, event: "started" });
|
|
else if (now.state === "restarting") {
|
|
events.push({ container: name, event: "restarting" });
|
|
}
|
|
}
|
|
// A container can go unhealthy while it stays up.
|
|
if (!before.unhealthy && now.unhealthy) {
|
|
events.push({ container: name, event: "unhealthy" });
|
|
}
|
|
}
|
|
return events;
|
|
}
|
|
|
|
export interface ContainerCounts {
|
|
running: number;
|
|
stopped: number;
|
|
unhealthy: number;
|
|
total: number;
|
|
}
|
|
|
|
/** Counts from `ps -a --format PS_STATE_FORMAT` output. */
|
|
export function countContainers(output: string): ContainerCounts {
|
|
const states = [...parseContainerStates(output).values()];
|
|
const running = states.filter((s) => s.state === "running").length;
|
|
return {
|
|
running,
|
|
stopped: states.length - running,
|
|
unhealthy: states.filter((s) => s.unhealthy).length,
|
|
total: states.length,
|
|
};
|
|
}
|
|
|
|
// Compose stacks
|
|
|
|
export const STACK_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
export const STACK_SERVICE_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,126}$/;
|
|
export const COMPOSE_FILES = [
|
|
"compose.yaml",
|
|
"compose.yml",
|
|
"docker-compose.yaml",
|
|
"docker-compose.yml",
|
|
];
|
|
|
|
export const STACK_ACTIONS = [
|
|
"deploy",
|
|
"redeploy",
|
|
"pull",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"pause",
|
|
"unpause",
|
|
"down",
|
|
"destroy",
|
|
] as const;
|
|
export type StackAction = (typeof STACK_ACTIONS)[number];
|
|
|
|
/** Actions that can target one service of a stack. */
|
|
export const STACK_SERVICE_ACTIONS: StackAction[] = [
|
|
"deploy",
|
|
"pull",
|
|
"start",
|
|
"stop",
|
|
"restart",
|
|
"pause",
|
|
"unpause",
|
|
];
|
|
|
|
export type StackState =
|
|
"running" | "partial" | "stopped" | "paused" | "not-deployed";
|
|
|
|
export interface StackSummary {
|
|
name: string;
|
|
managed: boolean;
|
|
state: StackState;
|
|
running: number;
|
|
total: number;
|
|
composeFile: string | null;
|
|
}
|
|
|
|
export interface StackService {
|
|
service: string;
|
|
name: string;
|
|
id: string;
|
|
image: string;
|
|
state: string;
|
|
status: string;
|
|
health: string;
|
|
ports: string;
|
|
}
|
|
|
|
/** `compose ls` and `compose ps --format json` print one object per line or one array. */
|
|
export function parseJsonLines(output: string): Record<string, unknown>[] {
|
|
const text = output.trim();
|
|
if (!text) return [];
|
|
if (text.startsWith("[")) {
|
|
try {
|
|
return JSON.parse(text) as Record<string, unknown>[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
return text.split("\n").flatMap((line) => {
|
|
try {
|
|
return [JSON.parse(line) as Record<string, unknown>];
|
|
} catch {
|
|
return [];
|
|
}
|
|
});
|
|
}
|
|
|
|
const str = (value: unknown): string =>
|
|
typeof value === "string" ? value : "";
|
|
|
|
export function parseServices(output: string): StackService[] {
|
|
return parseJsonLines(output).map((row) => {
|
|
const publishers = Array.isArray(row.Publishers)
|
|
? (row.Publishers as Record<string, unknown>[])
|
|
: [];
|
|
const ports = publishers
|
|
.filter((p) => Number(p.PublishedPort) > 0)
|
|
.map((p) => `${p.PublishedPort}:${p.TargetPort}`)
|
|
.filter((value, index, all) => all.indexOf(value) === index)
|
|
.join(", ");
|
|
return {
|
|
service: str(row.Service),
|
|
name: str(row.Name),
|
|
id: str(row.ID),
|
|
image: str(row.Image),
|
|
state: str(row.State),
|
|
status: str(row.Status),
|
|
health: str(row.Health),
|
|
ports,
|
|
};
|
|
});
|
|
}
|
|
|
|
function stateOf(running: number, paused: number, total: number): StackState {
|
|
if (total === 0) return "not-deployed";
|
|
if (paused === total) return "paused";
|
|
if (running === total) return "running";
|
|
if (running > 0) return "partial";
|
|
return "stopped";
|
|
}
|
|
|
|
/** Turns the `compose ls` status text ("running(2), exited(1)") into a state. */
|
|
export function parseStackStatus(status: string): {
|
|
state: StackState;
|
|
running: number;
|
|
total: number;
|
|
} {
|
|
let running = 0;
|
|
let paused = 0;
|
|
let total = 0;
|
|
for (const part of status.split(",")) {
|
|
const match = part.trim().match(/^([a-z]+)\((\d+)\)$/i);
|
|
if (!match) continue;
|
|
const count = Number(match[2]);
|
|
total += count;
|
|
if (match[1] === "running") running += count;
|
|
if (match[1] === "paused") paused += count;
|
|
}
|
|
return { state: stateOf(running, paused, total), running, total };
|
|
}
|
|
|
|
export function stackStateFromServices(services: StackService[]) {
|
|
const total = services.length;
|
|
const running = services.filter((s) => s.state === "running").length;
|
|
const paused = services.filter((s) => s.state === "paused").length;
|
|
return { state: stateOf(running, paused, total), running, total };
|
|
}
|
|
|
|
/** Stacks from `compose ls -a --format json`, for hosts with no stacks folder. */
|
|
export function parseComposeProjects(output: string): StackSummary[] {
|
|
return parseJsonLines(output)
|
|
.flatMap((project) => {
|
|
const name = str(project.Name);
|
|
if (!STACK_NAME_RE.test(name)) return [];
|
|
return [
|
|
{
|
|
name,
|
|
managed: false,
|
|
...parseStackStatus(str(project.Status)),
|
|
composeFile: str(project.ConfigFiles).split(",")[0] || null,
|
|
},
|
|
];
|
|
})
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/** The compose commands one action runs, in order. */
|
|
export function composeArgsFor(
|
|
action: StackAction,
|
|
service: string | undefined,
|
|
options: { removeVolumes?: boolean },
|
|
): string[] {
|
|
const target = service ? ` ${service}` : "";
|
|
switch (action) {
|
|
case "deploy":
|
|
return [`up -d --remove-orphans${target}`];
|
|
case "redeploy":
|
|
return [`pull${target}`, `up -d --remove-orphans${target}`];
|
|
case "pull":
|
|
return [`pull${target}`];
|
|
case "start":
|
|
case "stop":
|
|
case "restart":
|
|
case "pause":
|
|
case "unpause":
|
|
return [`${action}${target}`];
|
|
case "down":
|
|
return ["down --remove-orphans"];
|
|
case "destroy":
|
|
return [`down --remove-orphans${options.removeVolumes ? " -v" : ""}`];
|
|
}
|
|
}
|