mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05: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>
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
/**
|
|
* Linux metrics: one awk sample of /proc for CPU, memory, load, network and
|
|
* processes, plus df parsing for disks.
|
|
*/
|
|
|
|
// One awk reads every /proc file itself, so a sample is one exec of awk instead
|
|
// of a shell plus six tools. Output sections are split by @@; each process line
|
|
// is pid, utime+stime ticks, rss pages, starttime, state, ppid, comm.
|
|
const AWK = String.raw`BEGIN{f="/proc/stat";while((getline l<f)>0)if(l~/^cpu/)print l;close(f);print "@@";f="/proc/meminfo";while((getline l<f)>0)if(l~/^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|SwapFree):/)print l;close(f);print "@@";f="/proc/loadavg";getline l<f;print l;close(f);f="/proc/uptime";getline l<f;print l;close(f);print "@@";f="/proc/net/dev";while((getline l<f)>0)print l;close(f);print "@@";print pg;print "@@";for(i=1;i<ARGC;i++){f=ARGV[i];if((getline s<f)>0){a=index(s,"(");if(a&&match(s,/\)[^)]*$/)){split(substr(s,RSTART+2),x," ");print substr(s,1,a-2)" "(x[12]+x[13])" "x[22]"\t"x[20]"\t"x[1]" "x[2]" "substr(s,a+1,RSTART-a-1)}}close(f)}}`;
|
|
|
|
/** The live sample command. The page size never changes on a host, so pass it back once known. */
|
|
export const liveCmd = (pageBytes?: number) =>
|
|
"awk -v pg=" +
|
|
(pageBytes ?? '"$(getconf PAGESIZE)"') +
|
|
" '" +
|
|
AWK +
|
|
"' /proc/[0-9]*/stat";
|
|
|
|
export interface LiveSample {
|
|
/** The admin minimum between samples; the client never polls faster. */
|
|
minIntervalS?: number;
|
|
cpu: number | null;
|
|
cpuMix?: { user: number; system: number; iowait: number; steal: number };
|
|
cores: Array<number | null>;
|
|
coreIds: string[];
|
|
memTotalKb: number;
|
|
memCachedKb: number;
|
|
memFreeKb: number;
|
|
memUsedKb: number;
|
|
swapTotalKb: number;
|
|
swapUsedKb: number;
|
|
load: [number, number, number];
|
|
net: Array<{ name: string; rxBps: number | null; txBps: number | null }>;
|
|
count: number;
|
|
running: number;
|
|
procs: Array<{
|
|
pid: number;
|
|
name: string;
|
|
cpu: number;
|
|
memKb: number;
|
|
state: string;
|
|
ppid: number;
|
|
}>;
|
|
/** Seconds since boot. */
|
|
uptime?: number;
|
|
/** Total bytes received and sent on every interface but lo, since boot. */
|
|
netTotals?: { rx: number; tx: number };
|
|
}
|
|
|
|
/** What the next call needs from this one. */
|
|
export interface LivePrev {
|
|
at: number;
|
|
uptime: number;
|
|
pageBytes: number;
|
|
cpu: Map<string, { total: number; idle: number }>;
|
|
/** Aggregate user nice system idle iowait irq softirq steal ticks, for the breakdown. */
|
|
cpuRaw?: number[];
|
|
/** Keyed by pid:starttime so a reused pid never inherits another process's ticks. */
|
|
ticks: Map<string, number>;
|
|
net: Map<string, { rx: number; tx: number }>;
|
|
}
|
|
|
|
/** Total and idle ticks from a /proc/stat `cpu` line. */
|
|
export function parseCpuLine(
|
|
cpuLine: string,
|
|
): { total: number; idle: number } | undefined {
|
|
const parts = cpuLine.trim().split(/\s+/);
|
|
if (parts[0] !== "cpu") return undefined;
|
|
const nums = parts
|
|
.slice(1)
|
|
.map((n) => Number(n))
|
|
.filter((n) => Number.isFinite(n));
|
|
if (nums.length < 4) return undefined;
|
|
const idle = (nums[3] ?? 0) + (nums[4] ?? 0);
|
|
const total = nums.reduce((a, b) => a + b, 0);
|
|
return { total, idle };
|
|
}
|
|
|
|
const TOP_N = 60;
|
|
|
|
/** Parses `liveCmd()` output. Rates need the `next` of the previous call. */
|
|
export function parseLive(
|
|
output: string,
|
|
prev: LivePrev | undefined,
|
|
at = Date.now(),
|
|
): { sample: LiveSample; next: LivePrev } {
|
|
const [cpuS = "", memS = "", loadS = "", netS = "", pageS = "", procS = ""] =
|
|
output.split("@@\n");
|
|
|
|
// Only the first 8 fields: guest and guest_nice are already inside user and nice.
|
|
const cpu = new Map<string, { total: number; idle: number }>();
|
|
let cpuRaw: number[] | undefined;
|
|
for (const l of cpuS.split("\n")) {
|
|
const name = l.split(/\s+/)[0];
|
|
if (!name.startsWith("cpu")) continue;
|
|
if (name === "cpu") cpuRaw = l.split(/\s+/).slice(1, 9).map(Number);
|
|
const c = parseCpuLine(
|
|
l
|
|
.replace(/^cpu\d*/, "cpu")
|
|
.split(/\s+/)
|
|
.slice(0, 9)
|
|
.join(" "),
|
|
);
|
|
if (c) cpu.set(name, c);
|
|
}
|
|
// Matched by name, so an offline core does not shift the ones after it.
|
|
const pct = (name: string) => {
|
|
const p = prev?.cpu.get(name);
|
|
const c = cpu.get(name);
|
|
if (!p || !c || c.total <= p.total) return null;
|
|
const used = c.total - p.total - (c.idle - p.idle);
|
|
return Math.max(0, Math.min(100, (used / (c.total - p.total)) * 100));
|
|
};
|
|
|
|
// Where the busy time went since the last sample, as percent of all ticks.
|
|
let cpuMix: LiveSample["cpuMix"];
|
|
if (prev?.cpuRaw && cpuRaw && cpuRaw.length === 8) {
|
|
const d = cpuRaw.map((v, i) => v - (prev.cpuRaw?.[i] ?? 0));
|
|
const total = d.reduce((a, b) => a + b, 0);
|
|
if (total > 0 && d.every((v) => v >= 0)) {
|
|
const p = (n: number) => (n / total) * 100;
|
|
cpuMix = {
|
|
user: p(d[0] + d[1]),
|
|
system: p(d[2] + d[5] + d[6]),
|
|
iowait: p(d[4]),
|
|
steal: p(d[7]),
|
|
};
|
|
}
|
|
}
|
|
|
|
const mem: Record<string, number> = {};
|
|
for (const l of memS.split("\n")) {
|
|
const m = l.match(/^(\w+):\s+(\d+)/);
|
|
if (m) mem[m[1]] = Number(m[2]);
|
|
}
|
|
|
|
const [la = "", up = ""] = loadS.trim().split("\n");
|
|
const laParts = la.split(/\s+/).map(Number);
|
|
const uptime = Number(up.split(/\s+/)[0]) || 0;
|
|
|
|
const nets = new Map<string, { rx: number; tx: number }>();
|
|
for (const l of netS.split("\n")) {
|
|
const [name, rest] = l.split(":");
|
|
if (!rest || name.trim() === "lo") continue;
|
|
const f = rest.trim().split(/\s+/);
|
|
nets.set(name.trim(), { rx: Number(f[0]) || 0, tx: Number(f[8]) || 0 });
|
|
}
|
|
// A reboot makes uptime go backwards, so rates start again.
|
|
const dt = prev && uptime > prev.uptime ? uptime - prev.uptime : 0;
|
|
const rate = (now: number, before: number | undefined) =>
|
|
dt > 0 && before !== undefined && now >= before
|
|
? (now - before) / dt
|
|
: null;
|
|
|
|
const pageBytes = Number(pageS.trim()) || prev?.pageBytes || 4096;
|
|
const pageKb = pageBytes / 1024;
|
|
const aggDelta =
|
|
prev && cpu.has("cpu") && prev.cpu.has("cpu")
|
|
? cpu.get("cpu")!.total - prev.cpu.get("cpu")!.total
|
|
: 0;
|
|
const cores = [...cpu.keys()].filter((k) => k !== "cpu");
|
|
const ncpu = Math.max(1, cores.length);
|
|
const ticks = new Map<string, number>();
|
|
const procs: LiveSample["procs"] = [];
|
|
for (const l of procS.split("\n")) {
|
|
const [pid, t, rss, start, state, ppid, ...rest] = l.split("\t");
|
|
const name = rest.join("\t");
|
|
if (!name) continue;
|
|
const key = pid + ":" + start;
|
|
const tk = Number(t) || 0;
|
|
ticks.set(key, tk);
|
|
const before = prev?.ticks.get(key);
|
|
procs.push({
|
|
pid: Number(pid),
|
|
name,
|
|
cpu:
|
|
before !== undefined && aggDelta > 0 && tk >= before
|
|
? ((tk - before) / aggDelta) * ncpu * 100
|
|
: 0,
|
|
memKb: (Number(rss) || 0) * pageKb,
|
|
state: state || "?",
|
|
ppid: Number(ppid) || 0,
|
|
});
|
|
}
|
|
procs.sort((a, b) => b.cpu - a.cpu || b.memKb - a.memKb);
|
|
|
|
// Awk exits 0 when /proc is missing (macOS, restricted containers); that is not a sample.
|
|
if (!cpu.has("cpu") || !mem.MemTotal || !uptime) {
|
|
throw new Error("live sample unavailable: /proc data missing");
|
|
}
|
|
const memTotalKb = mem.MemTotal;
|
|
const swapTotalKb = mem.SwapTotal ?? 0;
|
|
let rxTotal = 0;
|
|
let txTotal = 0;
|
|
for (const c of nets.values()) {
|
|
rxTotal += c.rx;
|
|
txTotal += c.tx;
|
|
}
|
|
return {
|
|
sample: {
|
|
cpu: pct("cpu"),
|
|
cores: cores.map(pct),
|
|
coreIds: cores.map((k) => k.slice(3)),
|
|
cpuMix,
|
|
memTotalKb,
|
|
memCachedKb: (mem.Buffers ?? 0) + (mem.Cached ?? 0),
|
|
memFreeKb: mem.MemFree ?? 0,
|
|
memUsedKb: memTotalKb - (mem.MemAvailable ?? 0),
|
|
swapTotalKb,
|
|
swapUsedKb: swapTotalKb - (mem.SwapFree ?? 0),
|
|
load: [laParts[0] || 0, laParts[1] || 0, laParts[2] || 0],
|
|
net: [...nets].map(([name, c]) => ({
|
|
name,
|
|
rxBps: rate(c.rx, prev?.net.get(name)?.rx),
|
|
txBps: rate(c.tx, prev?.net.get(name)?.tx),
|
|
})),
|
|
count: procs.length,
|
|
running: procs.filter((p) => p.state === "R").length,
|
|
procs: procs.slice(0, TOP_N),
|
|
uptime,
|
|
netTotals: { rx: rxTotal, tx: txTotal },
|
|
},
|
|
next: { at, uptime, pageBytes, cpu, cpuRaw, ticks, net: nets },
|
|
};
|
|
}
|
|
|
|
// Disks
|
|
|
|
const DARWIN_DATA_MOUNT = "/System/Volumes/Data";
|
|
const PSEUDO_FS_RE =
|
|
/^(tmpfs|devtmpfs|overlay|udev|none|shm|squashfs|efivarfs)$/;
|
|
|
|
/** GNU df sizes in KiB, without the header. */
|
|
export const DF_BYTES_COMMAND = "df -TkP | tail -n +2";
|
|
/** GNU df sizes as short human text, without the header. */
|
|
export const DF_HUMAN_COMMAND = "df -hT -P | tail -n +2";
|
|
|
|
export interface DfRow {
|
|
filesystem: string;
|
|
type: string;
|
|
mount: string;
|
|
parts: string[];
|
|
}
|
|
|
|
export interface DiskFilesystem {
|
|
filesystem: string;
|
|
type: string;
|
|
mount: string;
|
|
percent: number | null;
|
|
usedHuman: string | null;
|
|
totalHuman: string | null;
|
|
availableHuman: string | null;
|
|
usedBytes: number | null;
|
|
totalBytes: number | null;
|
|
availableBytes: number | null;
|
|
label?: string;
|
|
}
|
|
|
|
export interface MonitoredMount {
|
|
path: string;
|
|
label?: string;
|
|
}
|
|
|
|
function toFixedNum(n: number | null | undefined, digits = 2): number | null {
|
|
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
|
return Number(n.toFixed(digits));
|
|
}
|
|
|
|
/**
|
|
* Parses `df -T -P` style output: Filesystem, Type, then the size columns,
|
|
* with Mounted-on last. Pseudo filesystems are left out.
|
|
*/
|
|
export function parseDfLines(output: string): DfRow[] {
|
|
return output
|
|
.split("\n")
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
.map((line) => {
|
|
const parts = line.split(/\s+/);
|
|
return {
|
|
filesystem: parts[0] || "",
|
|
type: parts[1] || "",
|
|
mount: parts[6] || "",
|
|
parts,
|
|
};
|
|
})
|
|
.filter((row) => row.parts.length >= 7 && !PSEUDO_FS_RE.test(row.type));
|
|
}
|
|
|
|
/** The most used real filesystem in a `df -TkP` row set. */
|
|
export function findWorstMountIndex(bytesRows: DfRow[]): {
|
|
index: number;
|
|
usedBytes: number;
|
|
totalBytes: number;
|
|
} {
|
|
let worstIndex = -1;
|
|
let worstUsedBytes = -1;
|
|
let worstTotalBytes = 0;
|
|
bytesRows.forEach((row, index) => {
|
|
const totalBytes = Number(row.parts[2]);
|
|
const usedBytes = Number(row.parts[3]);
|
|
if (
|
|
!Number.isFinite(totalBytes) ||
|
|
!Number.isFinite(usedBytes) ||
|
|
totalBytes <= 0
|
|
) {
|
|
return;
|
|
}
|
|
const usedRatio = usedBytes / totalBytes;
|
|
const worstRatio =
|
|
worstTotalBytes > 0 ? worstUsedBytes / worstTotalBytes : -1;
|
|
if (usedRatio > worstRatio) {
|
|
worstIndex = index;
|
|
worstUsedBytes = usedBytes;
|
|
worstTotalBytes = totalBytes;
|
|
}
|
|
});
|
|
return {
|
|
index: worstIndex,
|
|
usedBytes: worstUsedBytes,
|
|
totalBytes: worstTotalBytes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Merges `df -TkP` size rows (parts[2..4] in `blockSizeBytes` units) and
|
|
* `df -hT -P` human rows into one list. Human rows only supply the display
|
|
* text, matched by mount point so a mismatched row count can't shift them.
|
|
*/
|
|
export function buildFilesystemList(
|
|
bytesRows: DfRow[],
|
|
humanRows: DfRow[],
|
|
blockSizeBytes = 1,
|
|
): DiskFilesystem[] {
|
|
const aligned = humanRows.length === bytesRows.length;
|
|
return bytesRows
|
|
.map((row, index) => {
|
|
const totalBytes = Number(row.parts[2]) * blockSizeBytes;
|
|
const usedBytes = Number(row.parts[3]) * blockSizeBytes;
|
|
const availableBytes = Number(row.parts[4]) * blockSizeBytes;
|
|
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null;
|
|
const humanRow = aligned
|
|
? humanRows[index]
|
|
: humanRows.find((h) => h.mount === row.mount);
|
|
const percent = Number.isFinite(usedBytes)
|
|
? Math.max(0, Math.min(100, (usedBytes / totalBytes) * 100))
|
|
: null;
|
|
return {
|
|
filesystem: row.filesystem,
|
|
type: row.type,
|
|
mount: row.mount,
|
|
percent: toFixedNum(percent, 0),
|
|
usedHuman: humanRow?.parts[3] || null,
|
|
totalHuman: humanRow?.parts[2] || null,
|
|
availableHuman: humanRow?.parts[4] || null,
|
|
usedBytes: Number.isFinite(usedBytes) ? usedBytes : null,
|
|
totalBytes,
|
|
availableBytes: Number.isFinite(availableBytes) ? availableBytes : null,
|
|
};
|
|
})
|
|
.filter((fs): fs is DiskFilesystem => fs !== null);
|
|
}
|
|
|
|
/**
|
|
* The headline disk is the root filesystem. On macOS "/" is the small sealed
|
|
* system volume, so the data volume wins there. With no root mount the most
|
|
* used one is shown.
|
|
*/
|
|
export function selectPrimaryFilesystem(
|
|
filesystems: DiskFilesystem[],
|
|
): DiskFilesystem | null {
|
|
if (filesystems.length === 0) return null;
|
|
const root =
|
|
filesystems.find((fs) => fs.mount === DARWIN_DATA_MOUNT) ??
|
|
filesystems.find((fs) => fs.mount === "/");
|
|
if (root) return root;
|
|
let best = filesystems[0];
|
|
for (const fs of filesystems) {
|
|
const ratio = (fs.usedBytes ?? 0) / (fs.totalBytes || 1);
|
|
const bestRatio = (best.usedBytes ?? 0) / (best.totalBytes || 1);
|
|
if (ratio > bestRatio) best = fs;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/**
|
|
* Drops mounts by exact path ("/mnt/nas") or by filesystem type substring
|
|
* ("nfs" matches nfs and nfs4), so network shares can be hidden.
|
|
*/
|
|
export function filterExcludedFilesystems(
|
|
filesystems: DiskFilesystem[],
|
|
excludedMounts?: string[] | null,
|
|
): DiskFilesystem[] {
|
|
if (!excludedMounts || excludedMounts.length === 0) return filesystems;
|
|
const normalized = excludedMounts
|
|
.map((entry) => entry.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
if (normalized.length === 0) return filesystems;
|
|
return filesystems.filter((fs) => {
|
|
const mount = fs.mount.toLowerCase();
|
|
const type = fs.type.toLowerCase();
|
|
return !normalized.some(
|
|
(entry) => mount === entry || (type && type.includes(entry)),
|
|
);
|
|
});
|
|
}
|
|
|
|
export function mergeMonitoredFilesystems(
|
|
filesystems: DiskFilesystem[],
|
|
monitored: MonitoredMount[],
|
|
customFilesystems: Array<DiskFilesystem | null>,
|
|
): DiskFilesystem[] {
|
|
const result = [...filesystems];
|
|
monitored.forEach((entry, index) => {
|
|
const path = entry.path.trim();
|
|
const custom = customFilesystems[index];
|
|
if (!path || !custom) return;
|
|
const existing = result.find((fs) => fs.mount === path);
|
|
if (existing) {
|
|
existing.label = entry.label?.trim() || undefined;
|
|
return;
|
|
}
|
|
result.push({
|
|
...custom,
|
|
mount: path,
|
|
label: entry.label?.trim() || undefined,
|
|
});
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/** Host name, OS, kernel and arch in one exec, as key=value lines. */
|
|
export const SYSTEM_INFO_COMMAND = `echo "hostname=$(hostname 2>/dev/null || cat /etc/hostname 2>/dev/null)"; echo "os=$( . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME")"; echo "kernel=$(uname -r 2>/dev/null)"; echo "arch=$(uname -m 2>/dev/null)"`;
|