merge: 启用 Webterm 会话持久化

This commit is contained in:
pikastech
2026-07-21 15:12:43 +02:00
2 changed files with 82 additions and 6 deletions
+21 -5
View File
@@ -60,6 +60,14 @@ browserPerformance:
dashboardSampleWindowSeconds: 60 dashboardSampleWindowSeconds: 60
dashboardRefreshIntervalMs: 1000 dashboardRefreshIntervalMs: 1000
runtimeDatabase:
sourceRef: /root/.unidesk/.state/docker-compose.env
userKey: UNIDESK_DATABASE_USER
passwordKey: UNIDESK_DATABASE_PASSWORD
databaseKey: UNIDESK_DATABASE_NAME
host: host.docker.internal
port: 15432
defaults: defaults:
targetId: local-7683 targetId: local-7683
@@ -75,6 +83,7 @@ targets:
containerName: web-terminal containerName: web-terminal
image: webterm-web-terminal-7681:latest image: webterm-web-terminal-7681:latest
envFile: /root/webterm/.env envFile: /root/webterm/.env
runtimeDataDomain: webterm-7681
hostPort: 7681 hostPort: 7681
containerPort: 7681 containerPort: 7681
privileged: true privileged: true
@@ -93,7 +102,8 @@ targets:
target: /root/user_uploads target: /root/user_uploads
readOnly: false readOnly: false
autoStartSessions: autoStartSessions:
- title: TERM - id: term
title: TERM
cwd: /root/unidesk cwd: /root/unidesk
command: mycx resume 019f4eeb-bbb3-7e32-bb98-d10753561516 command: mycx resume 019f4eeb-bbb3-7e32-bb98-d10753561516
cols: 100 cols: 100
@@ -154,6 +164,7 @@ targets:
containerName: web-terminal-7682 containerName: web-terminal-7682
image: webterm-web-terminal-7682:latest image: webterm-web-terminal-7682:latest
envFile: /root/webterm/.env envFile: /root/webterm/.env
runtimeDataDomain: webterm-7682
hostPort: 7682 hostPort: 7682
containerPort: 7681 containerPort: 7681
privileged: true privileged: true
@@ -172,7 +183,8 @@ targets:
target: /root/user_uploads target: /root/user_uploads
readOnly: false readOnly: false
autoStartSessions: autoStartSessions:
- title: TERM - id: term
title: TERM
cwd: /root/unidesk cwd: /root/unidesk
command: mycx resume 019f4eeb-bbb3-7e32-bb98-d10753561516 command: mycx resume 019f4eeb-bbb3-7e32-bb98-d10753561516
cols: 100 cols: 100
@@ -227,6 +239,7 @@ targets:
containerName: web-terminal-7683 containerName: web-terminal-7683
image: webterm-web-terminal-7682:latest image: webterm-web-terminal-7682:latest
envFile: /root/webterm/.env envFile: /root/webterm/.env
runtimeDataDomain: webterm-7683
hostPort: 7683 hostPort: 7683
containerPort: 7681 containerPort: 7681
privileged: true privileged: true
@@ -254,21 +267,24 @@ targets:
uploadBytes: 8388608 uploadBytes: 8388608
maxDurationMs: 10000 maxDurationMs: 10000
autoStartSessions: autoStartSessions:
- title: 决策中心 - id: decision-center
title: 决策中心
cwd: /root/unidesk cwd: /root/unidesk
command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c command: mycx resume 019f4937-9820-7b31-9178-344ae93d5a4c
cols: 100 cols: 100
rows: 30 rows: 30
resumePrompt: true resumePrompt: true
fallbackShell: true fallbackShell: true
- title: CONSTAR - id: constar
title: CONSTAR
cwd: /root/unidesk cwd: /root/unidesk
command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d command: mycx resume 019f4b5f-b5ce-76b3-8ccb-508790e9662d
cols: 100 cols: 100
rows: 30 rows: 30
resumePrompt: true resumePrompt: true
fallbackShell: true fallbackShell: true
- title: WORKBENCH - id: workbench
title: WORKBENCH
cwd: /root/unidesk cwd: /root/unidesk
command: mycx resume 019f4a1a-fd46-7662-8dc2-bf628210739d command: mycx resume 019f4a1a-fd46-7662-8dc2-bf628210739d
cols: 100 cols: 100
+61 -1
View File
@@ -21,6 +21,7 @@ const serviceName = "webterm";
interface WebtermConfig { interface WebtermConfig {
defaults: { targetId: string }; defaults: { targetId: string };
runtimeDatabase: RuntimeDatabase;
sessionStartModes: SessionStartModes; sessionStartModes: SessionStartModes;
autoResumePrompt: AutoResumePrompt; autoResumePrompt: AutoResumePrompt;
browserTerminalHistory: BrowserTerminalHistory; browserTerminalHistory: BrowserTerminalHistory;
@@ -29,6 +30,15 @@ interface WebtermConfig {
targets: WebtermTarget[]; targets: WebtermTarget[];
} }
interface RuntimeDatabase {
sourceRef: string;
userKey: string;
passwordKey: string;
databaseKey: string;
host: string;
port: number;
}
interface SessionStartModes { interface SessionStartModes {
defaultMode: string; defaultMode: string;
options: Array<{ id: string; label: string; command: string }>; options: Array<{ id: string; label: string; command: string }>;
@@ -75,13 +85,15 @@ interface WebtermTarget {
containerName: string; containerName: string;
image: string; image: string;
envFile: string; envFile: string;
runtimeDataDomain: string;
runtimeDatabase: RuntimeDatabase;
hostPort: number; hostPort: number;
containerPort: number; containerPort: number;
privileged: boolean; privileged: boolean;
pid: "host" | "container"; pid: "host" | "container";
sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>;
networkSpeedTest: { enabled: boolean; containerName: string; hostPort: number; containerPort: number; publicUrl: string; downloadBytes: number; uploadBytes: number; maxDurationMs: number } | null; networkSpeedTest: { enabled: boolean; containerName: string; hostPort: number; containerPort: number; publicUrl: string; downloadBytes: number; uploadBytes: number; maxDurationMs: number } | null;
autoStartSessions: Array<{ title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean; fallbackShell: boolean }>; autoStartSessions: Array<{ id: string; title: string; cwd: string; command: string; cols: number; rows: number; resumePrompt: boolean; fallbackShell: boolean }>;
sessionStartModes: SessionStartModes; sessionStartModes: SessionStartModes;
autoResumePrompt: AutoResumePrompt; autoResumePrompt: AutoResumePrompt;
browserTerminalHistory: BrowserTerminalHistory; browserTerminalHistory: BrowserTerminalHistory;
@@ -216,6 +228,7 @@ async function apply(config: UniDeskConfig, targetId: string | null, confirm: bo
const composeResult = spawnSync("docker", ["compose", "-f", target.runtime.composePath, "-p", target.runtime.composeProject, "up", "-d", "--force-recreate"], { const composeResult = spawnSync("docker", ["compose", "-f", target.runtime.composePath, "-p", target.runtime.composeProject, "up", "-d", "--force-recreate"], {
cwd: target.runtime.workDir, cwd: target.runtime.workDir,
encoding: "utf8", encoding: "utf8",
env: { ...process.env, ...runtimeDatabaseEnvironment(target.runtime.runtimeDatabase) },
}); });
const localProbe = providerHealthProbeWithRetry(target.runtime.hostPort, 10, 500); const localProbe = providerHealthProbeWithRetry(target.runtime.hostPort, 10, 500);
const caddy = target.publicExposure.enabled && target.publicExposure.pk01 !== null const caddy = target.publicExposure.enabled && target.publicExposure.pk01 !== null
@@ -318,10 +331,12 @@ function readWebtermConfig(): WebtermConfig {
const browserTerminalHistory = parseBrowserTerminalHistory(recordField(yaml, "browserTerminalHistory", "webterm"), "webterm.browserTerminalHistory"); const browserTerminalHistory = parseBrowserTerminalHistory(recordField(yaml, "browserTerminalHistory", "webterm"), "webterm.browserTerminalHistory");
const browserOutputPolicy = parseBrowserOutputPolicy(recordField(yaml, "browserOutputPolicy", "webterm"), "webterm.browserOutputPolicy"); const browserOutputPolicy = parseBrowserOutputPolicy(recordField(yaml, "browserOutputPolicy", "webterm"), "webterm.browserOutputPolicy");
const browserPerformance = parseBrowserPerformance(recordField(yaml, "browserPerformance", "webterm"), "webterm.browserPerformance"); const browserPerformance = parseBrowserPerformance(recordField(yaml, "browserPerformance", "webterm"), "webterm.browserPerformance");
const runtimeDatabase = parseRuntimeDatabase(recordField(yaml, "runtimeDatabase", "webterm"), "webterm.runtimeDatabase");
const targetsRaw = yaml.targets; const targetsRaw = yaml.targets;
if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array");
return { return {
defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") },
runtimeDatabase,
sessionStartModes, sessionStartModes,
autoResumePrompt, autoResumePrompt,
browserTerminalHistory, browserTerminalHistory,
@@ -335,6 +350,7 @@ function readWebtermConfig(): WebtermConfig {
browserTerminalHistory, browserTerminalHistory,
browserOutputPolicy, browserOutputPolicy,
browserPerformance, browserPerformance,
runtimeDatabase,
)), )),
}; };
} }
@@ -347,6 +363,7 @@ function parseTarget(
browserTerminalHistory: BrowserTerminalHistory, browserTerminalHistory: BrowserTerminalHistory,
browserOutputPolicy: BrowserOutputPolicy, browserOutputPolicy: BrowserOutputPolicy,
browserPerformance: BrowserPerformance, browserPerformance: BrowserPerformance,
runtimeDatabase: RuntimeDatabase,
): WebtermTarget { ): WebtermTarget {
const runtime = recordField(record, "runtime", path); const runtime = recordField(record, "runtime", path);
const exposure = recordField(record, "publicExposure", path); const exposure = recordField(record, "publicExposure", path);
@@ -385,6 +402,8 @@ function parseTarget(
containerName: stringField(runtime, "containerName", `${path}.runtime`), containerName: stringField(runtime, "containerName", `${path}.runtime`),
image: stringField(runtime, "image", `${path}.runtime`), image: stringField(runtime, "image", `${path}.runtime`),
envFile: stringField(runtime, "envFile", `${path}.runtime`), envFile: stringField(runtime, "envFile", `${path}.runtime`),
runtimeDataDomain: stringField(runtime, "runtimeDataDomain", `${path}.runtime`),
runtimeDatabase,
hostPort: portField(runtime, "hostPort", `${path}.runtime`), hostPort: portField(runtime, "hostPort", `${path}.runtime`),
containerPort: portField(runtime, "containerPort", `${path}.runtime`), containerPort: portField(runtime, "containerPort", `${path}.runtime`),
privileged: booleanField(runtime, "privileged", `${path}.runtime`), privileged: booleanField(runtime, "privileged", `${path}.runtime`),
@@ -456,6 +475,8 @@ function resolveTarget(targetId: string | null): WebtermTarget {
function renderCompose(target: WebtermTarget): string { function renderCompose(target: WebtermTarget): string {
const environment = { const environment = {
...target.runtime.environment, ...target.runtime.environment,
WEBTERM_DATA_DOMAIN: target.runtime.runtimeDataDomain,
WEBTERM_DATABASE_URL: "${WEBTERM_DATABASE_URL}",
AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions.map((session) => ({ AUTO_START_SESSIONS_JSON: JSON.stringify(target.runtime.autoStartSessions.map((session) => ({
...session, ...session,
command: resumeCommandWithPrompt(session, target.runtime.autoResumePrompt), command: resumeCommandWithPrompt(session, target.runtime.autoResumePrompt),
@@ -505,6 +526,8 @@ services:
- "${target.runtime.hostPort}:${target.runtime.containerPort}" - "${target.runtime.hostPort}:${target.runtime.containerPort}"
privileged: ${target.runtime.privileged ? "true" : "false"} privileged: ${target.runtime.privileged ? "true" : "false"}
pid: ${target.runtime.pid} pid: ${target.runtime.pid}
extra_hosts:
- "host.docker.internal:host-gateway"
env_file: env_file:
- ${target.runtime.envFile} - ${target.runtime.envFile}
volumes: volumes:
@@ -590,6 +613,12 @@ function targetSummary(target: WebtermTarget): Record<string, unknown> {
composeProject: target.runtime.composeProject, composeProject: target.runtime.composeProject,
containerName: target.runtime.containerName, containerName: target.runtime.containerName,
image: target.runtime.image, image: target.runtime.image,
runtimeDataDomain: target.runtime.runtimeDataDomain,
runtimeDatabase: {
sourceRef: target.runtime.runtimeDatabase.sourceRef,
keys: [target.runtime.runtimeDatabase.userKey, target.runtime.runtimeDatabase.passwordKey, target.runtime.runtimeDatabase.databaseKey],
valuesPrinted: false,
},
autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title), autoStartSessions: target.runtime.autoStartSessions.map((session) => session.title),
sessionStartModes: { sessionStartModes: {
defaultMode: target.runtime.sessionStartModes.defaultMode, defaultMode: target.runtime.sessionStartModes.defaultMode,
@@ -745,6 +774,7 @@ function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["ru
const itemPath = `${path}[${index}]`; const itemPath = `${path}[${index}]`;
const record = asRecord(item, itemPath); const record = asRecord(item, itemPath);
return { return {
id: stringField(record, "id", itemPath),
title: stringField(record, "title", itemPath), title: stringField(record, "title", itemPath),
cwd: stringField(record, "cwd", itemPath), cwd: stringField(record, "cwd", itemPath),
command: stringField(record, "command", itemPath), command: stringField(record, "command", itemPath),
@@ -756,6 +786,36 @@ function parseAutoStartSessions(value: unknown, path: string): WebtermTarget["ru
}); });
} }
function parseRuntimeDatabase(record: Record<string, unknown>, path: string): RuntimeDatabase {
return {
sourceRef: stringField(record, "sourceRef", path),
userKey: stringField(record, "userKey", path),
passwordKey: stringField(record, "passwordKey", path),
databaseKey: stringField(record, "databaseKey", path),
host: stringField(record, "host", path),
port: portField(record, "port", path),
};
}
function runtimeDatabaseEnvironment(config: RuntimeDatabase): Record<string, string> {
const values: Record<string, string> = {};
for (const line of readFileSync(config.sourceRef, "utf8").split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const separator = trimmed.indexOf("=");
if (separator <= 0) continue;
values[trimmed.slice(0, separator)] = trimmed.slice(separator + 1).replace(/^['"]|['"]$/gu, "");
}
const required = (key: string): string => {
const value = values[key];
if (!value) throw new Error(`missing ${key} in ${config.sourceRef}`);
return value;
};
return {
WEBTERM_DATABASE_URL: `postgres://${encodeURIComponent(required(config.userKey))}:${encodeURIComponent(required(config.passwordKey))}@${config.host}:${config.port}/${encodeURIComponent(required(config.databaseKey))}`,
};
}
function parseSessionStartModes(record: Record<string, unknown>, path: string): SessionStartModes { function parseSessionStartModes(record: Record<string, unknown>, path: string): SessionStartModes {
const defaultMode = stringField(record, "defaultMode", path); const defaultMode = stringField(record, "defaultMode", path);
const optionsRecord = recordField(record, "options", path); const optionsRecord = recordField(record, "options", path);