mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 09:24:09 +00:00
f253276303
* fix: make published port links open reliably Container published-port links now render as real anchors that open on desktop and mobile, replacing ad hoc window.open calls. A shared service URL builder centralizes host resolution (configured host, remote node API host, or the browser host, with no browser fallback for unreachable remote nodes), protocol selection (HTTPS for port 443), and known app sub-paths (Plex opens its web path). The container port mapping itself is the link, with a Copy URL action beside it. The stack Open App menu and the anatomy panel footer use the same builder, and the menu only offers Open App when a reachable URL can be built. * fix: skip UDP ports and scope known-app paths to the container port Two follow-ups to the published-port links: - The known-app path (Plex web sub-path) was borrowed from the published host port even when the container port was known and unregistered, so a non-Plex service published on host port 32400 wrongly inherited it. The container-port lookup now wins when known; the published-port lookup stays a fallback for the menu and anatomy footer, which only have the host port. - UDP ports could surface as HTTP links. The backend now carries the port protocol through both container-mapping paths and skips UDP when choosing the main web port (extracted as selectMainWebPort), and the container card filters UDP before selecting a port to link.
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
/**
|
|
* Deterministic Markdown export for the Stack Anatomy panel.
|
|
*
|
|
* Turns the already-parsed compose anatomy into a clean Markdown document so an
|
|
* operator can paste a stack summary into Git, Obsidian, BookStack, a README, or
|
|
* a support thread. The builder is pure and side-effect free: the same input
|
|
* always yields byte-identical output.
|
|
*
|
|
* It only ever receives env variable names and a count, never `.env` values, so
|
|
* no secret can leak into the exported text.
|
|
*/
|
|
|
|
export interface PortRow {
|
|
host: string;
|
|
container: string;
|
|
proto: string;
|
|
/**
|
|
* True when the mapping publishes a host port (short `host:container` form or
|
|
* long syntax with `published:`/`target:`), false for container-only short
|
|
* syntax (a single value with no host port).
|
|
*/
|
|
published?: boolean;
|
|
}
|
|
|
|
export interface VolumeRow {
|
|
host: string;
|
|
container: string;
|
|
}
|
|
|
|
export interface AnatomyMarkdownInput {
|
|
stackName: string;
|
|
services: string[];
|
|
ports: Record<string, PortRow[]>;
|
|
volumes: Record<string, VolumeRow[]>;
|
|
restart: string | null;
|
|
/** Selected env file path, or null when the stack declares none. */
|
|
envFile: string | null;
|
|
envVarCount: number;
|
|
/** Referenced `${VAR}` names with no entry in the env file. */
|
|
missingVars: string[];
|
|
networkName: string;
|
|
/** Formatted Git label (typically `host/repo#branch`) when Git-linked, else null (local). */
|
|
gitSource: string | null;
|
|
}
|
|
|
|
const code = (s: string): string => `\`${s}\``;
|
|
|
|
// Escape the backslash first (so it cannot defeat the pipe escaping), then pipes
|
|
// that would break a table row, then collapse line breaks (CRLF, LF, or a lone CR)
|
|
// onto the same line.
|
|
function escapeCell(value: string): string {
|
|
return value
|
|
.replace(/\\/g, '\\\\')
|
|
.replace(/\|/g, '\\|')
|
|
.replace(/\r\n?|\n/g, ' ');
|
|
}
|
|
|
|
function servicesSection(services: string[]): string {
|
|
if (services.length === 0) return '## Services\n_none defined_';
|
|
return `## Services\n${services.map(s => `- ${code(s)}`).join('\n')}`;
|
|
}
|
|
|
|
function portsSection(ports: Record<string, PortRow[]>): string {
|
|
const rows = Object.entries(ports).flatMap(([svc, list]) =>
|
|
list.map(r => `| ${escapeCell(svc)} | ${escapeCell(r.host)} | ${escapeCell(r.container)} | ${escapeCell(r.proto)} |`),
|
|
);
|
|
if (rows.length === 0) return '## Ports\n_none_';
|
|
return ['## Ports', '| Service | Host | Container | Protocol |', '| --- | --- | --- | --- |', ...rows].join('\n');
|
|
}
|
|
|
|
function volumesSection(volumes: Record<string, VolumeRow[]>): string {
|
|
const rows = Object.entries(volumes).flatMap(([svc, list]) =>
|
|
list.map(r => `| ${escapeCell(svc)} | ${escapeCell(r.host)} | ${escapeCell(r.container)} |`),
|
|
);
|
|
if (rows.length === 0) return '## Volumes\n_none_';
|
|
return ['## Volumes', '| Service | Host | Container |', '| --- | --- | --- |', ...rows].join('\n');
|
|
}
|
|
|
|
function environmentSection(envFile: string | null, envVarCount: number, missingVars: string[]): string {
|
|
if (!envFile) return '## Environment\n- File: _none_';
|
|
const lines = [`- File: ${code(envFile)}`, `- Variables: ${envVarCount}`];
|
|
if (missingVars.length > 0) {
|
|
lines.push(`- Missing: ${missingVars.map(code).join(', ')}`);
|
|
}
|
|
return `## Environment\n${lines.join('\n')}`;
|
|
}
|
|
|
|
export function buildStackAnatomyMarkdown(input: AnatomyMarkdownInput): string {
|
|
const restart = input.restart ? code(input.restart) : '_default_';
|
|
const source = input.gitSource ? `git · ${input.gitSource}` : 'local';
|
|
|
|
return [
|
|
`# ${input.stackName}`,
|
|
servicesSection(input.services),
|
|
portsSection(input.ports),
|
|
volumesSection(input.volumes),
|
|
`## Restart policy\n${restart}`,
|
|
environmentSection(input.envFile, input.envVarCount, input.missingVars),
|
|
`## Network\n${code(input.networkName)} (bridge)`,
|
|
`## Source\n${source}`,
|
|
].join('\n\n');
|
|
}
|